| // 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(); |
| } |