mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
4949748f99
commit
13b191c8a0
3 changed files with 293 additions and 33 deletions
5
.changeset/bright-taxis-grow.md
Normal file
5
.changeset/bright-taxis-grow.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Allow selection of log and metric source on K8s dashboard
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
197
packages/app/src/__tests__/KubernetesDashboardPage.test.ts
Normal file
197
packages/app/src/__tests__/KubernetesDashboardPage.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue