de: Binding columns in the dashboards (#5145)
Bind all columns from the datasources together, if they have the same
name.
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.scss b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.scss
index 69d4ba3..d633943 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.scss
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.scss
@@ -254,6 +254,7 @@
// Data panel — styled similarly to the query page's table list.
.pf-dashboard__data-panel {
width: 300px;
+ min-width: 0;
height: 100%;
border-left: 1px solid var(--pf-color-border);
background: var(--pf-color-background);
@@ -390,6 +391,57 @@
}
}
+// Linked columns panel entries.
+.pf-dashboard__linked-column {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--pf-color-border-secondary);
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+.pf-dashboard__linked-column-name {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ margin-bottom: 4px;
+
+ .pf-icon {
+ font-size: 14px;
+ color: var(--pf-color-text-muted);
+ }
+
+ code {
+ font-family: var(--pf-font-monospace);
+ }
+}
+
+.pf-dashboard__linked-column-sources {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-left: 20px;
+}
+
+.pf-dashboard__linked-column-source {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--pf-color-text-muted);
+}
+
+.pf-dashboard__linked-source-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
// Side panel matching the builder's .pf-qb-side-panel style.
.pf-dashboard__side-panel {
width: 60px;
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
index 0555d22..d6c9f13 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
@@ -32,6 +32,7 @@
DashboardDataSource,
DashboardItem,
getItemId,
+ getLinkedSourceNodeIds,
getNextItemPosition,
snapToGrid,
} from './dashboard_registry';
@@ -102,8 +103,10 @@
onBrushFiltersChange: (filters: Map<string, DashboardBrushFilter[]>) => void;
}
+type SidePanelTab = 'data' | 'linked';
+
export class Dashboard implements m.ClassComponent<DashboardAttrs> {
- private panelOpen = false;
+ private activePanel?: SidePanelTab;
private filtersExpanded = true;
private expandedInput: string | undefined = undefined;
private editingChartId?: string;
@@ -146,19 +149,29 @@
],
),
]),
- this.panelOpen &&
+ this.activePanel === 'data' &&
m('.pf-dashboard__data-panel', this.renderDataPanel(attrs)),
- m(
- '.pf-dashboard__side-panel',
+ this.activePanel === 'linked' &&
+ m('.pf-dashboard__data-panel', this.renderLinkedColumnsPanel(attrs)),
+ m('.pf-dashboard__side-panel', [
m(Button, {
icon: 'dataset',
title: 'Data',
- className: classNames(this.panelOpen && 'pf-active'),
+ className: classNames(this.activePanel === 'data' && 'pf-active'),
onclick: () => {
- this.panelOpen = !this.panelOpen;
+ this.activePanel = this.activePanel === 'data' ? undefined : 'data';
},
}),
- ),
+ m(Button, {
+ icon: 'link',
+ title: 'Linked columns',
+ className: classNames(this.activePanel === 'linked' && 'pf-active'),
+ onclick: () => {
+ this.activePanel =
+ this.activePanel === 'linked' ? undefined : 'linked';
+ },
+ }),
+ ]),
]);
}
@@ -245,7 +258,11 @@
},
renderChartConfigPopup(
{
- node: new DashboardChartView.Adapter(source, attrs, config),
+ node: new DashboardChartView.Adapter(
+ source,
+ {...attrs, allSources: attrs.sources},
+ config,
+ ),
},
config,
() => m.redraw(),
@@ -337,6 +354,7 @@
config,
dashboardId: attrs.dashboardId,
items: attrs.items,
+ allSources: attrs.sources,
brushFilters: attrs.brushFilters,
onItemsChange: attrs.onItemsChange,
onBrushFiltersChange: attrs.onBrushFiltersChange,
@@ -602,14 +620,23 @@
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);
+
+ // Clear the column from the specified source and all other sources that
+ // have a column with the same name (cross-datasource brushing).
+ for (const id of getLinkedSourceNodeIds(
+ attrs.sources,
+ sourceNodeId,
+ column,
+ )) {
+ const current = newFilters.get(id);
+ if (current === undefined) continue;
+ const filtered = current.filter((f) => f.column !== column);
+ if (filtered.length === 0) {
+ newFilters.delete(id);
+ } else {
+ newFilters.set(id, filtered);
+ }
}
attrs.onBrushFiltersChange(newFilters);
}
@@ -619,18 +646,27 @@
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);
+
+ // Remove the filter from the specified source and all other sources that
+ // have a column with the same name (cross-datasource brushing).
+ for (const id of getLinkedSourceNodeIds(
+ attrs.sources,
+ sourceNodeId,
+ filter.column,
+ )) {
+ const current = [...(newFilters.get(id) ?? [])];
+ const updated = current.filter(
+ (f) =>
+ f.column !== filter.column ||
+ f.op !== filter.op ||
+ f.value !== filter.value,
+ );
+ if (updated.length === 0) {
+ newFilters.delete(id);
+ } else {
+ newFilters.set(id, updated);
+ }
}
attrs.onBrushFiltersChange(newFilters);
}
@@ -658,55 +694,101 @@
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: source.name,
- filters,
- });
+
+ // Collect all active (column, sourceNodeId) filter entries.
+ const activeColumns = new Set<string>();
+ for (const filters of brushFilters.values()) {
+ for (const f of filters) {
+ activeColumns.add(f.column);
}
}
- if (allEntries.length === 0) return null;
-
+ if (activeColumns.size === 0) return null;
if (!this.filtersExpanded) return null;
- // Build chips grouped by source.
+ // For each active column, compute the sorted set of source nodeIds that
+ // share it. The stringified set is the grouping key.
+ const sourceNameById = new Map(sources.map((s) => [s.nodeId, s.name]));
+ const columnToKey = new Map<string, string>();
+ const columnToSourceIds = new Map<string, string[]>();
+ for (const col of activeColumns) {
+ const ids = getLinkedSourceNodeIds(sources, '', col).sort();
+ const key = ids.join(',');
+ columnToKey.set(col, key);
+ columnToSourceIds.set(col, ids);
+ }
+
+ // Group columns by their datasource-set key.
+ const groupMap = new Map<
+ string,
+ {sourceIds: string[]; columns: string[]}
+ >();
+ for (const col of activeColumns) {
+ const key = columnToKey.get(col) ?? '';
+ const ids = columnToSourceIds.get(col) ?? [];
+ const entry = groupMap.get(key);
+ if (entry !== undefined) {
+ entry.columns.push(col);
+ } else {
+ groupMap.set(key, {sourceIds: ids, columns: [col]});
+ }
+ }
+
+ // Render each group.
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);
+ for (const {sourceIds, columns} of groupMap.values()) {
+ const label = sourceIds
+ .map((id) => sourceNameById.get(id) ?? id)
+ .join(', ');
+ // Pick any sourceNodeId in the group for clear/remove operations —
+ // those functions already propagate across linked sources.
+ const representativeSourceId = sourceIds[0];
+ const chips = columns.sort().map((col) => {
+ // Gather filters for this column from all sources in the group.
+ const colFilters: DashboardBrushFilter[] = [];
+ for (const id of sourceIds) {
+ for (const f of brushFilters.get(id) ?? []) {
+ if (f.column === col) colFilters.push(f);
+ }
+ }
+ // De-duplicate (filters are propagated identically across sources).
+ const seen = new Set<string>();
+ const uniqueFilters = colFilters.filter((f) => {
+ const k = `${f.column}|${f.op}|${f.value}`;
+ if (seen.has(k)) return false;
+ seen.add(k);
+ return true;
+ });
return m(
Popup,
{
trigger: m(Chip, {
- label: `${col}: ${summarizeBrushFilters(colFilters)}`,
+ label: `${col}: ${summarizeBrushFilters(uniqueFilters)}`,
icon: 'filter_alt',
compact: true,
rounded: true,
removable: true,
onRemove: () => {
- this.clearBrushFiltersForColumn(attrs, sourceNodeId, col);
+ this.clearBrushFiltersForColumn(
+ attrs,
+ representativeSourceId,
+ col,
+ );
},
}),
position: PopupPosition.Bottom,
},
m(
'.pf-dashboard__filter-popup',
- colFilters.map((f) =>
+ uniqueFilters.map((f) =>
m(MenuItem, {
label: formatBrushFilterValue(f),
icon: Icons.Checkbox,
onclick: () => {
- this.removeSingleBrushFilter(attrs, sourceNodeId, f);
+ this.removeSingleBrushFilter(
+ attrs,
+ representativeSourceId,
+ f,
+ );
},
}),
),
@@ -717,7 +799,7 @@
groups.push(
m('.pf-dashboard__filter-group', [
m(Chip, {
- label: sourceName,
+ label,
icon: 'database',
compact: true,
className: 'pf-dashboard__source-chip',
@@ -818,6 +900,82 @@
return sections;
}
+ /**
+ * Renders the "Linked Columns" panel showing columns that appear in
+ * multiple datasources and will be cross-filtered when brushed.
+ */
+ private renderLinkedColumnsPanel(attrs: DashboardAttrs): m.Children {
+ const sources = attrs.sources;
+ if (sources.length < 2) {
+ return m(
+ EmptyState,
+ {icon: 'link', title: 'No linked columns'},
+ 'Add two or more data sources to see columns that are shared across them.',
+ );
+ }
+
+ // Build a map: column name → list of sources that contain it.
+ const columnSources = new Map<
+ string,
+ {source: DashboardDataSource; type: string}[]
+ >();
+ for (const source of sources) {
+ for (const col of source.columns) {
+ const entries = columnSources.get(col.name) ?? [];
+ entries.push({
+ source,
+ type: perfettoSqlTypeToString(col.type),
+ });
+ columnSources.set(col.name, entries);
+ }
+ }
+
+ // Filter to columns that appear in 2+ sources.
+ const linked = [...columnSources.entries()]
+ .filter(([, entries]) => entries.length >= 2)
+ .sort(([a], [b]) => a.localeCompare(b));
+
+ if (linked.length === 0) {
+ return m(
+ EmptyState,
+ {icon: 'link_off', title: 'No shared columns'},
+ 'None of the data sources share a column name. Brushing one source will not filter others.',
+ );
+ }
+
+ return [
+ m('.pf-dashboard__panel-section', [
+ m(
+ '.pf-dashboard__panel-section-title',
+ `Linked columns (${linked.length})`,
+ ),
+ m(
+ '.pf-dashboard__input-list',
+ linked.map(([columnName, entries]) =>
+ m('.pf-dashboard__linked-column', [
+ m('.pf-dashboard__linked-column-name', [
+ m(Icon, {icon: 'link'}),
+ m('code', columnName),
+ ]),
+ m(
+ '.pf-dashboard__linked-column-sources',
+ entries.map((entry) =>
+ m('.pf-dashboard__linked-column-source', [
+ m(
+ 'span.pf-dashboard__linked-source-name',
+ entry.source.name,
+ ),
+ m('span.pf-dashboard__column-type', entry.type),
+ ]),
+ ),
+ ),
+ ]),
+ ),
+ ),
+ ]),
+ ];
+ }
+
private renderInputContent(
source: DashboardDataSource,
attrs: DashboardAttrs,
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
index f3c9707..56793df 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
@@ -33,6 +33,7 @@
DashboardDataSource,
DashboardItem,
getItemId,
+ getLinkedSourceNodeIds,
} from './dashboard_registry';
import {EmptyState} from '../../../widgets/empty_state';
import {SqlValue} from '../../../trace_processor/query_result';
@@ -47,6 +48,7 @@
config: ChartConfig;
dashboardId: string;
items: DashboardItem[];
+ allSources: ReadonlyArray<DashboardDataSource>;
brushFilters: Map<string, DashboardBrushFilter[]>;
onItemsChange: (items: DashboardItem[]) => void;
onBrushFiltersChange: (filters: Map<string, DashboardBrushFilter[]>) => void;
@@ -56,6 +58,7 @@
export interface DashboardChartCallbacks {
dashboardId: string;
items: DashboardItem[];
+ allSources: ReadonlyArray<DashboardDataSource>;
brushFilters: Map<string, DashboardBrushFilter[]>;
onItemsChange: (items: DashboardItem[]) => void;
onBrushFiltersChange: (filters: Map<string, DashboardBrushFilter[]>) => void;
@@ -73,6 +76,10 @@
// clearChartFiltersForColumn + setBrushSelection works the same way as
// the visualisation node (synchronous mutations on the same array).
private filters: DashboardBrushFilter[];
+ // Columns explicitly cleared since the last flush — used by flushFilters
+ // to propagate clears to other datasources even when no new filters are
+ // added for the cleared column.
+ private pendingClearedColumns = new Set<string>();
private callbacks: DashboardChartCallbacks;
private config: ChartConfig;
@@ -126,16 +133,46 @@
} else {
newMap.set(this.sourceNodeId, [...this.filters]);
}
+
+ // Cross-datasource brushing: propagate filters to other sources that
+ // share columns with the same name. Also clear columns that were
+ // explicitly cleared (pendingClearedColumns) even if no new filters
+ // were added for them.
+ const touchedColumns = new Set([
+ ...this.filters.map((f) => f.column),
+ ...this.pendingClearedColumns,
+ ]);
+ this.pendingClearedColumns.clear();
+
+ for (const col of touchedColumns) {
+ const newFiltersForCol = this.filters.filter((f) => f.column === col);
+ for (const id of getLinkedSourceNodeIds(
+ this.callbacks.allSources,
+ this.sourceNodeId,
+ col,
+ )) {
+ if (id === this.sourceNodeId) continue;
+ const existing = newMap.get(id) ?? [];
+ const retained = existing.filter((f) => f.column !== col);
+ const combined = [...retained, ...newFiltersForCol];
+ if (combined.length === 0) {
+ newMap.delete(id);
+ } else {
+ newMap.set(id, combined);
+ }
+ }
+ }
+
this.callbacks.onBrushFiltersChange(newMap);
}
clearChartFiltersForColumn(column: string): void {
- this.filters = this.filters.filter((f) => f.column !== column);
+ this.clearColumnLocally(column);
this.flushFilters();
}
setBrushSelection(column: string, values: SqlValue[]): void {
- this.clearChartFiltersForColumn(column);
+ this.clearColumnLocally(column);
for (const value of values) {
if (value === null) {
this.filters.push({column, op: 'is null'});
@@ -148,13 +185,19 @@
}
addRangeFilter(column: string, min: number, max: number): void {
- this.clearChartFiltersForColumn(column);
+ this.clearColumnLocally(column);
this.filters.push({column, op: '>=', value: min});
this.filters.push({column, op: '<', value: max});
this.flushFilters();
m.redraw();
}
+ /** Remove filters for `column` locally without flushing. */
+ private clearColumnLocally(column: string): void {
+ this.filters = this.filters.filter((f) => f.column !== column);
+ this.pendingClearedColumns.add(column);
+ }
+
updateChart(
chartId: string,
updates: Partial<Omit<ChartConfig, 'id'>>,
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_registry.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_registry.ts
index 1b0eeaf..cd9aad4 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_registry.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_registry.ts
@@ -116,6 +116,23 @@
export const dashboardRegistry = new ExportedSourcesPool();
+/**
+ * Returns the nodeIds of all sources that have a column named `column`,
+ * including `sourceNodeId` itself.
+ */
+export function getLinkedSourceNodeIds(
+ sources: ReadonlyArray<Pick<DashboardDataSource, 'nodeId' | 'columns'>>,
+ sourceNodeId: string,
+ column: string,
+): string[] {
+ return sources
+ .filter(
+ (s) =>
+ s.nodeId === sourceNodeId || s.columns.some((c) => c.name === column),
+ )
+ .map((s) => s.nodeId);
+}
+
const GRID_SIZE = 20;
export function snapToGrid(value: number): number {