blob: e406c2d686a23bb69b360f9eae01ff13035a7f6b [file]
// Copyright (C) 2024 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 type {DataExplorerState} from './data_explorer';
import {type QueryNode, NodeType, ensureCounterAbove} from './query_node';
import {getAllNodes as getAllNodesUtil} from './query_builder/graph_utils';
import type {Trace} from '../../public/trace';
import type {SqlModules} from '../../plugins/dev.perfetto.SqlModules/sql_modules';
import {nodeRegistry} from './query_builder/node_registry';
import {restoreLegacySecondaryInputs} from './query_builder/legacy_connections';
import {
type PerfettoSqlType,
parsePerfettoSqlTypeFromString,
} from '../../trace_processor/perfetto_sql_type';
import type {ColumnInfo} from './query_builder/column_info';
// Interfaces for the serialized JSON structure
export interface SerializedNode {
nodeId: string;
type: NodeType;
state: object;
nextNodes: string[];
// Graph-level connection fields (automatically captured during serialization).
// These replace per-node connection serialization (e.g. primaryInputId inside
// node state, or node-specific fields like leftNodeId, intervalNodes, etc.).
primaryInputId?: string;
secondaryInputIds?: {[port: string]: string};
innerNodeIds?: string[];
// Deprecated: kept for backward compatibility with old saved graphs.
inputNodeIds?: string[];
}
export interface SerializedGraph {
nodes: SerializedNode[];
rootNodeIds: string[];
selectedNodeId?: string;
nodeLayouts?: {[key: string]: {x: number; y: number}};
labels?: Array<{
id: string;
x: number;
y: number;
width: number;
text: string;
}>;
isExplorerCollapsed?: boolean;
sidebarWidth?: number;
}
function serializeNode(node: QueryNode): SerializedNode {
const serialized: SerializedNode = {
nodeId: node.nodeId,
type: node.type,
state: node.attrs,
nextNodes: node.nextNodes.map((n: QueryNode) => n.nodeId),
};
// Automatically capture connections at the graph level.
if (node.primaryInput) {
serialized.primaryInputId = node.primaryInput.nodeId;
}
if (node.secondaryInputs && node.secondaryInputs.connections.size > 0) {
serialized.secondaryInputIds = {};
for (const [port, inputNode] of node.secondaryInputs.connections) {
serialized.secondaryInputIds[port.toString()] = inputNode.nodeId;
}
}
if (node.innerNodes !== undefined) {
serialized.innerNodeIds = node.innerNodes.map((n) => n.nodeId);
}
return serialized;
}
interface LabelData {
id: string;
x: number;
y: number;
width: number;
text: string;
}
/**
* Normalizes layout coordinates so that the top-left corner is at (minX, minY).
* This ensures consistent positioning when loading/exporting graphs.
*/
function normalizeLayoutCoordinates(
nodeLayouts: Map<string, {x: number; y: number}>,
labels: LabelData[],
): {
nodeLayouts: Map<string, {x: number; y: number}>;
labels: LabelData[];
} {
// Collect all x and y coordinates from node layouts and labels
const xCoords: number[] = [];
const yCoords: number[] = [];
for (const layout of nodeLayouts.values()) {
xCoords.push(layout.x);
yCoords.push(layout.y);
}
for (const label of labels) {
xCoords.push(label.x);
yCoords.push(label.y);
}
// If there are no coordinates, return as-is
if (xCoords.length === 0) {
return {nodeLayouts, labels};
}
const minX = Math.min(...xCoords);
const minY = Math.min(...yCoords);
// If already normalized (minX and minY are 0), return as-is
if (minX === 0 && minY === 0) {
return {nodeLayouts, labels};
}
// Create new normalized layouts
const normalizedLayouts = new Map<string, {x: number; y: number}>();
for (const [nodeId, layout] of nodeLayouts) {
normalizedLayouts.set(nodeId, {
x: layout.x - minX,
y: layout.y - minY,
});
}
// Normalize labels
const normalizedLabels = labels.map((label) => ({
...label,
x: label.x - minX,
y: label.y - minY,
}));
return {nodeLayouts: normalizedLayouts, labels: normalizedLabels};
}
export function serializeState(state: DataExplorerState): string {
// Use utility function to get all nodes (bidirectional traversal)
const allNodesArray = getAllNodesUtil(state.rootNodes);
const allNodes = new Map<string, QueryNode>();
for (const node of allNodesArray) {
allNodes.set(node.nodeId, node);
}
const serializedNodes = Array.from(allNodes.values()).map(serializeNode);
// Normalize coordinates so top-left corner is at (0, 0) when exporting
const normalized = normalizeLayoutCoordinates(
state.nodeLayouts,
state.labels,
);
// For backward compatibility, save the first selected node ID if any nodes are selected
const firstSelectedNodeId =
state.selectedNodes.size > 0
? state.selectedNodes.values().next().value
: undefined;
const serializedGraph: SerializedGraph = {
nodes: serializedNodes,
rootNodeIds: state.rootNodes.map((n) => n.nodeId),
selectedNodeId: firstSelectedNodeId,
nodeLayouts: Object.fromEntries(normalized.nodeLayouts),
labels: normalized.labels,
isExplorerCollapsed: state.isExplorerCollapsed,
sidebarWidth: state.sidebarWidth,
};
const replacer = (key: string, value: unknown) => {
// Only strip _trace to avoid including large trace objects
if (key === '_trace') {
return undefined;
}
// Connection info is stored in node-specific state (primaryInputId, inputNodeIds, etc.)
// so we don't need to filter them here
return typeof value === 'bigint' ? value.toString() : value;
};
return JSON.stringify(serializedGraph, replacer, 2);
}
/** Trigger a browser download of a JSON string. */
export function downloadJsonFile(json: string, filename: string): void {
const blob = new Blob([json], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function exportStateAsJson(
state: DataExplorerState,
trace: Trace,
): void {
const json = serializeState(state);
const traceName = trace.traceInfo.traceTitle.replace(
/[^a-zA-Z0-9._-]+/g,
'_',
);
const date = new Date().toISOString().slice(0, 10);
downloadJsonFile(json, `${traceName}-graph-${date}.json`);
}
// Translate a legacy string type (e.g. 'INT') to the current PerfettoSqlType
// object form (e.g. {kind: 'int'}). Returns undefined for unrecognised strings.
function legacyDeserializeType(
type: PerfettoSqlType | string | undefined,
): PerfettoSqlType | undefined {
if (type === undefined) return undefined;
if (typeof type === 'string') {
const parsed = parsePerfettoSqlTypeFromString({type});
return parsed.ok ? parsed.value : undefined;
}
if (type.kind !== undefined) return type;
return undefined;
}
function migrateColumnList(
cols: unknown[] | undefined,
): ColumnInfo[] | undefined {
if (!cols) return undefined;
return cols.map((c) => {
const col = c as {
columnName?: string;
name?: string;
type?: PerfettoSqlType | string;
checked?: boolean;
alias?: string;
typeUserModified?: boolean;
};
return {
name: col.columnName ?? col.name ?? '',
type: legacyDeserializeType(col.type),
checked: col.checked ?? false,
alias: col.alias,
typeUserModified: col.typeUserModified,
};
});
}
// Apply legacy type migrations to a node's raw serialized state before it
// reaches the node constructor. Each node type only handles what it needs.
function migrateNodeState(type: NodeType, state: unknown): unknown {
const s = state as Record<string, unknown>;
switch (type) {
case NodeType.kJoin:
return {
...s,
conditionType: (s.conditionType as string | undefined) ?? 'equality',
joinType: (s.joinType as string | undefined) ?? 'INNER',
leftColumn: (s.leftColumn as string | undefined) ?? '',
rightColumn: (s.rightColumn as string | undefined) ?? '',
sqlExpression: (s.sqlExpression as string | undefined) ?? '',
leftColumns: migrateColumnList(s.leftColumns as unknown[]),
rightColumns: migrateColumnList(s.rightColumns as unknown[]),
};
case NodeType.kModifyColumns:
return {
...s,
selectedColumns:
migrateColumnList(s.selectedColumns as unknown[]) ?? [],
};
case NodeType.kUnion:
return {
...s,
selectedColumns:
migrateColumnList(s.selectedColumns as unknown[]) ?? [],
};
case NodeType.kAggregation: {
type RawAgg = {column?: {name?: string; type?: PerfettoSqlType | string}};
const aggregations = s.aggregations as RawAgg[] | undefined;
return {
...s,
groupByColumns: migrateColumnList(s.groupByColumns as unknown[]) ?? [],
aggregations: aggregations?.map((agg) => ({
...agg,
column: agg.column
? {...agg.column, type: legacyDeserializeType(agg.column.type)}
: undefined,
})),
};
}
case NodeType.kAddColumns: {
const columnTypes = s.columnTypes as
| Record<string, PerfettoSqlType | string>
| undefined;
if (!columnTypes) return s;
return {
...s,
columnTypes: Object.fromEntries(
Object.entries(columnTypes)
.map(([k, v]) => [k, legacyDeserializeType(v)] as const)
.filter((e): e is [string, PerfettoSqlType] => e[1] !== undefined),
),
};
}
default:
// Unknown node types pass through unchanged for forward-compatibility.
return state;
}
}
function createNodeInstance(
serializedNode: SerializedNode,
trace: Trace,
sqlModules: SqlModules,
): QueryNode {
const descriptor = nodeRegistry.getByNodeType(serializedNode.type);
if (!descriptor) {
throw new Error(`Unknown node type: ${serializedNode.type}`);
}
const migratedState = migrateNodeState(
serializedNode.type,
serializedNode.state,
);
return descriptor.deserialize(migratedState as object, trace, sqlModules);
}
export function deserializeState(
json: string,
trace: Trace,
sqlModules: SqlModules,
): DataExplorerState {
const serializedGraph: SerializedGraph = JSON.parse(json);
// Basic validation to ensure the file is a Perfetto graph export.
if (
serializedGraph == null ||
typeof serializedGraph !== 'object' ||
!Array.isArray(serializedGraph.nodes) ||
!Array.isArray(serializedGraph.rootNodeIds)
) {
throw new Error(
'Invalid file format. The selected file is not a valid Perfetto graph.',
);
}
// Validate nodeLayouts if present
if (
serializedGraph.nodeLayouts != null &&
typeof serializedGraph.nodeLayouts !== 'object'
) {
throw new Error(
'Invalid file format. nodeLayouts must be an object if provided.',
);
}
const nodes = new Map<string, QueryNode>();
// First pass: create all node instances
for (const serializedNode of serializedGraph.nodes) {
const node = createNodeInstance(serializedNode, trace, sqlModules);
// Overwrite the newly generated nodeId with the one from the file
// to allow re-linking nodes correctly.
(node as {nodeId: string}).nodeId = serializedNode.nodeId;
nodes.set(serializedNode.nodeId, node);
}
// Ensure the global node counter is above all loaded IDs to prevent collisions
ensureCounterAbove(serializedGraph.nodes.map((n) => n.nodeId));
// Second pass: set forward links (nextNodes)
for (const serializedNode of serializedGraph.nodes) {
const node = nodes.get(serializedNode.nodeId);
if (!node) {
throw new Error(
`Graph is corrupted. Node with ID "${serializedNode.nodeId}" was serialized but not instantiated.`,
);
}
// Set forward links (nextNodes)
node.nextNodes = serializedNode.nextNodes.map((id) => {
const nextNode = nodes.get(id);
if (nextNode == null) {
throw new Error(`Graph is corrupted. Node "${id}" not found.`);
}
return nextNode;
});
}
// Third pass: restore backward connections from graph-level fields.
// Falls back to per-node hooks for backward compatibility with old formats.
for (const serializedNode of serializedGraph.nodes) {
const node = nodes.get(serializedNode.nodeId);
if (!node) {
throw new Error(
`Graph is corrupted. Node "${serializedNode.nodeId}" not found.`,
);
}
// Restore primary input from graph-level field, or from node state
// for backward compatibility with old saved graphs.
const primaryInputId =
serializedNode.primaryInputId ??
(serializedNode.state as {primaryInputId?: string}).primaryInputId;
if (primaryInputId) {
const inputNode = nodes.get(primaryInputId);
if (inputNode) {
node.primaryInput = inputNode;
}
}
// Restore secondary inputs from graph-level field.
if (serializedNode.secondaryInputIds && node.secondaryInputs) {
node.secondaryInputs.connections.clear();
for (const [portStr, inputNodeId] of Object.entries(
serializedNode.secondaryInputIds,
)) {
const inputNode = nodes.get(inputNodeId);
if (inputNode) {
node.secondaryInputs.connections.set(
parseInt(portStr, 10),
inputNode,
);
}
}
} else if (node.secondaryInputs) {
// Backward compatibility: old saved graphs stored connection IDs
// inside node state. A single lookup table in legacy_connections.ts
// maps each node type to the old field name pattern.
restoreLegacySecondaryInputs(node, serializedNode.state, nodes);
}
// Custom connection restoration (e.g. GroupNode rebuilding inner nodes).
const descriptor = nodeRegistry.getByNodeType(serializedNode.type);
descriptor?.deserializeConnections?.(
node,
serializedNode.state,
nodes,
serializedNode.innerNodeIds,
);
}
// Fourth pass: post-deserialization (resolve internal references, then
// update derived state). Two phases ensure that all nodes are resolved
// before any derived state is computed.
const descriptors = [...nodes.values()].map((node) => ({
node,
descriptor: nodeRegistry.getByNodeType(node.type),
}));
for (const {node, descriptor} of descriptors) {
descriptor?.postDeserialize?.(node);
}
for (const {node, descriptor} of descriptors) {
descriptor?.postDeserializeLate?.(node);
}
const rootNodes = serializedGraph.rootNodeIds.map((id) => {
const rootNode = nodes.get(id)!;
if (rootNode == null) {
throw new Error(`Graph is corrupted. Root node "${id}" not found.`);
}
return rootNode;
});
// For backward compatibility, load selectedNodeId from saved state (if present)
const selectedNode = serializedGraph.selectedNodeId
? nodes.get(serializedGraph.selectedNodeId)
: undefined;
// Use provided nodeLayouts if present, otherwise use empty map (will trigger auto-layout)
let nodeLayouts =
serializedGraph.nodeLayouts != null
? new Map(Object.entries(serializedGraph.nodeLayouts))
: new Map<string, {x: number; y: number}>();
// Normalize coordinates so top-left corner is at (minX, minY)
let labels = serializedGraph.labels ?? [];
const normalized = normalizeLayoutCoordinates(nodeLayouts, labels);
nodeLayouts = normalized.nodeLayouts;
labels = normalized.labels;
return {
rootNodes,
selectedNodes: selectedNode ? new Set([selectedNode.nodeId]) : new Set(),
nodeLayouts,
labels,
isExplorerCollapsed: serializedGraph.isExplorerCollapsed,
sidebarWidth: serializedGraph.sidebarWidth,
};
}
export function importStateFromJson(
file: File,
trace: Trace,
sqlModules: SqlModules,
onStateLoaded: (state: DataExplorerState) => void,
): void {
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target?.result as string;
if (!json) {
throw new Error('The selected file is empty or could not be read.');
}
const newState = deserializeState(json, trace, sqlModules);
onStateLoaded(newState);
};
reader.readAsText(file);
}