diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx
index 4fca79fc67..d0342dcbed 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: readonly 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,69 @@ 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];
+ // 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;
+ }
+ 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..0e0c4484e0
--- /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 = {
+ readonly title: string;
+ // data-testid for the chart card; the e2e suite selects on these.
+ readonly cardTestId: string;
+ readonly field: string;
+ readonly 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 = {
+ readonly title: string;
+ readonly detectAttribute: string;
+ readonly correlateAttribute: string;
+ // Metric field prefix, e.g. "k8s.pod.".
+ readonly fieldPrefix: string;
+ readonly charts: readonly InfraChartSpec[];
+ // Optional Kubernetes event timeline (Log sources only).
+ 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: readonly 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: readonly 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,
+): readonly InfraCorrelation[] {
+ if (!resourceAttributes) {
+ return [];
+ }
+ return INFRA_CORRELATIONS.filter(
+ correlation => resourceAttributes[correlation.detectAttribute] != null,
+ );
+}
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();