diff --git a/.changeset/bright-taxis-grow.md b/.changeset/bright-taxis-grow.md
new file mode 100644
index 00000000..1d4c6720
--- /dev/null
+++ b/.changeset/bright-taxis-grow.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+feat: Allow selection of log and metric source on K8s dashboard
diff --git a/packages/app/src/KubernetesDashboardPage.tsx b/packages/app/src/KubernetesDashboardPage.tsx
index a06cc0b5..5856e143 100644
--- a/packages/app/src/KubernetesDashboardPage.tsx
+++ b/packages/app/src/KubernetesDashboardPage.tsx
@@ -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() {
Kubernetes Dashboard
-
+ }
+ />
+
+ }
/>
diff --git a/packages/app/src/__tests__/KubernetesDashboardPage.test.ts b/packages/app/src/__tests__/KubernetesDashboardPage.test.ts
new file mode 100644
index 00000000..cd210a4e
--- /dev/null
+++ b/packages/app/src/__tests__/KubernetesDashboardPage.test.ts
@@ -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']);
+ });
+ });
+});