blob: 35619a902fd8eff36a2ee9f8da8fc4c3a1a92ff0 [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 {ExplorePageState} from './explore_page';
import {QueryNode, NodeType, ensureCounterAbove} from './query_node';
import {getAllNodes as getAllNodesUtil} from './query_builder/graph_utils';
import {Trace} from '../../public/trace';
import {SqlModules} from '../../plugins/dev.perfetto.SqlModules/sql_modules';
import {nodeRegistry} from './query_builder/node_registry';
// Interfaces for the serialized JSON structure
export interface SerializedNode {
nodeId: string;
type: NodeType;
state: object;
nextNodes: string[];
// Input node IDs (for multi-source nodes like Union, Merge, IntervalIntersect)
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 {
if (typeof node.serializeState !== 'function') {
throw new Error(`Node type ${node.type} is not serializable.`);
}
return {
nodeId: node.nodeId,
type: node.type,
state: node.serializeState(),
nextNodes: node.nextNodes.map((n: QueryNode) => n.nodeId),
};
}
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: ExplorePageState): 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);
}
export function exportStateAsJson(state: ExplorePageState, trace: Trace): void {
const json = serializeState(state);
const blob = new Blob([json], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const traceName = trace.traceInfo.traceTitle.replace(
/[^a-zA-Z0-9._-]+/g,
'_',
);
const date = new Date().toISOString().slice(0, 10);
a.download = `${traceName}-graph-${date}.json`;
a.click();
URL.revokeObjectURL(url);
}
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}`);
}
return descriptor.deserialize(serializedNode.state, trace, sqlModules);
}
export function deserializeState(
json: string,
trace: Trace,
sqlModules: SqlModules,
): ExplorePageState {
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: set backward connections using the node registry
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.`,
);
}
const descriptor = nodeRegistry.getByNodeType(serializedNode.type);
if (!descriptor) {
throw new Error(`Unknown node type: ${serializedNode.type}`);
}
// Restore primary input for nodes that have one
const hasPrimary =
descriptor.hasPrimaryInput ?? descriptor.type === 'modification';
if (hasPrimary) {
const serializedState = serializedNode.state as {
primaryInputId?: string;
};
if (serializedState.primaryInputId) {
const inputNode = nodes.get(serializedState.primaryInputId);
if (inputNode) {
node.primaryInput = inputNode;
}
}
}
// Node-specific connection deserialization
descriptor.deserializeConnections?.(node, serializedNode.state, nodes);
}
// 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: ExplorePageState) => 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);
}