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 {