From 0713edebdd9568923e7f47b38c3b9df1964f1919 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:31:38 +0000 Subject: [PATCH 1/3] refactor(app): declarative Infrastructure tab correlation list Turn the hardcoded Pod and Node correlation blocks in DBInfraPanel and the matching gate in rowHasK8sContext into a shared declarative descriptor list (detect attribute, correlate attribute, field prefix, chart specs, optional timeline). The gate and the renderer now read from the same list, so they cannot drift apart, and the detect attribute is separated from the correlate attribute so resource types that key on different attributes can be added as data rather than new code paths. No behavior change: the same Infrastructure tab, gate, Pod and Node charts, Pod Timeline, time and size toggles, and Event marker render identically for Kubernetes rows. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/components/DBInfraPanel.tsx | 249 +++++++----------- .../app/src/components/DBRowDataPanel.tsx | 15 +- .../__tests__/infraCorrelations.test.ts | 82 ++++++ .../app/src/components/infraCorrelations.ts | 94 +++++++ 4 files changed, 284 insertions(+), 156 deletions(-) create mode 100644 packages/app/src/components/__tests__/infraCorrelations.test.ts create mode 100644 packages/app/src/components/infraCorrelations.ts diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index 4fca79fc67..996657f349 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -32,22 +32,22 @@ import { TableSourceForm } from '@/components/Sources/SourceForm'; import { IS_LOCAL_MODE } from '@/config'; import { useSource } from '@/source'; -import { - K8S_CPU_PERCENTAGE_NUMBER_FORMAT, - K8S_FILESYSTEM_NUMBER_FORMAT, - K8S_MEM_NUMBER_FORMAT, -} from '../ChartUtils'; - import { DBTimeChart } from './DBTimeChart'; +import { + getActiveInfraCorrelations, + InfraChartSpec, +} from './infraCorrelations'; import { KubeTimeline } from './KubeComponents'; const InfraSubpanelGroup = ({ + charts, fieldPrefix, metricSource, timestamp, title, where, }: { + charts: InfraChartSpec[]; fieldPrefix: string; metricSource: TMetricSource; timestamp: any; @@ -115,96 +115,38 @@ const InfraSubpanelGroup = ({ - - - - - - - - - - - - - - - + {charts.map(chart => ( + + + + + + ))} ); @@ -229,8 +171,11 @@ export default ({ kinds: [SourceKind.Metric], }); - const podUid = rowData?.__hdx_resource_attributes['k8s.pod.uid']; - const nodeName = rowData?.__hdx_resource_attributes['k8s.node.name']; + const resourceAttributes = rowData?.__hdx_resource_attributes; + const activeCorrelations = useMemo( + () => getActiveInfraCorrelations(resourceAttributes), + [resourceAttributes], + ); const timestamp = new Date(rowData?.__hdx_timestamp).getTime(); @@ -269,57 +214,63 @@ export default ({ )} )} - {podUid && ( -
- {metricSource && ( - - )} - {source && source.kind === SourceKind.Log && ( - - - Pod Timeline - - - - - This Event
, - timestamp: new Date(timestamp).toISOString(), - }} - /> - - - - - )} - - )} - {nodeName && metricSource && ( - - )} + {activeCorrelations.map(correlation => { + const value = resourceAttributes?.[correlation.correlateAttribute]; + if (!value) { + return null; + } + const showTimeline = + correlation.timeline != null && source.kind === SourceKind.Log; + // Skip rendering an empty container when neither the metric group nor + // the timeline has anything to show (e.g. no metric source configured + // on a non-Log source). + if (!metricSource && !showTimeline) { + return null; + } + return ( +
+ {metricSource && ( + + )} + {correlation.timeline && source.kind === SourceKind.Log && ( + + + {correlation.title} Timeline + + + + + This Event
, + timestamp: new Date(timestamp).toISOString(), + }} + /> + + + + + )} + + ); + })} ); }; diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 5ca3a6194e..77cc39c054 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -15,6 +15,7 @@ import { getDisplayedTimestampValueExpression, getEventBody } from '@/source'; import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes'; import { DBRowJsonViewer } from './DBRowJsonViewer'; +import { getActiveInfraCorrelations } from './infraCorrelations'; export enum ROW_DATA_ALIASES { TIMESTAMP = '__hdx_timestamp', @@ -190,9 +191,12 @@ export function useRowData({ }; } -// Detects whether a normalized row carries Kubernetes resource attributes, used -// to conditionally surface the Infrastructure tab/panel. Requires the source to -// expose resource attributes; returns false (rather than throwing) on any gap. +// Detects whether a normalized row carries resource attributes that match a +// built-in infrastructure correlation (Kubernetes Pod or Node today), used to +// conditionally surface the Infrastructure tab/panel. Delegates to the same +// descriptor list the panel renders from, so the gate and the render never +// drift apart. Requires the source to expose resource attributes; returns +// false (rather than throwing) on any gap. export function rowHasK8sContext( source: TSource | null | undefined, normalizedRow: Record | null | undefined, @@ -208,10 +212,7 @@ export function rowHasK8sContext( } const resourceAttrs = normalizedRow[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]; - return ( - resourceAttrs?.['k8s.pod.uid'] != null || - resourceAttrs?.['k8s.node.name'] != null - ); + return getActiveInfraCorrelations(resourceAttrs).length > 0; } catch (e) { console.error(e); return false; diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts new file mode 100644 index 0000000000..52f3113206 --- /dev/null +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -0,0 +1,82 @@ +import { + getActiveInfraCorrelations, + INFRA_CORRELATIONS, +} from '../infraCorrelations'; + +describe('getActiveInfraCorrelations', () => { + it('returns the Pod group when only k8s.pod.uid is present', () => { + const active = getActiveInfraCorrelations({ 'k8s.pod.uid': 'pod-abc' }); + expect(active.map(c => c.title)).toEqual(['Pod']); + }); + + it('returns the Node group when only k8s.node.name is present', () => { + const active = getActiveInfraCorrelations({ 'k8s.node.name': 'node-1' }); + expect(active.map(c => c.title)).toEqual(['Node']); + }); + + it('returns both groups in render order when both attributes are present', () => { + const active = getActiveInfraCorrelations({ + 'k8s.pod.uid': 'pod-abc', + 'k8s.node.name': 'node-1', + }); + expect(active.map(c => c.title)).toEqual(['Pod', 'Node']); + }); + + it('returns no groups when no detect attribute is present', () => { + expect(getActiveInfraCorrelations({})).toEqual([]); + }); + + it('returns no groups for unrelated resource attributes', () => { + expect( + getActiveInfraCorrelations({ + 'host.name': 'web-1', + 'service.name': 'api', + }), + ).toEqual([]); + }); + + it('returns no groups when resource attributes are null or undefined', () => { + expect(getActiveInfraCorrelations(undefined)).toEqual([]); + expect(getActiveInfraCorrelations(null)).toEqual([]); + }); + + // The gate uses != null, not truthiness, matching the prior hardcoded gate. + it('treats a detect attribute explicitly set to null as absent', () => { + expect(getActiveInfraCorrelations({ 'k8s.pod.uid': null })).toEqual([]); + }); +}); + +describe('INFRA_CORRELATIONS built-ins', () => { + it('preserves the Kubernetes Pod and Node correlation identity', () => { + expect(INFRA_CORRELATIONS).toMatchObject([ + { + title: 'Pod', + detectAttribute: 'k8s.pod.uid', + correlateAttribute: 'k8s.pod.uid', + fieldPrefix: 'k8s.pod.', + timeline: { queryAttribute: 'k8s.pod.uid' }, + }, + { + title: 'Node', + detectAttribute: 'k8s.node.name', + correlateAttribute: 'k8s.node.name', + fieldPrefix: 'k8s.node.', + }, + ]); + }); + + it('keeps the Pod Timeline only on the Pod group', () => { + const node = INFRA_CORRELATIONS.find(c => c.title === 'Node'); + expect(node?.timeline).toBeUndefined(); + }); + + it('keeps the three k8s metric fields and card test ids on every group', () => { + for (const correlation of INFRA_CORRELATIONS) { + expect(correlation.charts.map(c => [c.cardTestId, c.field])).toEqual([ + ['cpu-usage-card', 'cpu.utilization'], + ['memory-usage-card', 'memory.usage'], + ['disk-usage-card', 'filesystem.available'], + ]); + } + }); +}); diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts new file mode 100644 index 0000000000..19fe692f74 --- /dev/null +++ b/packages/app/src/components/infraCorrelations.ts @@ -0,0 +1,94 @@ +import { + K8S_CPU_PERCENTAGE_NUMBER_FORMAT, + K8S_FILESYSTEM_NUMBER_FORMAT, + K8S_MEM_NUMBER_FORMAT, +} from '@/ChartUtils'; +import { NumberFormat } from '@/types'; + +// One metric chart inside an infrastructure correlation group. The rendered +// metric field is `${fieldPrefix}${field} - Gauge` (see DBInfraPanel), so +// `field` is the metric name without the resource prefix or the type suffix. +export type InfraChartSpec = { + title: string; + // data-testid for the chart card; the e2e suite selects on these. + cardTestId: string; + field: string; + numberFormat: NumberFormat; +}; + +// A declarative infrastructure correlation group. `detectAttribute` decides +// whether the group (and the Infrastructure tab) appears for an opened row; +// `correlateAttribute` is the resource attribute the metrics are filtered by. +// They match for Kubernetes today but are kept separate so resource types that +// detect on one attribute and correlate on another can be added as data rather +// than new code paths. +export type InfraCorrelation = { + title: string; + detectAttribute: string; + correlateAttribute: string; + // Metric field prefix, e.g. "k8s.pod.". + fieldPrefix: string; + charts: InfraChartSpec[]; + // Optional Kubernetes event timeline (Log sources only). + timeline?: { + queryAttribute: string; + }; +}; + +// Pod and Node render the same three charts; only the field prefix and the +// correlate filter differ, so the specs are shared. +const K8S_CHART_SPECS: InfraChartSpec[] = [ + { + title: 'CPU Usage (%)', + cardTestId: 'cpu-usage-card', + field: 'cpu.utilization', + numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT, + }, + { + title: 'Memory Used', + cardTestId: 'memory-usage-card', + field: 'memory.usage', + numberFormat: K8S_MEM_NUMBER_FORMAT, + }, + { + title: 'Disk Available', + cardTestId: 'disk-usage-card', + field: 'filesystem.available', + numberFormat: K8S_FILESYSTEM_NUMBER_FORMAT, + }, +]; + +// Built-in correlation groups. Array order is the render order in the +// Infrastructure panel (Pod, then Node), matching the prior hardcoding. +export const INFRA_CORRELATIONS: InfraCorrelation[] = [ + { + title: 'Pod', + detectAttribute: 'k8s.pod.uid', + correlateAttribute: 'k8s.pod.uid', + fieldPrefix: 'k8s.pod.', + charts: K8S_CHART_SPECS, + timeline: { queryAttribute: 'k8s.pod.uid' }, + }, + { + title: 'Node', + detectAttribute: 'k8s.node.name', + correlateAttribute: 'k8s.node.name', + fieldPrefix: 'k8s.node.', + charts: K8S_CHART_SPECS, + }, +]; + +// Returns the built-in correlation groups whose detect attribute is present +// (non-null) on the given resource attributes. This is the single source of +// truth for both the Infrastructure tab gate (rowHasK8sContext) and the panel +// renderer (DBInfraPanel), so the gate and the render never drift apart. +export function getActiveInfraCorrelations( + resourceAttributes: Record | null | undefined, +): InfraCorrelation[] { + if (!resourceAttributes) { + return []; + } + return INFRA_CORRELATIONS.filter( + correlation => resourceAttributes[correlation.detectAttribute] != null, + ); +} From cb274b821f49ffb22bb47db19c3e3c527e640f51 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:32:39 +0000 Subject: [PATCH 2/3] refactor(app): make infra correlation descriptors readonly Mark the InfraCorrelation / InfraChartSpec descriptor data (including the shared K8S_CHART_SPECS array reused by the Pod and Node groups) as readonly so the shared reference cannot be mutated through one descriptor and affect the other. No behavior change. Also document that the render-time truthiness guard intentionally mirrors the prior Pod/Node render blocks; the tab gate uses != null, and the two only diverge once a descriptor splits its detect and correlate attributes, which is handled when such a descriptor is added. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/components/DBInfraPanel.tsx | 8 +++++- .../app/src/components/infraCorrelations.ts | 28 +++++++++---------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index 996657f349..d0342dcbed 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -47,7 +47,7 @@ const InfraSubpanelGroup = ({ title, where, }: { - charts: InfraChartSpec[]; + charts: readonly InfraChartSpec[]; fieldPrefix: string; metricSource: TMetricSource; timestamp: any; @@ -216,6 +216,12 @@ export default ({ )} {activeCorrelations.map(correlation => { const value = resourceAttributes?.[correlation.correlateAttribute]; + // Truthiness guard, mirroring the previous Pod/Node render blocks + // (which gated on the attribute value with `&&`); the tab gate uses + // != null. detect and correlate are the same attribute for the + // built-in k8s descriptors, so this stays byte-identical. A future + // descriptor that splits the two decides here how an empty correlate + // value should render. if (!value) { return null; } diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts index 19fe692f74..0e0c4484e0 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -9,11 +9,11 @@ import { NumberFormat } from '@/types'; // metric field is `${fieldPrefix}${field} - Gauge` (see DBInfraPanel), so // `field` is the metric name without the resource prefix or the type suffix. export type InfraChartSpec = { - title: string; + readonly title: string; // data-testid for the chart card; the e2e suite selects on these. - cardTestId: string; - field: string; - numberFormat: NumberFormat; + readonly cardTestId: string; + readonly field: string; + readonly numberFormat: NumberFormat; }; // A declarative infrastructure correlation group. `detectAttribute` decides @@ -23,21 +23,21 @@ export type InfraChartSpec = { // detect on one attribute and correlate on another can be added as data rather // than new code paths. export type InfraCorrelation = { - title: string; - detectAttribute: string; - correlateAttribute: string; + readonly title: string; + readonly detectAttribute: string; + readonly correlateAttribute: string; // Metric field prefix, e.g. "k8s.pod.". - fieldPrefix: string; - charts: InfraChartSpec[]; + readonly fieldPrefix: string; + readonly charts: readonly InfraChartSpec[]; // Optional Kubernetes event timeline (Log sources only). - timeline?: { - queryAttribute: string; + readonly timeline?: { + readonly queryAttribute: string; }; }; // Pod and Node render the same three charts; only the field prefix and the // correlate filter differ, so the specs are shared. -const K8S_CHART_SPECS: InfraChartSpec[] = [ +const K8S_CHART_SPECS: readonly InfraChartSpec[] = [ { title: 'CPU Usage (%)', cardTestId: 'cpu-usage-card', @@ -60,7 +60,7 @@ const K8S_CHART_SPECS: InfraChartSpec[] = [ // Built-in correlation groups. Array order is the render order in the // Infrastructure panel (Pod, then Node), matching the prior hardcoding. -export const INFRA_CORRELATIONS: InfraCorrelation[] = [ +export const INFRA_CORRELATIONS: readonly InfraCorrelation[] = [ { title: 'Pod', detectAttribute: 'k8s.pod.uid', @@ -84,7 +84,7 @@ export const INFRA_CORRELATIONS: InfraCorrelation[] = [ // renderer (DBInfraPanel), so the gate and the render never drift apart. export function getActiveInfraCorrelations( resourceAttributes: Record | null | undefined, -): InfraCorrelation[] { +): readonly InfraCorrelation[] { if (!resourceAttributes) { return []; } From 6bcec128d4f2fd2f3138ff03420dad46a5f4b529 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:51:54 +0000 Subject: [PATCH 3/3] test(app): assert Infrastructure tab is hidden for non-k8s rows Adds an e2e negative case: open a non-Kubernetes log row and confirm the Infrastructure tab is not offered. This locks the gate (the tab appears only when a built-in correlation's detect attribute is present) at the rendered-UI level, complementing the existing positive case that asserts the Pod and Node charts render for a k8s row. Co-Authored-By: Claude Opus 4.7 --- .../tests/e2e/features/search/search.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/app/tests/e2e/features/search/search.spec.ts b/packages/app/tests/e2e/features/search/search.spec.ts index c9dda8cb1c..5ca57692ff 100644 --- a/packages/app/tests/e2e/features/search/search.spec.ts +++ b/packages/app/tests/e2e/features/search/search.spec.ts @@ -141,6 +141,29 @@ test.describe('Search', { tag: '@search' }, () => { }); }); + test('Infrastructure tab is hidden for non-Kubernetes rows', async () => { + await test.step('Open a non-Kubernetes log row', async () => { + // Regular logs set ResourceAttributes.service.name to a real service + // name; k8s logs set it to a pod name, so this matches only non-k8s rows. + await searchPage.performSearch( + 'ResourceAttributes.service.name:"api-server"', + ); + await expect(searchPage.table.firstRow).toBeVisible(); + await searchPage.table.clickFirstRow(); + await expect(searchPage.sidePanel.tabs).toBeVisible(); + }); + + await test.step('Infrastructure tab is not offered', async () => { + // The tab bar rendered (an always-present tab is visible), but the gate + // omits Infrastructure because the row carries no k8s correlation + // attributes. + await expect(searchPage.sidePanel.getTab('trace')).toBeVisible(); + await expect(searchPage.sidePanel.getTab('infrastructure')).toHaveCount( + 0, + ); + }); + }); + test('Time Picker Integration with Search', async () => { await test.step('Interact with time picker', async () => { await expect(searchPage.timePicker.input).toBeVisible();