| // 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 m from 'mithril'; | |
| import type SqlModulesPlugin from '../dev.perfetto.SqlModules'; | |
| import {Builder} from './query_builder/builder'; | |
| import type {QueryNode} from './query_node'; | |
| import {ensureAllNodeActions} from './node_actions'; | |
| import type {Trace} from '../../public/trace'; | |
| import {getOrCreate} from '../../base/utils'; | |
| import {shortUuid} from '../../base/uuid'; | |
| import {MenuItem} from '../../widgets/menu'; | |
| import {Tabs, type TabsTab} from '../../widgets/tabs'; | |
| import {Button, ButtonBar} from '../../widgets/button'; | |
| import {Icons} from '../../base/semantic_icons'; | |
| import {showModal} from '../../widgets/modal'; | |
| import {serializeState, deserializeState} from './json_handler'; | |
| import { | |
| confirmAndFinalizeCurrentGraph, | |
| exportGraph, | |
| exportTab, | |
| importGraph, | |
| importTab, | |
| loadGraphFromJson, | |
| loadGraphFromPath, | |
| createDataExplorerGraph, | |
| type GraphIODeps, | |
| } from './graph_io'; | |
| import {registerCoreNodes} from './query_builder/core_nodes'; | |
| import {nodeRegistry} from './query_builder/node_registry'; | |
| import {QueryExecutionService} from './query_builder/query_execution_service'; | |
| import {CleanupManager} from './query_builder/cleanup_manager'; | |
| import {HistoryManager} from './history_manager'; | |
| import {getPrimarySelectedNode} from './selection_utils'; | |
| import { | |
| getAllNodes, | |
| createGroupFromSelection, | |
| applyGroupRewiring, | |
| ungroupNode, | |
| } from './query_builder/graph_utils'; | |
| import {GroupNode} from './query_builder/nodes/group_node'; | |
| import { | |
| cleanupExistingNodes, | |
| addOperationNode, | |
| addSourceNode, | |
| addAndConnectTable, | |
| insertNodeAtPort, | |
| clearAllNodes, | |
| duplicateNode, | |
| deleteNode, | |
| deleteSelectedNodes, | |
| removeNodeConnection, | |
| } from './node_crud_operations'; | |
| import {Dashboard} from './dashboard/dashboard'; | |
| import {isDashboardNode} from './query_builder/nodes/dashboard_node'; | |
| import { | |
| dashboardRegistry, | |
| type DashboardBrushFilter, | |
| type DashboardItem, | |
| } from './dashboard/dashboard_registry'; | |
| import type {NodeCrudDeps} from './node_crud_operations'; | |
| import {addFilter, addColumnFromJoinid} from './datagrid_node_creation'; | |
| import {showHelp} from './help_modal'; | |
| import {copySelectedNodes, pasteClipboardNodes} from './clipboard_operations'; | |
| import type {ClipboardResult} from './clipboard_operations'; | |
| import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules'; | |
| registerCoreNodes(); | |
| /** State for a single dashboard within a graph tab. */ | |
| export interface DashboardTabState { | |
| readonly id: string; | |
| items: DashboardItem[]; | |
| brushFilters: Map<string, DashboardBrushFilter[]>; | |
| } | |
| export interface DataExplorerTab { | |
| readonly id: string; | |
| title: string; | |
| state: DataExplorerState; | |
| // Each graph tab has one or more dashboards. | |
| dashboards: DashboardTabState[]; | |
| // Which sub-tab is active: 'graph' (default) or a dashboard ID. | |
| activeSubTab?: string; | |
| } | |
| export interface DataExplorerState { | |
| rootNodes: QueryNode[]; | |
| selectedNodes: ReadonlySet<string>; // Set of selected node IDs for multi-selection | |
| nodeLayouts: Map<string, {x: number; y: number}>; | |
| labels: Array<{ | |
| id: string; | |
| x: number; | |
| y: number; | |
| width: number; | |
| text: string; | |
| }>; | |
| isExplorerCollapsed?: boolean; | |
| sidebarWidth?: number; | |
| loadGeneration?: number; // Incremented each time content is loaded | |
| } | |
| type StateUpdateFn = ( | |
| update: | |
| DataExplorerState | ((current: DataExplorerState) => DataExplorerState), | |
| ) => void; | |
| interface DataExplorerAttrs { | |
| readonly trace: Trace; | |
| readonly sqlModulesPlugin: SqlModulesPlugin; | |
| // Active tab's state (convenience reference) | |
| readonly state: DataExplorerState; | |
| // State updater for the active tab (used by keyboard handlers, etc.) | |
| readonly onStateUpdate: StateUpdateFn; | |
| // Factory that returns a per-tab state update function. | |
| // Used in renderTabContent to create tab-scoped updaters that remain | |
| // correct even if the active tab changes while async work is in flight. | |
| readonly makeOnStateUpdate: (tabId: string) => StateUpdateFn; | |
| // Multi-tab props | |
| readonly tabs: DataExplorerTab[]; | |
| readonly activeTabId: string; | |
| readonly onTabAdd: () => void; | |
| readonly onTabClose: (tabId: string) => void; | |
| readonly onTabChange: (tabId: string) => void; | |
| readonly onTabRename: (tabId: string, newName: string) => void; | |
| readonly onTabReorder: ( | |
| draggedId: string, | |
| beforeId: string | undefined, | |
| ) => void; | |
| // Creates a new tab with the given title and state, inserted after the | |
| // tab identified by afterTabId. Optionally accepts pre-built dashboards; | |
| // when omitted a single empty dashboard is created. | |
| readonly onTabAddWithState: ( | |
| title: string, | |
| state: DataExplorerState, | |
| afterTabId: string, | |
| dashboards?: DashboardTabState[], | |
| ) => void; | |
| // Notify the plugin that dashboard-local state changed (items/filters) | |
| // so it can trigger debounced saves. | |
| readonly onDashboardStateChange: () => void; | |
| } | |
| // Per-tab service instances that live for the lifetime of the tab. | |
| interface TabServices { | |
| queryExecutionService: QueryExecutionService; | |
| cleanupManager: CleanupManager; | |
| historyManager?: HistoryManager; | |
| initializedNodes: Set<string>; | |
| executeFn?: () => Promise<void>; | |
| } | |
| export class DataExplorer implements m.ClassComponent<DataExplorerAttrs> { | |
| // Shared clipboard across all tabs (not persisted). | |
| private clipboard?: ClipboardResult; | |
| // Per-tab services, keyed by tab ID. | |
| private tabServices = new Map<string, TabServices>(); | |
| private getOrCreateServices( | |
| tabId: string, | |
| engine: Trace['engine'], | |
| ): TabServices { | |
| return getOrCreate(this.tabServices, tabId, () => { | |
| const qes = new QueryExecutionService(engine); | |
| return { | |
| queryExecutionService: qes, | |
| cleanupManager: new CleanupManager(qes), | |
| initializedNodes: new Set<string>(), | |
| }; | |
| }); | |
| } | |
| private getActiveServices(activeTabId: string): TabServices | undefined { | |
| return this.tabServices.get(activeTabId); | |
| } | |
| private selectNode(attrs: DataExplorerAttrs, node: QueryNode) { | |
| attrs.onStateUpdate((currentState) => ({ | |
| ...currentState, | |
| selectedNodes: new Set([node.nodeId]), | |
| })); | |
| } | |
| private addNodeToSelection(attrs: DataExplorerAttrs, node: QueryNode) { | |
| attrs.onStateUpdate((currentState) => { | |
| const newSelectedNodes = new Set(currentState.selectedNodes); | |
| newSelectedNodes.add(node.nodeId); | |
| return { | |
| ...currentState, | |
| selectedNodes: newSelectedNodes, | |
| }; | |
| }); | |
| } | |
| private removeNodeFromSelection(attrs: DataExplorerAttrs, nodeId: string) { | |
| attrs.onStateUpdate((currentState) => { | |
| const newSelectedNodes = new Set(currentState.selectedNodes); | |
| newSelectedNodes.delete(nodeId); | |
| return { | |
| ...currentState, | |
| selectedNodes: newSelectedNodes, | |
| }; | |
| }); | |
| } | |
| private deselectNode(attrs: DataExplorerAttrs) { | |
| attrs.onStateUpdate((currentState) => ({ | |
| ...currentState, | |
| selectedNodes: new Set(), | |
| })); | |
| } | |
| private handleCopy(attrs: DataExplorerAttrs): void { | |
| const result = copySelectedNodes(attrs.state); | |
| if (result !== undefined) { | |
| this.clipboard = result; | |
| } | |
| } | |
| private handlePaste(attrs: DataExplorerAttrs): void { | |
| if (this.clipboard === undefined) return; | |
| const clipboard = this.clipboard; | |
| attrs.onStateUpdate((currentState) => { | |
| const result = pasteClipboardNodes(currentState, clipboard); | |
| if (result === undefined) return currentState; | |
| return {...currentState, ...result}; | |
| }); | |
| } | |
| private handleKeyDown( | |
| event: KeyboardEvent, | |
| attrs: DataExplorerAttrs, | |
| nodeCrudDeps: NodeCrudDeps, | |
| graphIODeps: GraphIODeps, | |
| ) { | |
| const {state} = attrs; | |
| // Do not interfere with text inputs | |
| if ( | |
| event.target instanceof HTMLInputElement || | |
| event.target instanceof HTMLTextAreaElement | |
| ) { | |
| return; | |
| } | |
| // Handle "?" to show help modal | |
| if (event.key === '?') { | |
| showHelp(); | |
| event.preventDefault(); | |
| return; | |
| } | |
| const activeServices = this.getActiveServices(attrs.activeTabId); | |
| // Handle Ctrl+Enter to execute selected node | |
| if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { | |
| const selectedNode = getPrimarySelectedNode( | |
| state.selectedNodes, | |
| state.rootNodes, | |
| ); | |
| if ( | |
| selectedNode !== undefined && | |
| activeServices?.executeFn !== undefined | |
| ) { | |
| void activeServices.executeFn(); | |
| event.preventDefault(); | |
| } | |
| return; | |
| } | |
| // Handle copy shortcut - text selection takes priority over node copy, | |
| // so users can copy proto/SQL from the sidebar, error messages, etc. | |
| if ((event.ctrlKey || event.metaKey) && event.key === 'c') { | |
| const textSelection = window.getSelection(); | |
| const hasTextSelected = | |
| textSelection !== null && textSelection.toString().length > 0; | |
| if (!hasTextSelected && state.selectedNodes.size > 0) { | |
| this.handleCopy(attrs); | |
| event.preventDefault(); | |
| } | |
| return; | |
| } | |
| if ((event.ctrlKey || event.metaKey) && event.key === 'v') { | |
| this.handlePaste(attrs); | |
| event.preventDefault(); | |
| return; | |
| } | |
| // Handle delete key - delete all selected nodes | |
| if (event.key === 'Delete' || event.key === 'Backspace') { | |
| if (state.selectedNodes.size > 0) { | |
| deleteSelectedNodes(nodeCrudDeps, attrs.state); | |
| event.preventDefault(); | |
| } | |
| return; | |
| } | |
| // For other shortcuts, skip if a node is selected to avoid interfering | |
| // with node-specific interactions | |
| if (state.selectedNodes.size > 0) { | |
| return; | |
| } | |
| // Handle undo/redo shortcuts | |
| if ((event.ctrlKey || event.metaKey) && event.key === 'z') { | |
| if (event.shiftKey) { | |
| // Ctrl+Shift+Z or Cmd+Shift+Z for Redo | |
| this.handleRedo(attrs); | |
| event.preventDefault(); | |
| return; | |
| } else { | |
| // Ctrl+Z or Cmd+Z for Undo | |
| this.handleUndo(attrs); | |
| event.preventDefault(); | |
| return; | |
| } | |
| } | |
| // Also support Ctrl+Y for Redo on Windows/Linux | |
| if ((event.ctrlKey || event.metaKey) && event.key === 'y') { | |
| this.handleRedo(attrs); | |
| event.preventDefault(); | |
| return; | |
| } | |
| // Handle source node creation shortcuts | |
| for (const [id, descriptor] of nodeRegistry.list()) { | |
| if ( | |
| descriptor.type === 'source' && | |
| descriptor.hotkey && | |
| event.key.toLowerCase() === descriptor.hotkey.toLowerCase() | |
| ) { | |
| addSourceNode(nodeCrudDeps, attrs.state, id); | |
| event.preventDefault(); // Prevent default browser actions for this key | |
| return; | |
| } | |
| } | |
| // Handle other shortcuts | |
| switch (event.key) { | |
| case 'i': | |
| importTab(graphIODeps, (title, newState, dashboards) => | |
| attrs.onTabAddWithState( | |
| title, | |
| newState, | |
| attrs.activeTabId, | |
| dashboards, | |
| ), | |
| ); | |
| break; | |
| case 'e': { | |
| const activeTab = attrs.tabs.find((t) => t.id === attrs.activeTabId); | |
| if (activeTab !== undefined) { | |
| void exportTab(activeTab, attrs.trace); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| private handleUndo(attrs: DataExplorerAttrs) { | |
| const historyManager = this.getActiveServices( | |
| attrs.activeTabId, | |
| )?.historyManager; | |
| if (!historyManager) { | |
| console.warn('Cannot undo: history manager not initialized'); | |
| return; | |
| } | |
| const previousState = historyManager.undo(); | |
| if (previousState) { | |
| attrs.onStateUpdate(previousState); | |
| } | |
| } | |
| private handleRedo(attrs: DataExplorerAttrs) { | |
| const historyManager = this.getActiveServices( | |
| attrs.activeTabId, | |
| )?.historyManager; | |
| if (!historyManager) { | |
| console.warn('Cannot redo: history manager not initialized'); | |
| return; | |
| } | |
| const nextState = historyManager.redo(); | |
| if (nextState) { | |
| attrs.onStateUpdate(nextState); | |
| } | |
| } | |
| // Render the content for a single tab. This includes the Builder and all | |
| // per-tab service setup (history, query execution, cleanup, etc.). | |
| private renderTabContent( | |
| attrs: DataExplorerAttrs, | |
| tab: DataExplorerTab, | |
| sqlModules: SqlModules, | |
| ): m.Children { | |
| const {trace} = attrs; | |
| const {state} = tab; | |
| const isActive = tab.id === attrs.activeTabId; | |
| const services = this.getOrCreateServices(tab.id, trace.engine); | |
| // Initialize history manager for this tab if not already done | |
| if (services.historyManager === undefined) { | |
| services.historyManager = new HistoryManager(trace, sqlModules); | |
| services.historyManager.pushState(state); | |
| } | |
| // Create a per-tab state updater that wraps history tracking. | |
| // Uses makeOnStateUpdate(tab.id) so the updater is always bound to THIS | |
| // tab's state, even if the active tab changes while async work is in flight. | |
| const tabOnStateUpdate = attrs.makeOnStateUpdate(tab.id); | |
| const wrappedOnStateUpdate: StateUpdateFn = (update) => { | |
| tabOnStateUpdate((currentState) => { | |
| const newState = | |
| typeof update === 'function' ? update(currentState) : update; | |
| services.historyManager?.pushState(newState); | |
| return newState; | |
| }); | |
| }; | |
| const wrappedAttrs = { | |
| ...attrs, | |
| state, | |
| onStateUpdate: wrappedOnStateUpdate, | |
| }; | |
| // Construct deps objects once per render cycle. | |
| const nodeCrudDeps: NodeCrudDeps = { | |
| trace, | |
| sqlModules, | |
| onStateUpdate: wrappedOnStateUpdate, | |
| cleanupManager: services.cleanupManager, | |
| initializedNodes: services.initializedNodes, | |
| nodeActionHandlers: { | |
| onAddAndConnectTable: ( | |
| tableName: string, | |
| node: QueryNode, | |
| portIndex: number, | |
| ) => { | |
| addAndConnectTable( | |
| nodeCrudDeps, | |
| wrappedAttrs.state, | |
| tableName, | |
| node, | |
| portIndex, | |
| ); | |
| }, | |
| onInsertNodeAtPort: ( | |
| node: QueryNode, | |
| portIndex: number, | |
| descriptorKey: string, | |
| ) => { | |
| insertNodeAtPort( | |
| nodeCrudDeps, | |
| wrappedAttrs.state, | |
| node, | |
| portIndex, | |
| descriptorKey, | |
| ); | |
| }, | |
| }, | |
| }; | |
| const graphIODeps: GraphIODeps = { | |
| trace, | |
| sqlModules, | |
| onStateUpdate: wrappedOnStateUpdate, | |
| cleanupExistingNodes: (rootNodes) => | |
| cleanupExistingNodes( | |
| services.cleanupManager, | |
| services.initializedNodes, | |
| rootNodes, | |
| ), | |
| }; | |
| const allNodes = getAllNodes(state.rootNodes); | |
| // Set callbacks on dashboard nodes so they can resolve table names and | |
| // trigger execution even when the graph tab is not active. | |
| for (const node of allNodes) { | |
| if (!isDashboardNode(node)) continue; | |
| // Propagate the stable tab ID so sources are namespaced by graph. | |
| // Re-publish if the graphId changed so the registry has the right key. | |
| const prevGraphId = node.graphId; | |
| node.graphId = tab.id; | |
| if (prevGraphId !== tab.id) { | |
| node.onPrevNodesUpdated?.(); | |
| } | |
| if (node.context.getTableNameForNode === undefined) { | |
| node.context.getTableNameForNode = (nodeId: string) => | |
| services.queryExecutionService.getTableName(nodeId); | |
| } | |
| if (node.context.requestNodeExecution === undefined) { | |
| node.context.requestNodeExecution = (nodeId: string) => { | |
| const targetNode = allNodes.find((n) => n.nodeId === nodeId); | |
| if (targetNode === undefined) return Promise.resolve(); | |
| return services.queryExecutionService | |
| .processNode(targetNode, trace.engine, allNodes, {manual: true}) | |
| .then(() => {}); | |
| }; | |
| } | |
| } | |
| // Only do full node processing for the active tab to avoid unnecessary work | |
| if (isActive) { | |
| // Provide getTableNameForNode callback for all nodes (used by | |
| // add_columns_node and others). | |
| for (const node of allNodes) { | |
| if (node.context.getTableNameForNode === undefined) { | |
| node.context.getTableNameForNode = (nodeId: string) => | |
| services.queryExecutionService.getTableName(nodeId); | |
| } | |
| } | |
| // Ensure all nodes have actions initialized | |
| ensureAllNodeActions( | |
| allNodes, | |
| services.initializedNodes, | |
| nodeCrudDeps.nodeActionHandlers, | |
| ); | |
| // Store deps for keyboard handler access | |
| this.activeNodeCrudDeps = nodeCrudDeps; | |
| this.activeGraphIODeps = graphIODeps; | |
| } | |
| const doUngroup = (groupNode: GroupNode) => { | |
| wrappedOnStateUpdate((currentState) => { | |
| // ungroupNode performs in-place graph mutations (rewiring | |
| // nextNodes/primaryInput) matching the applyGroupRewiring pattern. | |
| ungroupNode(groupNode); | |
| const newRootNodes = currentState.rootNodes | |
| .filter((n) => n.nodeId !== groupNode.nodeId) | |
| .concat(groupNode.innerNodes); | |
| const newNodeLayouts = new Map(currentState.nodeLayouts); | |
| newNodeLayouts.delete(groupNode.nodeId); | |
| const innerIds = new Set(groupNode.innerNodes.map((n) => n.nodeId)); | |
| return { | |
| ...currentState, | |
| rootNodes: newRootNodes, | |
| nodeLayouts: newNodeLayouts, | |
| selectedNodes: innerIds, | |
| }; | |
| }); | |
| }; | |
| // Sized wrapper so DrawerPanel can read a non-zero clientHeight; | |
| // Gate (display:contents) elements have clientHeight === 0. | |
| return m( | |
| '.pf-data-explorer__tab-content', | |
| m(Builder, { | |
| trace, | |
| sqlModules, | |
| queryExecutionService: services.queryExecutionService, | |
| rootNodes: state.rootNodes, | |
| selectedNodes: state.selectedNodes, | |
| nodeLayouts: state.nodeLayouts, | |
| labels: state.labels, | |
| loadGeneration: state.loadGeneration, | |
| isExplorerCollapsed: state.isExplorerCollapsed, | |
| sidebarWidth: state.sidebarWidth, | |
| onExecuteReady: (executeFn) => { | |
| services.executeFn = executeFn; | |
| }, | |
| onRootNodeCreated: (node) => { | |
| wrappedOnStateUpdate((currentState) => ({ | |
| ...currentState, | |
| rootNodes: [...currentState.rootNodes, node], | |
| selectedNodes: new Set([node.nodeId]), | |
| })); | |
| }, | |
| onExplorerCollapsedChange: (collapsed) => { | |
| wrappedOnStateUpdate((currentState) => ({ | |
| ...currentState, | |
| isExplorerCollapsed: collapsed, | |
| })); | |
| }, | |
| onSidebarWidthChange: (width) => { | |
| wrappedOnStateUpdate((currentState) => ({ | |
| ...currentState, | |
| sidebarWidth: width, | |
| })); | |
| }, | |
| graphCallbacks: { | |
| onNodeSelected: (node) => { | |
| this.selectNode(wrappedAttrs, node); | |
| }, | |
| onNodeAddToSelection: (node) => { | |
| this.addNodeToSelection(wrappedAttrs, node); | |
| }, | |
| onNodeRemoveFromSelection: (nodeId) => { | |
| this.removeNodeFromSelection(wrappedAttrs, nodeId); | |
| }, | |
| onDeselect: () => this.deselectNode(wrappedAttrs), | |
| onNodeLayoutChange: (nodeId, layout) => { | |
| wrappedOnStateUpdate((currentState) => { | |
| const newNodeLayouts = new Map(currentState.nodeLayouts); | |
| newNodeLayouts.set(nodeId, layout); | |
| return { | |
| ...currentState, | |
| nodeLayouts: newNodeLayouts, | |
| }; | |
| }); | |
| }, | |
| onLabelsChange: (labels) => { | |
| wrappedOnStateUpdate((currentState) => ({ | |
| ...currentState, | |
| labels, | |
| })); | |
| }, | |
| onAddSourceNode: (id) => { | |
| addSourceNode(nodeCrudDeps, wrappedAttrs.state, id); | |
| }, | |
| onAddOperationNode: (id, node) => { | |
| addOperationNode(nodeCrudDeps, wrappedAttrs.state, node, id); | |
| }, | |
| onClearAllNodes: () => | |
| clearAllNodes(nodeCrudDeps, wrappedAttrs.state), | |
| onDuplicateNode: (node) => { | |
| duplicateNode(wrappedOnStateUpdate, node); | |
| }, | |
| onDeleteNode: (node) => { | |
| deleteNode(nodeCrudDeps, wrappedAttrs.state, node); | |
| }, | |
| onConnectionRemove: (fromNode, toNode, isSecondaryInput) => { | |
| removeNodeConnection( | |
| wrappedAttrs.state, | |
| wrappedOnStateUpdate, | |
| fromNode, | |
| toNode, | |
| isSecondaryInput, | |
| ); | |
| }, | |
| onImport: () => | |
| importGraph(graphIODeps, (title, newState) => | |
| attrs.onTabAddWithState(title, newState, tab.id), | |
| ), | |
| onExport: () => exportGraph(state, trace), | |
| onCreateGroup: (selectedNodeIds) => { | |
| wrappedOnStateUpdate((currentState) => { | |
| // Validate against currentState so we never act on a stale | |
| // snapshot. | |
| const allNodes = getAllNodes(currentState.rootNodes); | |
| const result = createGroupFromSelection( | |
| selectedNodeIds, | |
| allNodes, | |
| ); | |
| if (!result.ok) { | |
| showModal({ | |
| title: 'Cannot create group', | |
| content: () => m('p', result.error), | |
| buttons: [{text: 'OK', action: () => {}}], | |
| }); | |
| return currentState; | |
| } | |
| const groupNode = result.value; | |
| applyGroupRewiring(groupNode); | |
| // Remove inner nodes from rootNodes and add the group instead. | |
| // Inner nodes are now reachable via GroupNode.innerNodes | |
| // (getAllNodes traverses into groups automatically). | |
| const newRootNodes = currentState.rootNodes | |
| .filter((n) => !selectedNodeIds.has(n.nodeId)) | |
| .concat(groupNode); | |
| // Place the group at the centroid of the inner nodes' layouts. | |
| const newNodeLayouts = new Map(currentState.nodeLayouts); | |
| let sumX = 0; | |
| let sumY = 0; | |
| let count = 0; | |
| for (const id of selectedNodeIds) { | |
| const layout = newNodeLayouts.get(id); | |
| if (layout !== undefined) { | |
| sumX += layout.x; | |
| sumY += layout.y; | |
| count++; | |
| } | |
| newNodeLayouts.delete(id); | |
| } | |
| if (count > 0) { | |
| newNodeLayouts.set(groupNode.nodeId, { | |
| x: sumX / count, | |
| y: sumY / count, | |
| }); | |
| } | |
| return { | |
| ...currentState, | |
| rootNodes: newRootNodes, | |
| nodeLayouts: newNodeLayouts, | |
| selectedNodes: new Set([groupNode.nodeId]), | |
| }; | |
| }); | |
| }, | |
| onUngroupNode: (node) => { | |
| if (node instanceof GroupNode) doUngroup(node); | |
| }, | |
| }, | |
| onLoadEmptyTemplate: async () => { | |
| if (!(await confirmAndFinalizeCurrentGraph(state))) return; | |
| wrappedOnStateUpdate((currentState) => { | |
| return { | |
| ...currentState, | |
| rootNodes: [], | |
| selectedNodes: new Set(), | |
| nodeLayouts: new Map(), | |
| labels: [], | |
| loadGeneration: (currentState.loadGeneration ?? 0) + 1, | |
| }; | |
| }); | |
| }, | |
| onLoadExampleByPath: (jsonPath: string) => | |
| loadGraphFromPath(graphIODeps, state, jsonPath, 'Failed to Load'), | |
| onLoadDataExplorerTemplate: async () => { | |
| if (!(await confirmAndFinalizeCurrentGraph(state))) return; | |
| await createDataExplorerGraph(graphIODeps); | |
| }, | |
| onLoadRecentGraph: async (json: string) => { | |
| if (!(await confirmAndFinalizeCurrentGraph(state))) return; | |
| await loadGraphFromJson(graphIODeps, state.rootNodes, json); | |
| }, | |
| onFilterAdd: (node, filter, filterOperator) => { | |
| addFilter(nodeCrudDeps, node, filter, filterOperator); | |
| }, | |
| onColumnAdd: (node, column) => { | |
| addColumnFromJoinid(nodeCrudDeps, wrappedAttrs.state, node, column); | |
| }, | |
| onNodeStateChange: () => { | |
| wrappedOnStateUpdate((currentState) => { | |
| return {...currentState}; | |
| }); | |
| }, | |
| onUndo: () => this.handleUndo(attrs), | |
| onRedo: () => this.handleRedo(attrs), | |
| canUndo: services.historyManager?.canUndo() ?? false, | |
| canRedo: services.historyManager?.canRedo() ?? false, | |
| }), | |
| ); | |
| } | |
| // Stored references for the active tab's deps, used by keyboard handler. | |
| private activeNodeCrudDeps?: NodeCrudDeps; | |
| private activeGraphIODeps?: GraphIODeps; | |
| /** Get exported sources for a specific graph tab. */ | |
| private getDashboardSources(graphTabId: string) { | |
| return dashboardRegistry.getExportedSourcesForGraph(graphTabId); | |
| } | |
| /** Trigger debounced saves after dashboard state changes. */ | |
| private triggerSave(attrs: DataExplorerAttrs) { | |
| attrs.onDashboardStateChange(); | |
| m.redraw(); | |
| } | |
| private addDashboard(tab: DataExplorerTab, attrs: DataExplorerAttrs): void { | |
| const db: DashboardTabState = { | |
| id: shortUuid(), | |
| items: [], | |
| brushFilters: new Map(), | |
| }; | |
| tab.dashboards.push(db); | |
| tab.activeSubTab = db.id; | |
| this.triggerSave(attrs); | |
| } | |
| private removeDashboard( | |
| tab: DataExplorerTab, | |
| attrs: DataExplorerAttrs, | |
| dbId: string, | |
| ): void { | |
| if (tab.dashboards.length <= 1) return; | |
| const idx = tab.dashboards.findIndex((d) => d.id === dbId); | |
| if (idx === -1) return; | |
| const itemCount = tab.dashboards[idx].items.length; | |
| const doRemove = () => { | |
| const currentIdx = tab.dashboards.findIndex((d) => d.id === dbId); | |
| if (currentIdx === -1) return; | |
| tab.dashboards.splice(currentIdx, 1); | |
| // If the removed dashboard was active, switch to the previous one or graph. | |
| if (tab.activeSubTab === dbId) { | |
| if (tab.dashboards.length > 0) { | |
| const newIdx = Math.min(currentIdx, tab.dashboards.length - 1); | |
| tab.activeSubTab = tab.dashboards[newIdx].id; | |
| } else { | |
| tab.activeSubTab = 'graph'; | |
| } | |
| } | |
| this.triggerSave(attrs); | |
| }; | |
| if (itemCount === 0) { | |
| doRemove(); | |
| return; | |
| } | |
| showModal({ | |
| title: 'Remove dashboard?', | |
| content: m( | |
| 'div', | |
| `This dashboard contains ${itemCount} item(s). ` + | |
| 'Removing it will discard all its contents.', | |
| ), | |
| buttons: [ | |
| {text: 'Remove', primary: true, action: doRemove}, | |
| {text: 'Cancel'}, | |
| ], | |
| }); | |
| } | |
| /** | |
| * Render a graph tab with nested sub-tabs (Graph | Dashboard 1 | ... | +). | |
| * The graph builder is always initialized (for dashboard node execution), | |
| * but we conditionally show either the builder or the dashboard view. | |
| */ | |
| private renderDashboardButton( | |
| tab: DataExplorerTab, | |
| attrs: DataExplorerAttrs, | |
| db: DashboardTabState, | |
| index: number, | |
| activeSubTab: string, | |
| ): m.Children { | |
| const label = | |
| tab.dashboards.length === 1 ? 'Dashboard' : `Dashboard ${index + 1}`; | |
| const showClose = tab.dashboards.length > 1; | |
| return [ | |
| m(Button, { | |
| label, | |
| icon: 'dashboard', | |
| active: activeSubTab === db.id, | |
| rightIcon: showClose ? Icons.Close : undefined, | |
| onclick: (e: MouseEvent) => { | |
| // If the click landed on the close (right) icon, remove instead. | |
| const target = e.target as HTMLElement; | |
| if (showClose && target.closest('.pf-right-icon') !== null) { | |
| this.removeDashboard(tab, attrs, db.id); | |
| return; | |
| } | |
| tab.activeSubTab = db.id; | |
| m.redraw(); | |
| }, | |
| }), | |
| ]; | |
| } | |
| private renderTabWithSubTabs( | |
| attrs: DataExplorerAttrs, | |
| tab: DataExplorerTab, | |
| sqlModules: SqlModules, | |
| ): m.Children { | |
| const activeSubTab = tab.activeSubTab ?? 'graph'; | |
| const graphContent = this.renderTabContent(attrs, tab, sqlModules); | |
| const subTabBar = m( | |
| '.pf-data-explorer__sub-tab-bar', | |
| m( | |
| ButtonBar, | |
| m(Button, { | |
| label: 'Graph', | |
| icon: 'account_tree', | |
| active: activeSubTab === 'graph', | |
| onclick: () => { | |
| tab.activeSubTab = 'graph'; | |
| m.redraw(); | |
| }, | |
| }), | |
| ...tab.dashboards.map((db, i) => | |
| this.renderDashboardButton(tab, attrs, db, i, activeSubTab), | |
| ), | |
| m(Button, { | |
| icon: 'add', | |
| title: 'Add dashboard', | |
| onclick: () => this.addDashboard(tab, attrs), | |
| }), | |
| m(Button, { | |
| icon: 'download', | |
| title: 'Export current tab', | |
| onclick: () => { | |
| void exportTab(tab, attrs.trace); | |
| }, | |
| }), | |
| ), | |
| ); | |
| // Find active dashboard if any. | |
| const activeDashboard = tab.dashboards.find((db) => db.id === activeSubTab); | |
| if (activeDashboard !== undefined) { | |
| return m('.pf-data-explorer__tab-with-subtabs', [ | |
| subTabBar, | |
| m( | |
| '.pf-data-explorer__tab-content', | |
| m(Dashboard, { | |
| dashboardId: activeDashboard.id, | |
| trace: attrs.trace, | |
| items: activeDashboard.items, | |
| sources: this.getDashboardSources(tab.id), | |
| brushFilters: activeDashboard.brushFilters, | |
| onItemsChange: (items) => { | |
| activeDashboard.items = items; | |
| this.triggerSave(attrs); | |
| }, | |
| onBrushFiltersChange: (filters) => { | |
| activeDashboard.brushFilters = filters; | |
| this.triggerSave(attrs); | |
| }, | |
| }), | |
| ), | |
| ]); | |
| } | |
| return m('.pf-data-explorer__tab-with-subtabs', [subTabBar, graphContent]); | |
| } | |
| view({attrs}: m.CVnode<DataExplorerAttrs>) { | |
| const {tabs, activeTabId} = attrs; | |
| const sqlModules = attrs.sqlModulesPlugin.getSqlModules(); | |
| if (!sqlModules) { | |
| return m( | |
| '.pf-data-explorer', | |
| m( | |
| '.pf-data-explorer__header', | |
| m('h1', 'Loading SQL Modules, please wait...'), | |
| ), | |
| ); | |
| } | |
| // Build tab entries for the Tabs widget | |
| const tabEntries: TabsTab[] = tabs.map((tab) => ({ | |
| key: tab.id, | |
| title: tab.title, | |
| leftIcon: 'account_tree', | |
| closeButton: tabs.length > 1, | |
| content: this.renderTabWithSubTabs(attrs, tab, sqlModules), | |
| menuItems: m(MenuItem, { | |
| label: 'Duplicate tab', | |
| icon: 'content_copy', | |
| onclick: () => { | |
| const sqlMods = attrs.sqlModulesPlugin.getSqlModules(); | |
| if (sqlMods === undefined) return; | |
| const json = serializeState(tab.state); | |
| const clonedState = deserializeState(json, attrs.trace, sqlMods); | |
| attrs.onTabAddWithState(`${tab.title} (copy)`, clonedState, tab.id); | |
| }, | |
| }), | |
| })); | |
| return m( | |
| '.pf-data-explorer', | |
| { | |
| onkeydown: (e: KeyboardEvent) => { | |
| if (this.activeNodeCrudDeps && this.activeGraphIODeps) { | |
| this.handleKeyDown( | |
| e, | |
| attrs, | |
| this.activeNodeCrudDeps, | |
| this.activeGraphIODeps, | |
| ); | |
| } | |
| }, | |
| oncreate: (vnode) => { | |
| (vnode.dom as HTMLElement).focus(); | |
| }, | |
| onremove: () => { | |
| // Clean up all materialized tables for all tabs in parallel | |
| void Promise.all( | |
| [...this.tabServices].map(([tabId, services]) => { | |
| const tab = tabs.find((t) => t.id === tabId); | |
| const rootNodes = tab ? getAllNodes(tab.state.rootNodes) : []; | |
| return services.cleanupManager | |
| .cleanupAll(rootNodes) | |
| .catch((e) => console.warn(`Tab ${tabId} cleanup failed:`, e)); | |
| }), | |
| ).finally(() => this.tabServices.clear()); | |
| }, | |
| tabindex: 0, | |
| }, | |
| m(Tabs, { | |
| className: 'pf-data-explorer__tabs', | |
| tabs: tabEntries, | |
| activeTabKey: activeTabId, | |
| reorderable: true, | |
| newTabContent: m('.pf-data-explorer__tab-actions', [ | |
| m(Button, { | |
| icon: 'add', | |
| className: 'pf-tabs__new-tab-btn', | |
| onclick: () => attrs.onTabAdd(), | |
| }), | |
| m(Button, { | |
| icon: 'upload', | |
| className: 'pf-tabs__new-tab-btn', | |
| title: 'Import tab', | |
| onclick: () => { | |
| if (this.activeGraphIODeps) { | |
| importTab( | |
| this.activeGraphIODeps, | |
| (title, newState, dashboards) => | |
| attrs.onTabAddWithState( | |
| title, | |
| newState, | |
| attrs.activeTabId, | |
| dashboards, | |
| ), | |
| ); | |
| } | |
| }, | |
| }), | |
| ]), | |
| onTabChange: (key) => attrs.onTabChange(key), | |
| onTabRename: (key, newTitle) => { | |
| attrs.onTabRename(key, newTitle); | |
| }, | |
| onTabClose: (key) => { | |
| // Clean up services for the closed tab eagerly | |
| const services = this.tabServices.get(key); | |
| if (services) { | |
| const tab = tabs.find((t) => t.id === key); | |
| const rootNodes = tab ? getAllNodes(tab.state.rootNodes) : []; | |
| void services.cleanupManager | |
| .cleanupAll(rootNodes) | |
| .catch((e) => console.warn(`Tab ${key} cleanup failed:`, e)); | |
| this.tabServices.delete(key); | |
| } | |
| attrs.onTabClose(key); | |
| }, | |
| onTabReorder: (draggedKey, beforeKey) => | |
| attrs.onTabReorder(draggedKey, beforeKey), | |
| }), | |
| ); | |
| } | |
| } |