Skip to content

Commit 13b191c

Browse files
authored
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" />
1 parent 4949748 commit 13b191c

File tree

3 files changed

+293
-33
lines changed

3 files changed

+293
-33
lines changed

.changeset/bright-taxis-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Allow selection of log and metric source on K8s dashboard

packages/app/src/KubernetesDashboardPage.tsx

Lines changed: 91 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
11
import * as React from 'react';
2+
import { useMemo } from 'react';
23
import dynamic from 'next/dynamic';
3-
import Head from 'next/head';
44
import Link from 'next/link';
55
import cx from 'classnames';
66
import sub from 'date-fns/sub';
7-
import {
8-
parseAsFloat,
9-
parseAsStringEnum,
10-
useQueryState,
11-
useQueryStates,
12-
} from 'nuqs';
7+
import { useQueryState } from 'nuqs';
138
import { useForm } from 'react-hook-form';
149
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
10+
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
1511
import {
16-
SearchConditionLanguage,
17-
SourceKind,
18-
TSource,
19-
} from '@hyperdx/common-utils/dist/types';
20-
import {
21-
Anchor,
2212
Badge,
2313
Box,
2414
Card,
2515
Flex,
2616
Grid,
2717
Group,
28-
Loader,
2918
ScrollArea,
3019
SegmentedControl,
3120
Skeleton,
@@ -37,25 +26,25 @@ import {
3726

3827
import { TimePicker } from '@/components/TimePicker';
3928

40-
import { ConnectionSelectControlled } from './components/ConnectionSelect';
4129
import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
4230
import { DBTimeChart } from './components/DBTimeChart';
4331
import { FormatPodStatus } from './components/KubeComponents';
4432
import { KubernetesFilters } from './components/KubernetesFilters';
4533
import OnboardingModal from './components/OnboardingModal';
34+
import SourceSchemaPreview from './components/SourceSchemaPreview';
35+
import { SourceSelectControlled } from './components/SourceSelect';
4636
import { useQueriedChartConfig } from './hooks/useChartConfig';
4737
import {
4838
convertDateRangeToGranularityString,
4939
convertV1ChartConfigToV2,
5040
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
5141
K8S_MEM_NUMBER_FORMAT,
5242
} from './ChartUtils';
53-
import { useConnections } from './connection';
5443
import { withAppNav } from './layout';
5544
import NamespaceDetailsSidePanel from './NamespaceDetailsSidePanel';
5645
import NodeDetailsSidePanel from './NodeDetailsSidePanel';
5746
import PodDetailsSidePanel from './PodDetailsSidePanel';
58-
import { getEventBody, useSource, useSources } from './source';
47+
import { useSources } from './source';
5948
import { parseTimeQuery, useTimeQuery } from './timeQuery';
6049
import { KubePhase } from './types';
6150
import { formatNumber, formatUptime } from './utils';
@@ -768,30 +757,85 @@ const defaultTimeRange = parseTimeQuery('Past 1h', false);
768757

769758
const CHART_HEIGHT = 300;
770759

760+
export const resolveSourceIds = (
761+
_logSourceId: string | null | undefined,
762+
_metricSourceId: string | null | undefined,
763+
sources: TSource[] | undefined,
764+
) => {
765+
if (_logSourceId && _metricSourceId) {
766+
return [_logSourceId, _metricSourceId];
767+
}
768+
769+
// Default the metric source to the first one from the same connection as the log source
770+
if (_logSourceId && !_metricSourceId && sources) {
771+
const { connection } = sources.find(s => s.id === _logSourceId) ?? {};
772+
const metricSource = sources.find(
773+
s => s.connection === connection && s.kind === SourceKind.Metric,
774+
);
775+
return [_logSourceId, metricSource?.id];
776+
}
777+
778+
// Default the log source to the first one from the same connection as the metric source
779+
if (!_logSourceId && _metricSourceId && sources) {
780+
const { connection } = sources.find(s => s.id === _metricSourceId) ?? {};
781+
const logSource = sources.find(
782+
s => s.connection === connection && s.kind === SourceKind.Log,
783+
);
784+
return [logSource?.id, _metricSourceId];
785+
}
786+
787+
// Find a Log and Metric source from the same connection
788+
if (sources) {
789+
const connections = sources.map(s => s.connection);
790+
const connectionWithBothSourceKinds = connections.find(
791+
conn =>
792+
sources.some(s => s.connection === conn && s.kind === SourceKind.Log) &&
793+
sources.some(
794+
s => s.connection === conn && s.kind === SourceKind.Metric,
795+
),
796+
);
797+
const logSource = sources.find(
798+
s =>
799+
s.connection === connectionWithBothSourceKinds &&
800+
s.kind === SourceKind.Log,
801+
);
802+
const metricSource = sources.find(
803+
s =>
804+
s.connection === connectionWithBothSourceKinds &&
805+
s.kind === SourceKind.Metric,
806+
);
807+
return [logSource?.id, metricSource?.id];
808+
}
809+
810+
return [_logSourceId, _metricSourceId];
811+
};
812+
771813
function KubernetesDashboardPage() {
772-
const { data: connections } = useConnections();
773-
const [_connection, setConnection] = useQueryState('connection');
814+
const { data: sources } = useSources();
774815

775-
const connection = _connection ?? connections?.[0]?.id ?? '';
816+
const [_logSourceId, setLogSourceId] = useQueryState('logSource');
817+
const [_metricSourceId, setMetricSourceId] = useQueryState('metricSource');
776818

777-
// TODO: Let users select log + metric sources
778-
const { data: sources, isLoading: isLoadingSources } = useSources();
779-
const logSource = sources?.find(
780-
s => s.kind === SourceKind.Log && s.connection === connection,
781-
);
782-
const metricSource = sources?.find(
783-
s => s.kind === SourceKind.Metric && s.connection === connection,
819+
const [logSourceId, metricSourceId] = useMemo(
820+
() => resolveSourceIds(_logSourceId, _metricSourceId, sources),
821+
[_logSourceId, _metricSourceId, sources],
784822
);
785823

824+
const logSource = sources?.find(s => s.id === logSourceId);
825+
const metricSource = sources?.find(s => s.id === metricSourceId);
826+
786827
const { control, watch } = useForm({
787828
values: {
788-
connection,
829+
logSourceId,
830+
metricSourceId,
789831
},
790832
});
791833

792834
watch((data, { name, type }) => {
793-
if (name === 'connection' && type === 'change') {
794-
setConnection(data.connection ?? null);
835+
if (name === 'logSourceId' && type === 'change') {
836+
setLogSourceId(data.logSourceId ?? null);
837+
} else if (name === 'metricSourceId' && type === 'change') {
838+
setMetricSourceId(data.metricSourceId ?? null);
795839
}
796840
});
797841

@@ -859,11 +903,25 @@ function KubernetesDashboardPage() {
859903
<Text c="gray.4" size="xl">
860904
Kubernetes Dashboard
861905
</Text>
862-
<ConnectionSelectControlled
863-
data-testid="kubernetes-connection-select"
906+
<SourceSelectControlled
907+
name="logSourceId"
908+
control={control}
909+
allowedSourceKinds={[SourceKind.Log]}
910+
size="xs"
911+
allowDeselect={false}
912+
sourceSchemaPreview={
913+
<SourceSchemaPreview source={logSource} variant="text" />
914+
}
915+
/>
916+
<SourceSelectControlled
917+
name="metricSourceId"
864918
control={control}
865-
name="connection"
919+
allowedSourceKinds={[SourceKind.Metric]}
866920
size="xs"
921+
allowDeselect={false}
922+
sourceSchemaPreview={
923+
<SourceSchemaPreview source={metricSource} variant="text" />
924+
}
867925
/>
868926
</Group>
869927

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
2+
3+
import { resolveSourceIds } from '@/KubernetesDashboardPage';
4+
5+
describe('resolveSourceIds', () => {
6+
const mockLogSource: TSource = {
7+
id: 'log-1',
8+
name: 'Log Source 1',
9+
kind: SourceKind.Log,
10+
connection: 'connection-1',
11+
// Add minimal required fields for TSource
12+
} as TSource;
13+
14+
const mockMetricSource: TSource = {
15+
id: 'metric-1',
16+
name: 'Metric Source 1',
17+
kind: SourceKind.Metric,
18+
connection: 'connection-1',
19+
// Add minimal required fields for TSource
20+
} as TSource;
21+
22+
const mockLogSource2: TSource = {
23+
id: 'log-2',
24+
name: 'Log Source 2',
25+
kind: SourceKind.Log,
26+
connection: 'connection-2',
27+
} as TSource;
28+
29+
const mockMetricSource2: TSource = {
30+
id: 'metric-2',
31+
name: 'Metric Source 2',
32+
kind: SourceKind.Metric,
33+
connection: 'connection-2',
34+
} as TSource;
35+
36+
describe('when both source IDs are provided', () => {
37+
it('should return both source IDs as-is', () => {
38+
const result = resolveSourceIds('log-1', 'metric-1', [
39+
mockLogSource,
40+
mockMetricSource,
41+
]);
42+
expect(result).toEqual(['log-1', 'metric-1']);
43+
});
44+
45+
it('should return both source IDs even if sources array is undefined', () => {
46+
const result = resolveSourceIds('log-1', 'metric-1', undefined);
47+
expect(result).toEqual(['log-1', 'metric-1']);
48+
});
49+
50+
it('should return both source IDs even if they are not in the sources array', () => {
51+
const result = resolveSourceIds('log-999', 'metric-999', [
52+
mockLogSource,
53+
mockMetricSource,
54+
]);
55+
expect(result).toEqual(['log-999', 'metric-999']);
56+
});
57+
});
58+
59+
describe('when only log source ID is provided', () => {
60+
it('should find metric source from the same connection', () => {
61+
const sources = [
62+
mockLogSource,
63+
mockMetricSource,
64+
mockMetricSource2,
65+
mockLogSource2,
66+
];
67+
const result = resolveSourceIds('log-2', null, sources);
68+
expect(result).toEqual(['log-2', 'metric-2']);
69+
});
70+
71+
it('should return undefined for metric source if no matching connection', () => {
72+
const sources = [mockLogSource, mockMetricSource2];
73+
const result = resolveSourceIds('log-1', null, sources);
74+
expect(result).toEqual(['log-1', undefined]);
75+
});
76+
77+
it('should return undefined for metric source if log source not found', () => {
78+
const sources = [mockLogSource, mockMetricSource];
79+
const result = resolveSourceIds('log-999', null, sources);
80+
expect(result).toEqual(['log-999', undefined]);
81+
});
82+
83+
it('should return log source ID and undefined if sources array is undefined', () => {
84+
const result = resolveSourceIds('log-1', null, undefined);
85+
expect(result).toEqual(['log-1', null]);
86+
});
87+
88+
it('should handle undefined metric source ID', () => {
89+
const sources = [mockLogSource, mockMetricSource];
90+
const result = resolveSourceIds('log-1', undefined, sources);
91+
expect(result).toEqual(['log-1', 'metric-1']);
92+
});
93+
});
94+
95+
describe('when only metric source ID is provided', () => {
96+
it('should find log source from the same connection', () => {
97+
const sources = [mockLogSource, mockMetricSource];
98+
const result = resolveSourceIds(null, 'metric-1', sources);
99+
expect(result).toEqual(['log-1', 'metric-1']);
100+
});
101+
102+
it('should return undefined for log source if no matching connection', () => {
103+
const sources = [mockLogSource2, mockMetricSource];
104+
const result = resolveSourceIds(null, 'metric-1', sources);
105+
expect(result).toEqual([undefined, 'metric-1']);
106+
});
107+
108+
it('should return undefined for log source if metric source not found', () => {
109+
const sources = [mockLogSource, mockMetricSource];
110+
const result = resolveSourceIds(null, 'metric-999', sources);
111+
expect(result).toEqual([undefined, 'metric-999']);
112+
});
113+
114+
it('should return undefined and metric source ID if sources array is undefined', () => {
115+
const result = resolveSourceIds(null, 'metric-1', undefined);
116+
expect(result).toEqual([null, 'metric-1']);
117+
});
118+
119+
it('should handle undefined log source ID', () => {
120+
const sources = [mockLogSource, mockMetricSource];
121+
const result = resolveSourceIds(undefined, 'metric-1', sources);
122+
expect(result).toEqual(['log-1', 'metric-1']);
123+
});
124+
});
125+
126+
describe('when neither source ID is provided', () => {
127+
it('should find log and metric sources from the same connection', () => {
128+
const sources = [
129+
mockLogSource,
130+
mockMetricSource,
131+
mockLogSource2,
132+
mockMetricSource2,
133+
];
134+
const result = resolveSourceIds(null, null, sources);
135+
expect(result).toEqual(['log-1', 'metric-1']);
136+
});
137+
138+
it('should return sources from the connection with both source kinds', () => {
139+
const sources = [mockLogSource2, mockLogSource, mockMetricSource];
140+
const result = resolveSourceIds(null, null, sources);
141+
expect(result).toEqual(['log-1', 'metric-1']);
142+
});
143+
144+
it('should handle connection with only one source kind', () => {
145+
const sources = [mockLogSource, mockMetricSource2];
146+
const result = resolveSourceIds(null, null, sources);
147+
expect(result).toEqual([undefined, undefined]);
148+
});
149+
150+
it('should return [null, null] if sources array is undefined', () => {
151+
const result = resolveSourceIds(null, null, undefined);
152+
expect(result).toEqual([null, null]);
153+
});
154+
155+
it('should return [undefined, undefined] if sources array is empty', () => {
156+
const result = resolveSourceIds(null, null, []);
157+
expect(result).toEqual([undefined, undefined]);
158+
});
159+
160+
it('should return [undefined, undefined] if no connection has both kinds', () => {
161+
const sources = [mockLogSource];
162+
const result = resolveSourceIds(null, null, sources);
163+
expect(result).toEqual([undefined, undefined]);
164+
});
165+
});
166+
167+
describe('edge cases', () => {
168+
it('should handle multiple sources on the same connection', () => {
169+
const logSource3: TSource = {
170+
id: 'log-3',
171+
name: 'Log Source 3',
172+
kind: SourceKind.Log,
173+
connection: 'connection-1',
174+
} as TSource;
175+
const metricSource3: TSource = {
176+
id: 'metric-3',
177+
name: 'Metric Source 3',
178+
kind: SourceKind.Metric,
179+
connection: 'connection-1',
180+
} as TSource;
181+
const sources = [
182+
mockLogSource,
183+
logSource3,
184+
mockMetricSource,
185+
metricSource3,
186+
];
187+
188+
// When log source is specified, should find first metric on same connection
189+
const result1 = resolveSourceIds('log-1', null, sources);
190+
expect(result1).toEqual(['log-1', 'metric-1']);
191+
192+
// When metric source is specified, should find first log on same connection
193+
const result2 = resolveSourceIds(null, 'metric-3', sources);
194+
expect(result2).toEqual(['log-1', 'metric-3']);
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)