feat: Allow selection of log and metric source on K8s dashboard (#1245)

Closes HDX-1887

This change allows the user to select which log and metric sources the k8s dashboard should show. Previously, the user could only select a connection, and the first log and metric source in that connection would be used.

<img width="1756" height="1121" alt="Screenshot 2025-10-07 at 2 50 34 PM" src="https://github.com/user-attachments/assets/f6e4f375-1f8d-486c-8940-4ee2ac38b94d" />
This commit is contained in:
Drew Davis 2025-10-09 15:15:21 -04:00 committed by GitHub
parent 4949748f99
commit 13b191c8a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 293 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Allow selection of log and metric source on K8s dashboard

View file

@ -1,31 +1,20 @@
import * as React from 'react';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import cx from 'classnames';
import sub from 'date-fns/sub';
import {
parseAsFloat,
parseAsStringEnum,
useQueryState,
useQueryStates,
} from 'nuqs';
import { useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import {
SearchConditionLanguage,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Badge,
Box,
Card,
Flex,
Grid,
Group,
Loader,
ScrollArea,
SegmentedControl,
Skeleton,
@ -37,12 +26,13 @@ import {
import { TimePicker } from '@/components/TimePicker';
import { ConnectionSelectControlled } from './components/ConnectionSelect';
import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
import { DBTimeChart } from './components/DBTimeChart';
import { FormatPodStatus } from './components/KubeComponents';
import { KubernetesFilters } from './components/KubernetesFilters';
import OnboardingModal from './components/OnboardingModal';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { SourceSelectControlled } from './components/SourceSelect';
import { useQueriedChartConfig } from './hooks/useChartConfig';
import {
convertDateRangeToGranularityString,
@ -50,12 +40,11 @@ import {
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
K8S_MEM_NUMBER_FORMAT,
} from './ChartUtils';
import { useConnections } from './connection';
import { withAppNav } from './layout';
import NamespaceDetailsSidePanel from './NamespaceDetailsSidePanel';
import NodeDetailsSidePanel from './NodeDetailsSidePanel';
import PodDetailsSidePanel from './PodDetailsSidePanel';
import { getEventBody, useSource, useSources } from './source';
import { useSources } from './source';
import { parseTimeQuery, useTimeQuery } from './timeQuery';
import { KubePhase } from './types';
import { formatNumber, formatUptime } from './utils';
@ -768,30 +757,85 @@ const defaultTimeRange = parseTimeQuery('Past 1h', false);
const CHART_HEIGHT = 300;
export const resolveSourceIds = (
_logSourceId: string | null | undefined,
_metricSourceId: string | null | undefined,
sources: TSource[] | undefined,
) => {
if (_logSourceId && _metricSourceId) {
return [_logSourceId, _metricSourceId];
}
// Default the metric source to the first one from the same connection as the log source
if (_logSourceId && !_metricSourceId && sources) {
const { connection } = sources.find(s => s.id === _logSourceId) ?? {};
const metricSource = sources.find(
s => s.connection === connection && s.kind === SourceKind.Metric,
);
return [_logSourceId, metricSource?.id];
}
// Default the log source to the first one from the same connection as the metric source
if (!_logSourceId && _metricSourceId && sources) {
const { connection } = sources.find(s => s.id === _metricSourceId) ?? {};
const logSource = sources.find(
s => s.connection === connection && s.kind === SourceKind.Log,
);
return [logSource?.id, _metricSourceId];
}
// Find a Log and Metric source from the same connection
if (sources) {
const connections = sources.map(s => s.connection);
const connectionWithBothSourceKinds = connections.find(
conn =>
sources.some(s => s.connection === conn && s.kind === SourceKind.Log) &&
sources.some(
s => s.connection === conn && s.kind === SourceKind.Metric,
),
);
const logSource = sources.find(
s =>
s.connection === connectionWithBothSourceKinds &&
s.kind === SourceKind.Log,
);
const metricSource = sources.find(
s =>
s.connection === connectionWithBothSourceKinds &&
s.kind === SourceKind.Metric,
);
return [logSource?.id, metricSource?.id];
}
return [_logSourceId, _metricSourceId];
};
function KubernetesDashboardPage() {
const { data: connections } = useConnections();
const [_connection, setConnection] = useQueryState('connection');
const { data: sources } = useSources();
const connection = _connection ?? connections?.[0]?.id ?? '';
const [_logSourceId, setLogSourceId] = useQueryState('logSource');
const [_metricSourceId, setMetricSourceId] = useQueryState('metricSource');
// TODO: Let users select log + metric sources
const { data: sources, isLoading: isLoadingSources } = useSources();
const logSource = sources?.find(
s => s.kind === SourceKind.Log && s.connection === connection,
);
const metricSource = sources?.find(
s => s.kind === SourceKind.Metric && s.connection === connection,
const [logSourceId, metricSourceId] = useMemo(
() => resolveSourceIds(_logSourceId, _metricSourceId, sources),
[_logSourceId, _metricSourceId, sources],
);
const logSource = sources?.find(s => s.id === logSourceId);
const metricSource = sources?.find(s => s.id === metricSourceId);
const { control, watch } = useForm({
values: {
connection,
logSourceId,
metricSourceId,
},
});
watch((data, { name, type }) => {
if (name === 'connection' && type === 'change') {
setConnection(data.connection ?? null);
if (name === 'logSourceId' && type === 'change') {
setLogSourceId(data.logSourceId ?? null);
} else if (name === 'metricSourceId' && type === 'change') {
setMetricSourceId(data.metricSourceId ?? null);
}
});
@ -859,11 +903,25 @@ function KubernetesDashboardPage() {
<Text c="gray.4" size="xl">
Kubernetes Dashboard
</Text>
<ConnectionSelectControlled
data-testid="kubernetes-connection-select"
<SourceSelectControlled
name="logSourceId"
control={control}
name="connection"
allowedSourceKinds={[SourceKind.Log]}
size="xs"
allowDeselect={false}
sourceSchemaPreview={
<SourceSchemaPreview source={logSource} variant="text" />
}
/>
<SourceSelectControlled
name="metricSourceId"
control={control}
allowedSourceKinds={[SourceKind.Metric]}
size="xs"
allowDeselect={false}
sourceSchemaPreview={
<SourceSchemaPreview source={metricSource} variant="text" />
}
/>
</Group>

View file

@ -0,0 +1,197 @@
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { resolveSourceIds } from '@/KubernetesDashboardPage';
describe('resolveSourceIds', () => {
const mockLogSource: TSource = {
id: 'log-1',
name: 'Log Source 1',
kind: SourceKind.Log,
connection: 'connection-1',
// Add minimal required fields for TSource
} as TSource;
const mockMetricSource: TSource = {
id: 'metric-1',
name: 'Metric Source 1',
kind: SourceKind.Metric,
connection: 'connection-1',
// Add minimal required fields for TSource
} as TSource;
const mockLogSource2: TSource = {
id: 'log-2',
name: 'Log Source 2',
kind: SourceKind.Log,
connection: 'connection-2',
} as TSource;
const mockMetricSource2: TSource = {
id: 'metric-2',
name: 'Metric Source 2',
kind: SourceKind.Metric,
connection: 'connection-2',
} as TSource;
describe('when both source IDs are provided', () => {
it('should return both source IDs as-is', () => {
const result = resolveSourceIds('log-1', 'metric-1', [
mockLogSource,
mockMetricSource,
]);
expect(result).toEqual(['log-1', 'metric-1']);
});
it('should return both source IDs even if sources array is undefined', () => {
const result = resolveSourceIds('log-1', 'metric-1', undefined);
expect(result).toEqual(['log-1', 'metric-1']);
});
it('should return both source IDs even if they are not in the sources array', () => {
const result = resolveSourceIds('log-999', 'metric-999', [
mockLogSource,
mockMetricSource,
]);
expect(result).toEqual(['log-999', 'metric-999']);
});
});
describe('when only log source ID is provided', () => {
it('should find metric source from the same connection', () => {
const sources = [
mockLogSource,
mockMetricSource,
mockMetricSource2,
mockLogSource2,
];
const result = resolveSourceIds('log-2', null, sources);
expect(result).toEqual(['log-2', 'metric-2']);
});
it('should return undefined for metric source if no matching connection', () => {
const sources = [mockLogSource, mockMetricSource2];
const result = resolveSourceIds('log-1', null, sources);
expect(result).toEqual(['log-1', undefined]);
});
it('should return undefined for metric source if log source not found', () => {
const sources = [mockLogSource, mockMetricSource];
const result = resolveSourceIds('log-999', null, sources);
expect(result).toEqual(['log-999', undefined]);
});
it('should return log source ID and undefined if sources array is undefined', () => {
const result = resolveSourceIds('log-1', null, undefined);
expect(result).toEqual(['log-1', null]);
});
it('should handle undefined metric source ID', () => {
const sources = [mockLogSource, mockMetricSource];
const result = resolveSourceIds('log-1', undefined, sources);
expect(result).toEqual(['log-1', 'metric-1']);
});
});
describe('when only metric source ID is provided', () => {
it('should find log source from the same connection', () => {
const sources = [mockLogSource, mockMetricSource];
const result = resolveSourceIds(null, 'metric-1', sources);
expect(result).toEqual(['log-1', 'metric-1']);
});
it('should return undefined for log source if no matching connection', () => {
const sources = [mockLogSource2, mockMetricSource];
const result = resolveSourceIds(null, 'metric-1', sources);
expect(result).toEqual([undefined, 'metric-1']);
});
it('should return undefined for log source if metric source not found', () => {
const sources = [mockLogSource, mockMetricSource];
const result = resolveSourceIds(null, 'metric-999', sources);
expect(result).toEqual([undefined, 'metric-999']);
});
it('should return undefined and metric source ID if sources array is undefined', () => {
const result = resolveSourceIds(null, 'metric-1', undefined);
expect(result).toEqual([null, 'metric-1']);
});
it('should handle undefined log source ID', () => {
const sources = [mockLogSource, mockMetricSource];
const result = resolveSourceIds(undefined, 'metric-1', sources);
expect(result).toEqual(['log-1', 'metric-1']);
});
});
describe('when neither source ID is provided', () => {
it('should find log and metric sources from the same connection', () => {
const sources = [
mockLogSource,
mockMetricSource,
mockLogSource2,
mockMetricSource2,
];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual(['log-1', 'metric-1']);
});
it('should return sources from the connection with both source kinds', () => {
const sources = [mockLogSource2, mockLogSource, mockMetricSource];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual(['log-1', 'metric-1']);
});
it('should handle connection with only one source kind', () => {
const sources = [mockLogSource, mockMetricSource2];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual([undefined, undefined]);
});
it('should return [null, null] if sources array is undefined', () => {
const result = resolveSourceIds(null, null, undefined);
expect(result).toEqual([null, null]);
});
it('should return [undefined, undefined] if sources array is empty', () => {
const result = resolveSourceIds(null, null, []);
expect(result).toEqual([undefined, undefined]);
});
it('should return [undefined, undefined] if no connection has both kinds', () => {
const sources = [mockLogSource];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual([undefined, undefined]);
});
});
describe('edge cases', () => {
it('should handle multiple sources on the same connection', () => {
const logSource3: TSource = {
id: 'log-3',
name: 'Log Source 3',
kind: SourceKind.Log,
connection: 'connection-1',
} as TSource;
const metricSource3: TSource = {
id: 'metric-3',
name: 'Metric Source 3',
kind: SourceKind.Metric,
connection: 'connection-1',
} as TSource;
const sources = [
mockLogSource,
logSource3,
mockMetricSource,
metricSource3,
];
// When log source is specified, should find first metric on same connection
const result1 = resolveSourceIds('log-1', null, sources);
expect(result1).toEqual(['log-1', 'metric-1']);
// When metric source is specified, should find first log on same connection
const result2 = resolveSourceIds(null, 'metric-3', sources);
expect(result2).toEqual(['log-1', 'metric-3']);
});
});
});