blob: b1334ffd81301ede5b4f76a389df11adbf2d1b75 [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 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),
}),
);
}
}