blob: 926893c489dffee48f665377e2fb1279fa1376ef [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {assetSrc} from '../../base/assets';
import {showModal} from '../../widgets/modal';
import type {Trace} from '../../public/trace';
import type {QueryNode} from './query_node';
import {
serializeState,
exportStateAsJson,
deserializeState,
downloadJsonFile,
} from './json_handler';
import {nodeRegistry} from './query_builder/node_registry';
import {SlicesSourceNode} from './query_builder/nodes/sources/slices_source';
import {
showStateOverwriteWarning,
showExportWarning,
} from './query_builder/widgets';
import {recentGraphsStorage} from './recent_graphs';
import type {DataExplorerState, DashboardTabState} from './data_explorer';
import type {DataExplorerTab} from './data_explorer';
import {parsePbtxtToState} from './pbtxt_import';
import {
serializeDashboardsForTab,
type SerializedDashboard,
} from './data_explorer_tabs_storage';
import {
validateDashboardItems,
parseBrushFilters,
} from './dashboard/dashboard_registry';
import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules';
import {isTableEffectivelyDisabled} from '../dev.perfetto.SqlModules/sql_modules';
// Dependencies needed by graph I/O operations.
export interface GraphIODeps {
readonly trace: Trace;
readonly sqlModules: SqlModules;
readonly onStateUpdate: (
update:
| DataExplorerState
| ((currentState: DataExplorerState) => DataExplorerState),
) => void;
readonly cleanupExistingNodes: (rootNodes: QueryNode[]) => Promise<void>;
}
// Shows confirmation dialog if there are unsaved changes, and finalizes
// the current graph before loading a new one. Returns true if the user
// confirmed (or there was nothing to confirm), false if cancelled.
export async function confirmAndFinalizeCurrentGraph(
state: DataExplorerState,
): Promise<boolean> {
if (state.rootNodes.length > 0 || state.labels.length > 0) {
const confirmed = await showStateOverwriteWarning();
if (!confirmed) return false;
recentGraphsStorage.finalizeCurrentGraph();
}
return true;
}
export async function exportGraph(
state: DataExplorerState,
trace: Trace,
): Promise<void> {
const confirmed = await showExportWarning();
if (!confirmed) return;
exportStateAsJson(state, trace);
}
// Common method to load state from a JSON string.
// Handles cleanup of existing nodes and state update.
export async function loadGraphFromJson(
deps: GraphIODeps,
currentRootNodes: QueryNode[],
json: string,
): Promise<void> {
await deps.cleanupExistingNodes(currentRootNodes);
const newState = deserializeState(json, deps.trace, deps.sqlModules);
deps.onStateUpdate((currentState) => ({
...newState,
loadGeneration: (currentState.loadGeneration ?? 0) + 1,
}));
}
export async function importGraph(
deps: GraphIODeps,
onCreateTab: (title: string, state: DataExplorerState) => void,
): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.pbtxt';
input.onchange = async (event) => {
const files = (event.target as HTMLInputElement).files;
if (files && files.length > 0) {
const file = files[0];
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target?.result as string;
if (!text) {
console.error('The selected file is empty or could not be read.');
return;
}
try {
const isPbtxt = file.name.toLowerCase().endsWith('.pbtxt');
const newState = isPbtxt
? await parsePbtxtToState(text, deps.trace, deps.sqlModules)
: deserializeState(text, deps.trace, deps.sqlModules);
const name = file.name.replace(/\.(json|pbtxt)$/i, '');
onCreateTab(name, newState);
} catch (error) {
console.error('Failed to import graph:', error);
showModal({
title: 'Import Failed',
content: () =>
m(
'div',
`Failed to import: ${error instanceof Error ? error.message : String(error)}`,
),
buttons: [],
});
}
};
reader.readAsText(file);
}
};
input.click();
}
// Centralized method to load JSON from a URL path.
// Handles confirmation, fetching, and error handling.
export async function loadGraphFromPath(
deps: GraphIODeps,
state: DataExplorerState,
jsonPath: string,
errorTitle: string = 'Failed to Load',
): Promise<void> {
if (!(await confirmAndFinalizeCurrentGraph(state))) return;
try {
const response = await fetch(assetSrc(jsonPath));
if (!response.ok) {
throw new Error(
`Failed to load: ${response.status} ${response.statusText}`,
);
}
const json = await response.text();
await loadGraphFromJson(deps, state.rootNodes, json);
} catch (error) {
console.error(`Failed to load from ${jsonPath}:`, error);
showModal({
title: errorTitle,
content: () =>
m(
'div',
`An error occurred while loading: ${error instanceof Error ? error.message : String(error)}`,
),
buttons: [],
});
}
}
export async function createDataExplorerGraph(
deps: GraphIODeps,
): Promise<void> {
const {sqlModules, trace} = deps;
const coreNodes: QueryNode[] = [];
const rightNodes: QueryNode[] = [];
const tableDescriptor = nodeRegistry.get('table');
// Helper to create table nodes for a given importance level.
function createTableNodes(importance: string, target: QueryNode[]): void {
if (!tableDescriptor) return;
const tables = sqlModules
.listTables()
.filter((table) => table.importance === importance);
for (const sqlTable of tables) {
try {
if (isTableEffectivelyDisabled(sqlModules, sqlTable.name)) {
continue;
}
const tableNode = tableDescriptor.factory(
{sqlTable, sqlModules, trace},
{allNodes: [...coreNodes, ...rightNodes]},
);
target.push(tableNode);
} catch (error) {
console.error(
`Failed to create table node for ${sqlTable.name}:`,
error,
);
}
}
}
// Create core table nodes (left column)
createTableNodes('core', coreNodes);
// Create slices source node (right side), only if slice data exists
if (!isTableEffectivelyDisabled(sqlModules, 'slice')) {
const slicesNode = new SlicesSourceNode({}, {sqlModules, trace});
rightNodes.push(slicesNode);
}
// Create high-importance table nodes (right side)
createTableNodes('high', rightNodes);
const allNodes = [...coreNodes, ...rightNodes];
if (allNodes.length > 0) {
const newNodeLayouts = new Map<string, {x: number; y: number}>();
const NODE_WIDTH = 300;
const CORE_COL_WIDTH = 200;
const START_X = 50;
const START_Y = 50;
// Left column: "Core tables" label + core nodes tightly stacked.
// LABEL_HEIGHT accounts for the label above the first core node.
const CORE_GAP_Y = 55;
const LABEL_HEIGHT = CORE_GAP_Y;
const LABEL_WIDTH = 100;
const coreStartY = START_Y + LABEL_HEIGHT;
coreNodes.forEach((node, index) => {
newNodeLayouts.set(node.nodeId, {
x: START_X,
y: coreStartY + index * CORE_GAP_Y,
});
});
// Right side: slices + high-importance tables in a tight grid,
// vertically centered relative to the core column.
const GROUP_GAP = 50;
const RIGHT_GAP_X = 10;
const RIGHT_GAP_Y = 80;
const rightStartX = START_X + CORE_COL_WIDTH + GROUP_GAP;
const rightCols = Math.max(1, Math.ceil(Math.sqrt(rightNodes.length)));
const rightRows = Math.ceil(rightNodes.length / rightCols);
const coreColumnHeight = LABEL_HEIGHT + (coreNodes.length - 1) * CORE_GAP_Y;
const rightGroupHeight = (rightRows - 1) * RIGHT_GAP_Y;
const rightStartY =
START_Y + Math.max(0, (coreColumnHeight - rightGroupHeight) / 2);
rightNodes.forEach((node, index) => {
const col = index % rightCols;
const row = Math.floor(index / rightCols);
newNodeLayouts.set(node.nodeId, {
x: rightStartX + col * (NODE_WIDTH + RIGHT_GAP_X),
y: rightStartY + row * RIGHT_GAP_Y,
});
});
// Add labels above each group when the group has nodes.
const labels: Array<{
id: string;
x: number;
y: number;
width: number;
text: string;
}> = [];
if (coreNodes.length > 0) {
labels.push({
id: 'core-tables-label',
x: START_X + 30,
y: START_Y,
width: LABEL_WIDTH,
text: 'Core tables',
});
}
if (rightNodes.length > 0) {
labels.push({
id: 'right-tables-label',
x: rightStartX,
y: rightStartY - LABEL_HEIGHT,
width: 200,
text: 'Tables important for this trace',
});
}
deps.onStateUpdate((currentState) => ({
...currentState,
rootNodes: allNodes,
nodeLayouts: newNodeLayouts,
selectedNodes: new Set([allNodes[0].nodeId]),
labels,
loadGeneration: (currentState.loadGeneration ?? 0) + 1,
}));
}
}
// --- Whole-tab export/import ---
export interface SerializedTabExport {
version: number;
title: string;
graph: string;
dashboards?: SerializedDashboard[];
}
export function isSerializedTabExport(
obj: unknown,
): obj is SerializedTabExport {
if (typeof obj !== 'object' || obj === null) return false;
const rec = obj as Record<string, unknown>;
return (
typeof rec.version === 'number' &&
typeof rec.title === 'string' &&
typeof rec.graph === 'string'
);
}
export function deserializeDashboardsFromExport(
serialized?: unknown,
): DashboardTabState[] | undefined {
if (!Array.isArray(serialized) || serialized.length === 0) return undefined;
const result: DashboardTabState[] = [];
for (const raw of serialized) {
if (typeof raw !== 'object' || raw === null) continue;
const db = raw as Record<string, unknown>;
if (typeof db.id !== 'string') continue;
result.push({
id: db.id,
items: validateDashboardItems(db.items as unknown[] | undefined) ?? [],
brushFilters:
db.brushFilters !== undefined &&
typeof db.brushFilters === 'object' &&
!Array.isArray(db.brushFilters)
? parseBrushFilters(db.brushFilters as Record<string, unknown[]>)
: new Map(),
});
}
return result.length > 0 ? result : undefined;
}
export async function exportTab(
tab: DataExplorerTab,
trace: Trace,
): Promise<void> {
const confirmed = await showExportWarning();
if (!confirmed) return;
const graphJson = serializeState(tab.state);
const dashboards = serializeDashboardsForTab(tab);
const exported: SerializedTabExport = {
version: 1,
title: tab.title,
graph: graphJson,
dashboards,
};
const json = JSON.stringify(exported, null, 2);
const traceName = trace.traceInfo.traceTitle.replace(
/[^a-zA-Z0-9._-]+/g,
'_',
);
const tabName = tab.title.replace(/[^a-zA-Z0-9._-]+/g, '_');
const date = new Date().toISOString().slice(0, 10);
downloadJsonFile(json, `${traceName}-tab-${tabName}-${date}.json`);
}
export function importTab(
deps: GraphIODeps,
onCreateTab: (
title: string,
state: DataExplorerState,
dashboards?: DashboardTabState[],
) => void,
): void {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.pbtxt';
input.onchange = (event) => {
const files = (event.target as HTMLInputElement).files;
if (files === null || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target?.result;
if (typeof text !== 'string' || text.length === 0) {
console.error('The selected file is empty or could not be read.');
return;
}
try {
const isPbtxt = file.name.toLowerCase().endsWith('.pbtxt');
if (isPbtxt) {
const newState = await parsePbtxtToState(
text,
deps.trace,
deps.sqlModules,
);
const name = file.name.replace(/\.pbtxt$/i, '');
onCreateTab(name, newState);
} else {
const parsed: unknown = JSON.parse(text);
if (isSerializedTabExport(parsed)) {
const newState = deserializeState(
parsed.graph,
deps.trace,
deps.sqlModules,
);
const dashboards = deserializeDashboardsFromExport(
parsed.dashboards,
);
const name = parsed.title ?? file.name.replace(/\.json$/i, '');
onCreateTab(name, newState, dashboards);
} else {
// Plain graph import (backward compat)
const newState = deserializeState(
text,
deps.trace,
deps.sqlModules,
);
const name = file.name.replace(/\.json$/i, '');
onCreateTab(name, newState);
}
}
} catch (error) {
console.error('Failed to import tab:', error);
showModal({
title: 'Import Failed',
content: () =>
m(
'div',
`Failed to import: ${error instanceof Error ? error.message : String(error)}`,
),
buttons: [],
});
}
};
reader.readAsText(file);
};
input.click();
}