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();