mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
757196f2e9
commit
15331acbee
3 changed files with 289 additions and 77 deletions
5
.changeset/funny-games-cheer.md
Normal file
5
.changeset/funny-games-cheer.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Auto-select correlated sources on k8s dashboard
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue