| // 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 {Trace} from '../../../public/trace'; |
| import {classNames} from '../../../base/classnames'; |
| import { |
| perfettoSqlTypeIcon, |
| perfettoSqlTypeToString, |
| } from '../../../trace_processor/perfetto_sql_type'; |
| import {Accordion, AccordionItem} from '../../../widgets/accordion'; |
| import {Button} from '../../../widgets/button'; |
| import {Icons} from '../../../base/semantic_icons'; |
| import {Chip} from '../../../widgets/chip'; |
| import {EmptyState} from '../../../widgets/empty_state'; |
| import {Icon} from '../../../widgets/icon'; |
| import {MenuItem, PopupMenu} from '../../../widgets/menu'; |
| import { |
| DashboardBrushFilter, |
| DashboardDataSource, |
| DashboardItem, |
| DashboardSourceWithName, |
| getItemId, |
| getNextItemPosition, |
| snapToGrid, |
| sourceDisplayName, |
| } from './dashboard_registry'; |
| import { |
| DashboardChartView, |
| createDefaultChartConfig, |
| } from './dashboard_chart_view'; |
| import {ResizeHandle} from '../../../widgets/resize_handle'; |
| import {Card} from '../../../widgets/card'; |
| import {getDefaultChartLabel} from '../query_builder/nodes/visualisation_node'; |
| import {Popup, PopupPosition} from '../../../widgets/popup'; |
| import {renderChartConfigPopup} from '../query_builder/charts/chart_config_popup'; |
| import {RoundActionButton} from '../query_builder/widgets'; |
| |
| // Default dimensions for dashboard chart cards (in pixels). |
| const DEFAULT_CHART_WIDTH = 400; |
| const DEFAULT_CHART_HEIGHT = 294; |
| const MIN_CHART_WIDTH = 200; |
| const MIN_CHART_HEIGHT = 150; |
| |
| function formatBrushFilter(f: DashboardBrushFilter): string { |
| if (f.op === 'is null') return 'IS NULL'; |
| if (f.op === '=') return `= ${f.value}`; |
| return `${f.op} ${f.value}`; |
| } |
| |
| function formatBrushFilterValue(f: DashboardBrushFilter): string { |
| if (f.op === 'is null') return 'NULL'; |
| if (f.op === '=') return String(f.value); |
| return `${f.op} ${f.value}`; |
| } |
| |
| function summarizeBrushFilters( |
| filters: ReadonlyArray<DashboardBrushFilter>, |
| ): string { |
| const eqValues = filters.filter((f) => f.op === '='); |
| const rangeFilters = filters.filter((f) => f.op === '>=' || f.op === '<'); |
| const nullFilters = filters.filter((f) => f.op === 'is null'); |
| |
| const parts: string[] = []; |
| if (rangeFilters.length === 2) { |
| const min = rangeFilters.find((f) => f.op === '>=')?.value; |
| const max = rangeFilters.find((f) => f.op === '<')?.value; |
| if (min !== undefined && max !== undefined) { |
| parts.push(`${min}–${max}`); |
| } |
| } else if (rangeFilters.length > 0) { |
| parts.push(...rangeFilters.map((f) => formatBrushFilter(f))); |
| } |
| if (eqValues.length <= 3) { |
| parts.push(...eqValues.map((f) => String(f.value))); |
| } else { |
| parts.push(`${eqValues.length} values`); |
| } |
| if (nullFilters.length > 0) { |
| parts.push('NULL'); |
| } |
| return parts.join(', '); |
| } |
| |
| // Re-export types that are defined in dashboard_registry but used by callers |
| // of this module (e.g. DataExplorer). |
| export type {DashboardSourceWithName} from './dashboard_registry'; |
| |
| export interface DashboardAttrs { |
| dashboardId: string; |
| trace: Trace; |
| items: DashboardItem[]; |
| sources: ReadonlyArray<DashboardSourceWithName>; |
| brushFilters: Map<string, DashboardBrushFilter[]>; |
| onItemsChange: (items: DashboardItem[]) => void; |
| onBrushFiltersChange: (filters: Map<string, DashboardBrushFilter[]>) => void; |
| } |
| |
| export class Dashboard implements m.ClassComponent<DashboardAttrs> { |
| private panelOpen = false; |
| private filtersExpanded = true; |
| private expandedInput: string | undefined = undefined; |
| private editingChartId?: string; |
| |
| // Pointer-event drag state. |
| private draggingItemId?: string; |
| private dragOffset = {x: 0, y: 0}; |
| private tempPositions = new Map<string, {x: number; y: number}>(); |
| // Latest attrs — kept in sync every render so drag handlers never use stale |
| // references. |
| private latestAttrs?: DashboardAttrs; |
| |
| view({attrs}: m.CVnode<DashboardAttrs>) { |
| this.latestAttrs = attrs; |
| const {items} = attrs; |
| |
| return m('.pf-dashboard', [ |
| m('.pf-dashboard__main', [ |
| this.renderFilterBar(attrs), |
| m( |
| '.pf-dashboard__canvas', |
| { |
| onpointermove: (e: PointerEvent) => this.handlePointerMove(e), |
| onpointerup: () => this.handlePointerUp(), |
| onpointercancel: () => this.cancelDrag(), |
| }, |
| [ |
| this.renderAddButton(attrs), |
| this.renderCollapsedFilterButton(attrs), |
| items.length > 0 |
| ? this.renderItems(attrs, items) |
| : m( |
| '.pf-dashboard__empty-overlay', |
| m( |
| EmptyState, |
| {icon: 'bar_chart', title: 'No charts yet'}, |
| 'Use the + button to add charts.', |
| ), |
| ), |
| ], |
| ), |
| ]), |
| this.panelOpen && |
| m('.pf-dashboard__data-panel', this.renderDataPanel(attrs)), |
| m( |
| '.pf-dashboard__side-panel', |
| m(Button, { |
| icon: 'dataset', |
| title: 'Data', |
| className: classNames(this.panelOpen && 'pf-active'), |
| onclick: () => { |
| this.panelOpen = !this.panelOpen; |
| }, |
| }), |
| ), |
| ]); |
| } |
| |
| // --- "+" button --- |
| |
| private renderAddButton(attrs: DashboardAttrs): m.Child { |
| const {sources, items} = attrs; |
| // Default to the source of the most recently added chart, or the first |
| // available source if there are no charts yet. |
| const lastChartItem = [...items].reverse().find((i) => i.kind === 'chart'); |
| const lastSource = |
| (lastChartItem !== undefined |
| ? sources.find((s) => s.nodeId === lastChartItem.sourceNodeId) |
| : undefined) ?? (sources.length > 0 ? sources[0] : undefined); |
| |
| return m( |
| '.pf-dashboard__add-button', |
| RoundActionButton({ |
| icon: 'add', |
| title: 'Add chart', |
| disabled: lastSource === undefined, |
| onclick: () => { |
| if (lastSource !== undefined) { |
| this.addChartForSource(attrs, lastSource); |
| } |
| }, |
| }), |
| ); |
| } |
| |
| // --- Canvas items --- |
| |
| private renderItems( |
| attrs: DashboardAttrs, |
| items: DashboardItem[], |
| ): m.Children { |
| return items.map((item) => { |
| const source = attrs.sources.find((s) => s.nodeId === item.sourceNodeId); |
| if (source === undefined) { |
| return this.renderOrphanedChart(attrs, item); |
| } |
| return this.renderChart(attrs, source, item); |
| }); |
| } |
| |
| private renderChart( |
| attrs: DashboardAttrs, |
| source: DashboardSourceWithName, |
| chart: DashboardItem, |
| ): m.Child { |
| const config = chart.config; |
| const itemId = config.id; |
| const headerLabel = config.name ?? getDefaultChartLabel(config); |
| |
| const editButton = m( |
| Popup, |
| { |
| trigger: m(Button, { |
| icon: 'settings', |
| title: 'Edit chart settings', |
| compact: true, |
| }), |
| position: PopupPosition.BottomEnd, |
| className: 'pf-chart-config-popup', |
| }, |
| renderChartConfigPopup( |
| { |
| node: new DashboardChartView.Adapter(source, attrs, config), |
| }, |
| config, |
| () => m.redraw(), |
| ), |
| ); |
| |
| const sourceChip = m( |
| PopupMenu, |
| { |
| trigger: m(Chip, { |
| label: sourceDisplayName(source, attrs.items, attrs.sources), |
| icon: 'database', |
| compact: true, |
| className: 'pf-dashboard__source-chip', |
| title: 'Change data source', |
| }), |
| }, |
| ...attrs.sources.map((s) => |
| m(MenuItem, { |
| label: s.name, |
| icon: s.nodeId === source.nodeId ? 'check' : undefined, |
| onclick: () => { |
| if (s.nodeId !== source.nodeId) { |
| this.changeChartSource(attrs, itemId, s); |
| } |
| }, |
| }), |
| ), |
| ); |
| |
| return this.renderItemCard(attrs, itemId, chart, [ |
| m( |
| '.pf-dashboard__chart-header', |
| { |
| onpointerdown: (e: PointerEvent) => this.startDrag(e, itemId), |
| }, |
| [ |
| this.editingChartId === itemId |
| ? m('input.pf-dashboard__chart-title-input', { |
| type: 'text', |
| value: config.name ?? '', |
| placeholder: getDefaultChartLabel(config), |
| oncreate: (vnode: m.VnodeDOM) => { |
| const input = vnode.dom as HTMLInputElement; |
| input.focus(); |
| input.select(); |
| }, |
| onblur: (e: Event) => { |
| const target = e.target as HTMLInputElement; |
| const name = target.value.trim() || undefined; |
| this.updateChartName(attrs, itemId, name); |
| this.editingChartId = undefined; |
| }, |
| onkeydown: (e: KeyboardEvent) => { |
| if (e.key === 'Enter' || e.key === 'Escape') { |
| this.editingChartId = undefined; |
| (e.target as HTMLInputElement).blur(); |
| } |
| }, |
| oninput: (e: Event) => { |
| const target = e.target as HTMLInputElement; |
| const name = target.value || undefined; |
| this.updateChartName(attrs, itemId, name); |
| }, |
| }) |
| : m( |
| '.pf-dashboard__chart-header-text', |
| { |
| onclick: (e: MouseEvent) => { |
| e.stopPropagation(); |
| this.editingChartId = itemId; |
| }, |
| title: 'Click to rename', |
| }, |
| headerLabel, |
| ), |
| m('.pf-dashboard__chart-actions', [ |
| sourceChip, |
| editButton, |
| this.deleteButton(attrs, itemId), |
| ]), |
| ], |
| ), |
| m( |
| '.pf-dashboard__chart-content', |
| m(DashboardChartView, { |
| trace: attrs.trace, |
| source, |
| config, |
| dashboardId: attrs.dashboardId, |
| items: attrs.items, |
| brushFilters: attrs.brushFilters, |
| onItemsChange: attrs.onItemsChange, |
| onBrushFiltersChange: attrs.onBrushFiltersChange, |
| }), |
| ), |
| ]); |
| } |
| |
| private renderOrphanedChart( |
| attrs: DashboardAttrs, |
| chart: DashboardItem, |
| ): m.Child { |
| const itemId = chart.config.id; |
| return this.renderItemCard(attrs, itemId, chart, [ |
| m( |
| '.pf-dashboard__chart-header', |
| { |
| onpointerdown: (e: PointerEvent) => this.startDrag(e, itemId), |
| }, |
| [ |
| m('.pf-dashboard__chart-header-text', chart.config.name ?? 'Chart'), |
| m('.pf-dashboard__chart-actions', [this.deleteButton(attrs, itemId)]), |
| ], |
| ), |
| m( |
| '.pf-dashboard__chart-content', |
| m( |
| EmptyState, |
| {icon: 'link_off', title: 'Data source removed'}, |
| 'The data source for this chart is no longer available. Delete or re-assign it.', |
| ), |
| ), |
| ]); |
| } |
| |
| // --- Shared card wrapper with resize + absolute positioning --- |
| |
| private renderItemCard( |
| attrs: DashboardAttrs, |
| itemId: string, |
| item: DashboardItem, |
| children: m.Children, |
| ): m.Child { |
| const isDragging = this.draggingItemId === itemId; |
| const hasCustomWidth = item.widthPx !== undefined; |
| const hasCustomHeight = item.heightPx !== undefined; |
| |
| // Use temp position during drag, persisted position otherwise. |
| const temp = this.tempPositions.get(itemId); |
| const x = temp?.x ?? item.x ?? 0; |
| const y = temp?.y ?? item.y ?? 0; |
| |
| const styleProps = [ |
| `left: ${x}px`, |
| `top: ${y}px`, |
| hasCustomWidth ? `--pf-db-chart-width: ${item.widthPx}px` : '', |
| hasCustomHeight ? `--pf-db-chart-height: ${item.heightPx}px` : '', |
| ] |
| .filter(Boolean) |
| .join('; '); |
| |
| return m( |
| Card, |
| { |
| key: itemId, |
| style: styleProps, |
| className: classNames( |
| 'pf-dashboard__chart', |
| hasCustomWidth && 'pf-dashboard__chart--custom-width', |
| hasCustomHeight && 'pf-dashboard__chart--custom-height', |
| isDragging && 'pf-dashboard__chart--dragging', |
| ), |
| }, |
| [ |
| ...(Array.isArray(children) ? children : [children]), |
| m(ResizeHandle, { |
| direction: 'horizontal', |
| onResize: (deltaPx: number) => { |
| const currentWidth = item.widthPx ?? DEFAULT_CHART_WIDTH; |
| const newWidth = Math.max(MIN_CHART_WIDTH, currentWidth + deltaPx); |
| this.mapItems(attrs, (i) => |
| getItemId(i) === itemId ? {...i, widthPx: newWidth} : i, |
| ); |
| }, |
| }), |
| m(ResizeHandle, { |
| direction: 'vertical', |
| onResize: (deltaPx: number) => { |
| const currentHeight = item.heightPx ?? DEFAULT_CHART_HEIGHT; |
| const newHeight = Math.max( |
| MIN_CHART_HEIGHT, |
| currentHeight + deltaPx, |
| ); |
| this.mapItems(attrs, (i) => |
| getItemId(i) === itemId ? {...i, heightPx: newHeight} : i, |
| ); |
| }, |
| }), |
| ], |
| ); |
| } |
| |
| // --- Pointer-event drag --- |
| |
| private startDrag(e: PointerEvent, itemId: string): void { |
| if (e.button !== 0) return; |
| if ((e.target as HTMLElement).closest('button, input, .pf-resize-handle')) { |
| return; |
| } |
| |
| const cardEl = (e.currentTarget as HTMLElement).closest( |
| '.pf-card', |
| ) as HTMLElement | null; |
| if (cardEl === null) return; |
| |
| const canvasEl = cardEl.closest( |
| '.pf-dashboard__canvas', |
| ) as HTMLElement | null; |
| if (canvasEl === null) return; |
| |
| const canvasRect = canvasEl.getBoundingClientRect(); |
| this.draggingItemId = itemId; |
| this.dragOffset = { |
| x: e.clientX - canvasRect.left + canvasEl.scrollLeft - cardEl.offsetLeft, |
| y: e.clientY - canvasRect.top + canvasEl.scrollTop - cardEl.offsetTop, |
| }; |
| } |
| |
| private handlePointerMove(e: PointerEvent): void { |
| if (this.draggingItemId === undefined) return; |
| |
| const canvasEl = e.currentTarget as HTMLElement; |
| const canvasRect = canvasEl.getBoundingClientRect(); |
| |
| const x = Math.max( |
| 0, |
| e.clientX - canvasRect.left + canvasEl.scrollLeft - this.dragOffset.x, |
| ); |
| const y = Math.max( |
| 0, |
| e.clientY - canvasRect.top + canvasEl.scrollTop - this.dragOffset.y, |
| ); |
| |
| this.tempPositions.set(this.draggingItemId, {x, y}); |
| m.redraw(); |
| } |
| |
| private handlePointerUp(): void { |
| if (this.draggingItemId === undefined) return; |
| const attrs = this.latestAttrs; |
| if (attrs === undefined) return; |
| |
| const temp = this.tempPositions.get(this.draggingItemId); |
| if (temp !== undefined) { |
| const itemId = this.draggingItemId; |
| const x = snapToGrid(temp.x); |
| const y = snapToGrid(temp.y); |
| this.mapItems(attrs, (i) => (getItemId(i) === itemId ? {...i, x, y} : i)); |
| } |
| this.cancelDrag(); |
| } |
| |
| private cancelDrag(): void { |
| this.draggingItemId = undefined; |
| this.tempPositions.clear(); |
| m.redraw(); |
| } |
| |
| private deleteButton(attrs: DashboardAttrs, itemId: string): m.Child { |
| return m(Button, { |
| icon: 'close', |
| title: 'Delete', |
| compact: true, |
| onclick: (e: MouseEvent) => { |
| e.stopPropagation(); |
| this.removeItem(attrs, itemId); |
| }, |
| }); |
| } |
| |
| // --- Item mutations --- |
| |
| /** Map over items, replacing those that match a predicate. */ |
| private mapItems( |
| attrs: DashboardAttrs, |
| fn: (item: DashboardItem) => DashboardItem, |
| ): void { |
| attrs.onItemsChange(attrs.items.map(fn)); |
| } |
| |
| private changeChartSource( |
| attrs: DashboardAttrs, |
| chartId: string, |
| newSource: DashboardDataSource, |
| ): void { |
| this.mapItems(attrs, (i) => |
| i.kind === 'chart' && i.config.id === chartId |
| ? {...i, sourceNodeId: newSource.nodeId} |
| : i, |
| ); |
| } |
| |
| private removeItem(attrs: DashboardAttrs, itemId: string): void { |
| attrs.onItemsChange(attrs.items.filter((i) => getItemId(i) !== itemId)); |
| } |
| |
| private updateChartName( |
| attrs: DashboardAttrs, |
| itemId: string, |
| name: string | undefined, |
| ): void { |
| this.mapItems(attrs, (i) => |
| i.config.id === itemId ? {...i, config: {...i.config, name}} : i, |
| ); |
| } |
| |
| private clearBrushFiltersForColumn( |
| attrs: DashboardAttrs, |
| sourceNodeId: string, |
| column: string, |
| ): void { |
| const existing = attrs.brushFilters.get(sourceNodeId); |
| if (existing === undefined) return; |
| const filtered = existing.filter((f) => f.column !== column); |
| const newFilters = new Map(attrs.brushFilters); |
| if (filtered.length === 0) { |
| newFilters.delete(sourceNodeId); |
| } else { |
| newFilters.set(sourceNodeId, filtered); |
| } |
| attrs.onBrushFiltersChange(newFilters); |
| } |
| |
| private removeSingleBrushFilter( |
| attrs: DashboardAttrs, |
| sourceNodeId: string, |
| filter: DashboardBrushFilter, |
| ): void { |
| const current = [...(attrs.brushFilters.get(sourceNodeId) ?? [])]; |
| const updated = current.filter( |
| (f) => |
| f.column !== filter.column || |
| f.op !== filter.op || |
| f.value !== filter.value, |
| ); |
| const newFilters = new Map(attrs.brushFilters); |
| if (updated.length === 0) { |
| newFilters.delete(sourceNodeId); |
| } else { |
| newFilters.set(sourceNodeId, updated); |
| } |
| attrs.onBrushFiltersChange(newFilters); |
| } |
| |
| // Collapsed filter icon — shown on the canvas when the bar is hidden. |
| private renderCollapsedFilterButton(attrs: DashboardAttrs): m.Child { |
| if (this.filtersExpanded) return null; |
| let count = 0; |
| for (const filters of attrs.brushFilters.values()) { |
| count += filters.length; |
| } |
| if (count === 0) return null; |
| return m('.pf-dashboard__filter-collapsed', [ |
| m(Button, { |
| icon: 'filter_alt', |
| title: `Filters (${count})`, |
| onclick: () => { |
| this.filtersExpanded = true; |
| }, |
| }), |
| ]); |
| } |
| |
| // --- Filter bar (horizontal strip below tabs) --- |
| |
| private renderFilterBar(attrs: DashboardAttrs): m.Child { |
| const {sources, brushFilters} = attrs; |
| // Collect filters from all sources. |
| const allEntries: { |
| sourceNodeId: string; |
| sourceName: string; |
| filters: ReadonlyArray<DashboardBrushFilter>; |
| }[] = []; |
| for (const source of sources) { |
| const filters = brushFilters.get(source.nodeId) ?? []; |
| if (filters.length > 0) { |
| allEntries.push({ |
| sourceNodeId: source.nodeId, |
| sourceName: sourceDisplayName(source, attrs.items, attrs.sources), |
| filters, |
| }); |
| } |
| } |
| if (allEntries.length === 0) return null; |
| |
| if (!this.filtersExpanded) return null; |
| |
| // Build chips grouped by source. |
| const groups: m.Child[] = []; |
| for (const {sourceNodeId, sourceName, filters} of allEntries) { |
| const columns = [...new Set(filters.map((f) => f.column))]; |
| const chips = columns.map((col) => { |
| const colFilters = filters.filter((f) => f.column === col); |
| return m( |
| Popup, |
| { |
| trigger: m(Chip, { |
| label: `${col}: ${summarizeBrushFilters(colFilters)}`, |
| icon: 'filter_alt', |
| compact: true, |
| rounded: true, |
| removable: true, |
| onRemove: () => { |
| this.clearBrushFiltersForColumn(attrs, sourceNodeId, col); |
| }, |
| }), |
| position: PopupPosition.Bottom, |
| }, |
| m( |
| '.pf-dashboard__filter-popup', |
| colFilters.map((f) => |
| m(MenuItem, { |
| label: formatBrushFilterValue(f), |
| icon: Icons.Checkbox, |
| onclick: () => { |
| this.removeSingleBrushFilter(attrs, sourceNodeId, f); |
| }, |
| }), |
| ), |
| ), |
| ); |
| }); |
| |
| groups.push( |
| m('.pf-dashboard__filter-group', [ |
| m(Chip, { |
| label: sourceName, |
| icon: 'database', |
| compact: true, |
| className: 'pf-dashboard__source-chip', |
| }), |
| ...chips, |
| ]), |
| ); |
| } |
| |
| return m('.pf-dashboard__filter-bar', [ |
| ...groups, |
| m('.pf-dashboard__filter-bar-actions', [ |
| m(Button, { |
| label: 'Clear all', |
| compact: true, |
| onclick: () => { |
| attrs.onBrushFiltersChange(new Map()); |
| }, |
| }), |
| m(Button, { |
| icon: 'unfold_less', |
| compact: true, |
| title: 'Collapse filters', |
| onclick: () => { |
| this.filtersExpanded = false; |
| }, |
| }), |
| ]), |
| ]); |
| } |
| |
| // --- Data panel --- |
| |
| private renderDataPanel(attrs: DashboardAttrs): m.Children { |
| const allExported = attrs.sources; |
| |
| if (allExported.length === 0) { |
| return m( |
| EmptyState, |
| {icon: 'dataset', title: 'No exported sources'}, |
| 'Use "Export to Dashboard" nodes in the graph builder to make data sources available here.', |
| ); |
| } |
| |
| // Partition sources into used (has at least one chart) and unused. |
| const items = attrs.items; |
| const usedNodeIds = new Set( |
| items.filter((i) => i.kind === 'chart').map((i) => i.sourceNodeId), |
| ); |
| const used = allExported.filter((s) => usedNodeIds.has(s.nodeId)); |
| const unused = allExported.filter((s) => !usedNodeIds.has(s.nodeId)); |
| |
| const makeSourceItems = ( |
| srcs: DashboardSourceWithName[], |
| ): AccordionItem[] => |
| srcs.map((source) => ({ |
| id: source.nodeId, |
| header: m('.pf-dashboard__source-row', [ |
| m( |
| 'code.pf-dashboard__input-name', |
| sourceDisplayName(source, attrs.items, attrs.sources, true), |
| ), |
| ]), |
| content: this.renderInputContent(source, attrs), |
| })); |
| |
| const sections: m.Child[] = []; |
| if (used.length > 0) { |
| sections.push( |
| m('.pf-dashboard__panel-section', [ |
| m('.pf-dashboard__panel-section-title', 'Used'), |
| m( |
| '.pf-dashboard__input-list', |
| m(Accordion, { |
| items: makeSourceItems(used), |
| expanded: this.expandedInput, |
| onToggle: (id) => { |
| this.expandedInput = id; |
| }, |
| }), |
| ), |
| ]), |
| ); |
| } |
| if (unused.length > 0) { |
| sections.push( |
| m('.pf-dashboard__panel-section', [ |
| m('.pf-dashboard__panel-section-title', 'Unused'), |
| m( |
| '.pf-dashboard__input-list', |
| m(Accordion, { |
| items: makeSourceItems(unused), |
| expanded: this.expandedInput, |
| onToggle: (id) => { |
| this.expandedInput = id; |
| }, |
| }), |
| ), |
| ]), |
| ); |
| } |
| |
| return sections; |
| } |
| |
| private renderInputContent( |
| source: DashboardDataSource, |
| attrs: DashboardAttrs, |
| ): m.Children { |
| return [ |
| m( |
| '.pf-dashboard__detail-row', |
| m('span.pf-dashboard__detail-label', 'Columns'), |
| m('span.pf-dashboard__detail-value', `${source.columns.length}`), |
| ), |
| source.columns.length > 0 && |
| m( |
| '.pf-dashboard__columns', |
| m( |
| '.pf-dashboard__column-list', |
| source.columns.map((col) => |
| m( |
| '.pf-dashboard__column', |
| m(Icon, { |
| icon: perfettoSqlTypeIcon(col.type), |
| className: 'pf-dashboard__column-icon', |
| }), |
| m( |
| '.pf-dashboard__column-info', |
| m( |
| '.pf-dashboard__column-header', |
| m('code.pf-dashboard__column-name', col.name), |
| m( |
| 'span.pf-dashboard__column-type', |
| perfettoSqlTypeToString(col.type), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| m(Button, { |
| label: 'Add Chart', |
| icon: 'bar_chart', |
| compact: true, |
| className: 'pf-dashboard__add-chart-btn', |
| onclick: () => { |
| this.addChartForSource(attrs, source); |
| }, |
| }), |
| ]; |
| } |
| |
| private addChartForSource( |
| attrs: DashboardAttrs, |
| source: DashboardDataSource, |
| ): void { |
| const items = [...attrs.items]; |
| const newConfig = createDefaultChartConfig(source.columns); |
| const {x, y} = getNextItemPosition(items); |
| items.push({ |
| kind: 'chart', |
| sourceNodeId: source.nodeId, |
| config: newConfig, |
| x, |
| y, |
| }); |
| attrs.onItemsChange(items); |
| } |
| } |