From 13b191c8a0f2f8096e79b453d034dfe01c8b0ad2 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Thu, 9 Oct 2025 15:15:21 -0400 Subject: [PATCH] feat: Allow selection of log and metric source on K8s dashboard (#1245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-10-07 at 2 50 34 PM --- .changeset/bright-taxis-grow.md | 5 + packages/app/src/KubernetesDashboardPage.tsx | 124 ++++++++--- .../__tests__/KubernetesDashboardPage.test.ts | 197 ++++++++++++++++++ 3 files changed, 293 insertions(+), 33 deletions(-) create mode 100644 .changeset/bright-taxis-grow.md create mode 100644 packages/app/src/__tests__/KubernetesDashboardPage.test.ts 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']); + }); + }); +});