feat: Auto-select correlated sources on k8s dashboard (#1302)

Closes HDX-2586

# Summary

This PR updates the K8s dashboard so that it auto-selects correlated log and metric sources.

Auto-selection of sources happens
1. During page load, if sources aren't specified in the URL params
2. When a new log source is selected, a correlated metric source is auto-selected. In this case, a notification is shown to the user to inform them that the metric source has been updated.

When a new metric source is selected, a correlated log source is not selected. This is to ensure the user has some way of selecting two non-correlated sources, if they truly want to. If the user does select a metric source which is not correlated with the selected log source, a warning notification will be shown to the user.

## Demo


https://github.com/user-attachments/assets/492121a1-0a51-4af9-a749-42771537678e
This commit is contained in:
Drew Davis 2025-10-28 08:17:53 -04:00 committed by GitHub
parent 757196f2e9
commit 15331acbee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 289 additions and 77 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Auto-select correlated sources on k8s dashboard

View file

@ -6,7 +6,6 @@ import cx from 'classnames';
import sub from 'date-fns/sub';
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 {
Badge,
@ -24,6 +23,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { TimePicker } from '@/components/TimePicker';
@ -757,57 +757,97 @@ const defaultTimeRange = parseTimeQuery('Past 1h', false);
const CHART_HEIGHT = 300;
const findSource = (
sources: TSource[] | undefined,
filters: {
kind?: SourceKind;
connection?: string;
id?: string;
},
) => {
if (!sources) return undefined;
const { kind, connection, id } = filters;
return sources.find(
s =>
(kind === undefined || s.kind === kind) &&
(id === undefined || s.id === id) &&
(connection === undefined || s.connection === connection),
);
};
export const resolveSourceIds = (
_logSourceId: string | null | undefined,
_metricSourceId: string | null | undefined,
sources: TSource[] | undefined,
) => {
if (_logSourceId && _metricSourceId) {
return [_logSourceId, _metricSourceId];
if ((_logSourceId && _metricSourceId) || !sources) {
return {
logSourceId: _logSourceId ?? undefined,
metricSourceId: _metricSourceId ?? undefined,
};
}
// 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];
// Find a default metric source that matches the existing log source
if (_logSourceId && !_metricSourceId) {
const { connection, metricSourceId: correlatedMetricSourceId } =
findSource(sources, { id: _logSourceId }) ?? {};
const metricSourceId =
(correlatedMetricSourceId &&
findSource(sources, { id: correlatedMetricSourceId })?.id) ??
(connection &&
findSource(sources, { connection, kind: SourceKind.Metric })?.id);
return { logSourceId: _logSourceId, metricSourceId };
}
// 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 default log source that matches the existing metric source
if (!_logSourceId && _metricSourceId) {
const { connection, logSourceId: correlatedLogSourceId } =
findSource(sources, { id: _metricSourceId }) ?? {};
const logSourceId =
(correlatedLogSourceId &&
findSource(sources, { id: correlatedLogSourceId })?.id) ??
(connection &&
findSource(sources, { connection, kind: SourceKind.Log })?.id);
return { logSourceId, metricSourceId: _metricSourceId };
}
// Find any two correlated log and metric sources
const logSourceWithMetricSource = sources.find(
s =>
s.kind === SourceKind.Log &&
s.metricSourceId &&
findSource(sources, { id: s.metricSourceId }),
);
if (logSourceWithMetricSource) {
return {
logSourceId: logSourceWithMetricSource.id,
metricSourceId: logSourceWithMetricSource.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];
}
const connections = Array.from(new Set(sources.map(s => s.connection)));
const connectionWithBothSourceKinds = connections.find(
connection =>
findSource(sources, { connection, kind: SourceKind.Log }) &&
findSource(sources, { connection, kind: SourceKind.Metric }),
);
const logSource = connectionWithBothSourceKinds
? findSource(sources, {
connection: connectionWithBothSourceKinds,
kind: SourceKind.Log,
})
: undefined;
const metricSource = connectionWithBothSourceKinds
? findSource(sources, {
connection: connectionWithBothSourceKinds,
kind: SourceKind.Metric,
})
: undefined;
return [_logSourceId, _metricSourceId];
return { logSourceId: logSource?.id, metricSourceId: metricSource?.id };
};
function KubernetesDashboardPage() {
@ -816,7 +856,7 @@ function KubernetesDashboardPage() {
const [_logSourceId, setLogSourceId] = useQueryState('logSource');
const [_metricSourceId, setMetricSourceId] = useQueryState('metricSource');
const [logSourceId, metricSourceId] = useMemo(
const { logSourceId, metricSourceId } = useMemo(
() => resolveSourceIds(_logSourceId, _metricSourceId, sources),
[_logSourceId, _metricSourceId, sources],
);
@ -846,22 +886,59 @@ function KubernetesDashboardPage() {
watch((data, { name, type }) => {
if (name === 'logSourceId' && type === 'change') {
setLogSourceId(data.logSourceId ?? null);
// Default to the log source's correlated metric source
if (data.logSourceId && sources) {
const logSource = findSource(sources, { id: data.logSourceId });
const correlatedMetricSource = logSource?.metricSourceId
? findSource(sources, { id: logSource.metricSourceId })
: undefined;
if (
correlatedMetricSource &&
correlatedMetricSource.id !== data.metricSourceId
) {
setMetricSourceId(correlatedMetricSource.id);
notifications.show({
id: `${correlatedMetricSource.id}-auto-correlated-metric-source`,
title: 'Updated Metrics Source',
message: `Using correlated metrics source: ${correlatedMetricSource.name}`,
});
} else if (logSource && !correlatedMetricSource) {
notifications.show({
id: `${logSource.id}-not-correlated`,
title: 'Warning',
message: `The selected logs source is not correlated with a metrics source. Source correlations can be configured in Team Settings.`,
color: 'yellow',
});
}
}
} else if (name === 'metricSourceId' && type === 'change') {
setMetricSourceId(data.metricSourceId ?? null);
const metricSource = data.metricSourceId
? findSource(sources, { id: data.metricSourceId })
: undefined;
if (
metricSource &&
data.logSourceId &&
metricSource.logSourceId !== data.logSourceId
) {
notifications.show({
id: `${metricSource.id}-not-correlated`,
title: 'Warning',
message: `The selected metrics source is not correlated with the selected logs source. Source correlations can be configured in Team Settings.`,
color: 'yellow',
});
}
}
});
const [activeTab, setActiveTab] = useQueryParam(
'tab',
withDefault(StringParam, 'pods'),
{ updateType: 'replaceIn' },
);
const [activeTab, setActiveTab] = useQueryState('tab', {
defaultValue: 'pods',
});
const [searchQuery, setSearchQuery] = useQueryParam(
'q',
withDefault(StringParam, ''),
{ updateType: 'replaceIn' },
);
const [searchQuery, setSearchQuery] = useQueryState('q', {
defaultValue: '',
});
const {
searchedTimeRange: dateRange,

View file

@ -8,7 +8,7 @@ describe('resolveSourceIds', () => {
name: 'Log Source 1',
kind: SourceKind.Log,
connection: 'connection-1',
// Add minimal required fields for TSource
metricSourceId: 'metric-1',
} as TSource;
const mockMetricSource: TSource = {
@ -16,7 +16,21 @@ describe('resolveSourceIds', () => {
name: 'Metric Source 1',
kind: SourceKind.Metric,
connection: 'connection-1',
// Add minimal required fields for TSource
logSourceId: 'log-1',
} as TSource;
const mockMetricSourceNotCorrelated: TSource = {
id: 'metric-1-not-correlated',
name: 'Metric Source Not Correlated',
kind: SourceKind.Metric,
connection: 'connection-1',
} as TSource;
const mockLogSourceNotCorrelated: TSource = {
id: 'log-1-not-correlated',
name: 'Log Source Not Correlated',
kind: SourceKind.Log,
connection: 'connection-1',
} as TSource;
const mockLogSource2: TSource = {
@ -39,12 +53,18 @@ describe('resolveSourceIds', () => {
mockLogSource,
mockMetricSource,
]);
expect(result).toEqual(['log-1', 'metric-1']);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: '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']);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
it('should return both source IDs even if they are not in the sources array', () => {
@ -52,12 +72,31 @@ describe('resolveSourceIds', () => {
mockLogSource,
mockMetricSource,
]);
expect(result).toEqual(['log-999', 'metric-999']);
expect(result).toEqual({
logSourceId: 'log-999',
metricSourceId: 'metric-999',
});
});
});
describe('when only log source ID is provided', () => {
it('should find metric source from the same connection', () => {
it('should return the correlated metric source when one is available', () => {
const sources = [
mockLogSourceNotCorrelated,
mockLogSource,
mockMetricSourceNotCorrelated,
mockMetricSource,
mockMetricSource2,
mockLogSource2,
];
const result = resolveSourceIds('log-1', null, sources);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
it('should find metric source from the same connection if there is no correlated metric source', () => {
const sources = [
mockLogSource,
mockMetricSource,
@ -65,102 +104,187 @@ describe('resolveSourceIds', () => {
mockLogSource2,
];
const result = resolveSourceIds('log-2', null, sources);
expect(result).toEqual(['log-2', 'metric-2']);
expect(result).toEqual({
logSourceId: 'log-2',
metricSourceId: '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]);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 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]);
expect(result).toEqual({
logSourceId: 'log-999',
metricSourceId: 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]);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: undefined,
});
});
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']);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
});
describe('when only metric source ID is provided', () => {
it('should find log source from the same connection', () => {
const sources = [mockLogSource, mockMetricSource];
it('should return the correlated metric source when one is available', () => {
const sources = [
mockLogSourceNotCorrelated,
mockLogSource,
mockMetricSourceNotCorrelated,
mockMetricSource,
mockMetricSource2,
mockLogSource2,
];
const result = resolveSourceIds('log-1', null, sources);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
it('should find log source from the same connection when there is no correlated log source', () => {
const sources = [
mockLogSourceNotCorrelated,
mockMetricSource,
mockLogSource2,
mockMetricSource2,
];
const result = resolveSourceIds(null, 'metric-1', sources);
expect(result).toEqual(['log-1', 'metric-1']);
expect(result).toEqual({
logSourceId: 'log-1-not-correlated',
metricSourceId: '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']);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: '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']);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: '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']);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: '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']);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
});
describe('when neither source ID is provided', () => {
it('should find log and metric sources from the same connection', () => {
it('should return two correlated sources, if available', () => {
const sources = [
mockLogSourceNotCorrelated,
mockLogSource,
mockMetricSourceNotCorrelated,
mockMetricSource,
mockMetricSource2,
mockLogSource2,
];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-1',
});
});
it('should find log and metric sources from the same connection, if there are no correlated sources', () => {
const sources = [
mockLogSourceNotCorrelated,
mockMetricSourceNotCorrelated,
mockLogSource2,
mockMetricSource2,
];
const result = resolveSourceIds(null, null, sources);
expect(result).toEqual(['log-1', 'metric-1']);
expect(result).toEqual({
logSourceId: 'log-1-not-correlated',
metricSourceId: 'metric-1-not-correlated',
});
});
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']);
expect(result).toEqual({
logSourceId: 'log-1',
metricSourceId: '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]);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: undefined,
});
});
it('should return [null, null] if sources array is undefined', () => {
const result = resolveSourceIds(null, null, undefined);
expect(result).toEqual([null, null]);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: undefined,
});
});
it('should return [undefined, undefined] if sources array is empty', () => {
const result = resolveSourceIds(null, null, []);
expect(result).toEqual([undefined, undefined]);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: 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]);
expect(result).toEqual({
logSourceId: undefined,
metricSourceId: undefined,
});
});
});
@ -187,11 +311,17 @@ describe('resolveSourceIds', () => {
// 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']);
expect(result1).toEqual({
logSourceId: 'log-1',
metricSourceId: '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']);
expect(result2).toEqual({
logSourceId: 'log-1',
metricSourceId: 'metric-3',
});
});
});
});