feat: introduce k8s preset dashboard - Pt2 (#719)

Ref: HDX-1543 + HDX-1545

- fix: k8s dashboard uptime metrics 
- fix: warning k8s event body
- feat: add side panel to warning events table
- perf: opt pods/nodes/namespaces logs table performance

<img width="1227" alt="Screenshot 2025-03-27 at 6 28 15 PM" src="https://github.com/user-attachments/assets/f222ca14-68f4-413c-9867-6f5a98aad025" />

<img width="488" alt="image" src="https://github.com/user-attachments/assets/e3076ec7-95c6-4e4b-a961-e7779d0a88ea" />
This commit is contained in:
Warren 2025-03-28 13:44:38 -07:00 committed by GitHub
parent a6fd5e3535
commit decd622fdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 334 additions and 74 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: k8s dashboard uptime metrics + warning k8s event body

View file

@ -933,7 +933,10 @@ export function formatResponseForTimeChart({
}
// Define a mapping from app AggFn to common-utils AggregateFunction
export const mapV1AggFnToV2 = (aggFn: AggFn): AggFnV2 => {
export const mapV1AggFnToV2 = (aggFn?: AggFn): AggFnV2 | undefined => {
if (aggFn == null) {
return aggFn;
}
// Map rate-based aggregations to their base aggregation
if (aggFn.endsWith('_rate')) {
return mapV1AggFnToV2(aggFn.replace('_rate', '') as AggFn);

View file

@ -38,6 +38,7 @@ import {
import { TimePicker } from '@/components/TimePicker';
import { ConnectionSelectControlled } from './components/ConnectionSelect';
import DBRowSidePanel from './components/DBRowSidePanel';
import { DBSqlRowTable } from './components/DBRowTable';
import { DBTimeChart } from './components/DBTimeChart';
import { FormatPodStatus } from './components/KubeComponents';
@ -56,7 +57,7 @@ import NamespaceDetailsSidePanel from './NamespaceDetailsSidePanel';
import NodeDetailsSidePanel from './NodeDetailsSidePanel';
import PodDetailsSidePanel from './PodDetailsSidePanel';
import HdxSearchInput from './SearchInput';
import { getEventBody, useSources } from './source';
import { getEventBody, useSource, useSources } from './source';
import { parseTimeQuery, useTimeQuery } from './timeQuery';
import { KubePhase } from './types';
import { formatNumber, formatUptime } from './utils';
@ -189,7 +190,7 @@ export const InfraPodsStatusTable = ({
table: 'metrics',
field: 'k8s.pod.uptime - Sum',
type: 'table',
aggFn: 'sum',
aggFn: undefined,
where,
groupBy,
...(sortState.column === 'uptime' && {
@ -259,7 +260,7 @@ export const InfraPodsStatusTable = ({
row["arrayElement(ResourceAttributes, 'k8s.namespace.name')"],
node: row["arrayElement(ResourceAttributes, 'k8s.node.name')"],
restarts: row['last_value(k8s.container.restarts)'],
uptime: row['sum(k8s.pod.uptime)'],
uptime: row['undefined(k8s.pod.uptime)'],
cpuAvg: row['avg(k8s.pod.cpu.utilization)'],
cpuLimitUtilization: row['avg(k8s.pod.cpu_limit_utilization)'],
memAvg: row['avg(k8s.pod.memory.usage)'],
@ -477,7 +478,7 @@ const NodesTable = ({
table: 'metrics',
field: 'k8s.node.uptime - Sum',
type: 'table',
aggFn: 'avg',
aggFn: undefined,
where,
groupBy,
},
@ -508,7 +509,7 @@ const NodesTable = ({
cpuAvg: row['avg(k8s.node.cpu.utilization)'],
memAvg: row['avg(k8s.node.memory.usage)'],
ready: row['avg(k8s.node.condition_ready)'],
uptime: row['avg(k8s.node.uptime)'],
uptime: row['undefined(k8s.node.uptime)'],
};
});
}, [data]);
@ -866,6 +867,25 @@ function KubernetesDashboardPage() {
},
[_searchQuery, setSearchQuery],
);
// Row details side panel
const [rowId, setRowId] = useQueryState('rowWhere');
const [rowSource, setRowSource] = useQueryState('rowSource');
const { data: rowSidePanelSource } = useSource({ id: rowSource || '' });
const handleSidePanelClose = React.useCallback(() => {
setRowId(null);
setRowSource(null);
}, [setRowId, setRowSource]);
const handleRowExpandClick = React.useCallback(
(rowWhere: string) => {
setRowId(rowWhere);
setRowSource(logSource?.id ?? null);
},
[logSource?.id, setRowId, setRowSource],
);
return (
<Box p="sm">
<OnboardingModal requireSource={false} />
@ -887,6 +907,13 @@ function KubernetesDashboardPage() {
logSource={logSource}
/>
)}
{rowId && rowSidePanelSource && (
<DBRowSidePanel
source={rowSidePanelSource}
rowId={rowId}
onClose={handleSidePanelClose}
/>
)}
<Group justify="space-between">
<Group>
<Text c="gray.4" size="xl">
@ -1074,7 +1101,7 @@ function KubernetesDashboardPage() {
alias: 'Name',
},
{
valueExpression: `${getEventBody(logSource)}`,
valueExpression: `JSONExtractString(${getEventBody(logSource)}, 'object', 'note')`,
alias: 'Message',
},
],
@ -1088,8 +1115,8 @@ function KubernetesDashboardPage() {
limit: { limit: 200, offset: 0 },
dateRange,
}}
onRowExpandClick={() => {}}
highlightedLineId={undefined}
onRowExpandClick={handleRowExpandClick}
highlightedLineId={rowId ?? undefined}
isLive={false}
queryKeyPrefix="k8s-dashboard-events"
onScroll={() => {}}

View file

@ -2,6 +2,7 @@ import * as React from 'react';
import Link from 'next/link';
import Drawer from 'react-modern-drawer';
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
import { tcFromSource } from '@hyperdx/common-utils/dist/metadata';
import { TSource } from '@hyperdx/common-utils/dist/types';
import {
Anchor,
@ -30,6 +31,8 @@ import { getEventBody } from '@/source';
import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { useZIndex, ZIndexContext } from '@/zIndex';
import { useGetKeyValues, useTableMetadata } from './hooks/useMetadata';
import styles from '../styles/LogSidePanel.module.scss';
const CHART_HEIGHT = 300;
@ -226,8 +229,8 @@ export default function NamespaceDetailsSidePanel({
metricSource,
logSource,
}: {
metricSource?: TSource;
logSource?: TSource;
metricSource: TSource;
logSource: TSource;
}) {
const [namespaceName, setNamespaceName] = useQueryParam(
'namespaceName',
@ -240,9 +243,9 @@ export default function NamespaceDetailsSidePanel({
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
const where = React.useMemo(() => {
const metricsWhere = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`;
}, [namespaceName]);
}, [namespaceName, metricSource]);
const { searchedTimeRange: dateRange } = useTimeQuery({
defaultValue: 'Past 1h',
@ -252,6 +255,72 @@ export default function NamespaceDetailsSidePanel({
],
});
const { data: logsTableMetadata } = useTableMetadata(tcFromSource(logSource));
let doesPrimaryOrSortingKeysContainServiceExpression = false;
if (
logSource?.serviceNameExpression &&
(logsTableMetadata?.primary_key || logsTableMetadata?.sorting_key)
) {
if (
logsTableMetadata.primary_key &&
logsTableMetadata.primary_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
} else if (
logsTableMetadata.sorting_key &&
logsTableMetadata.sorting_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
}
}
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`,
whereLanguage: 'lucene',
select: '',
timestampValueExpression: logSource.timestampValueExpression ?? '',
connection: logSource.connection,
dateRange,
},
keys: [logSource.serviceNameExpression ?? ''],
limit: 10,
disableRowLimit: false,
},
{
enabled:
!!namespaceName &&
!!logSource.serviceNameExpression &&
doesPrimaryOrSortingKeysContainServiceExpression,
},
);
// HACK: craft where clause for logs given the ServiceName is part of the primary key
const logsWhere = React.useMemo(() => {
const _where = `${logSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`;
if (
logServiceNames &&
logServiceNames[0].value.length > 0 &&
doesPrimaryOrSortingKeysContainServiceExpression
) {
const _svs: string[] = logServiceNames[0].value;
const _key = logServiceNames[0].key;
return `(${_svs
.map(sv => `${_key}:"${sv}"`)
.join(' OR ')}) AND ${_where}`;
}
return _where;
}, [
namespaceName,
logSource,
doesPrimaryOrSortingKeysContainServiceExpression,
logServiceNames,
]);
const handleClose = React.useCallback(() => {
setNamespaceName(undefined);
}, [setNamespaceName]);
@ -303,7 +372,7 @@ export default function NamespaceDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
@ -338,7 +407,7 @@ export default function NamespaceDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
@ -359,14 +428,14 @@ export default function NamespaceDetailsSidePanel({
<InfraPodsStatusTable
dateRange={dateRange}
metricSource={metricSource}
where={where}
where={metricsWhere}
/>
)}
</Grid.Col>
<Grid.Col span={12}>
{logSource && (
<NamespaceLogs
where={where}
where={logsWhere}
dateRange={dateRange}
logSource={logSource}
/>

View file

@ -2,6 +2,7 @@ import * as React from 'react';
import Link from 'next/link';
import Drawer from 'react-modern-drawer';
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
import { tcFromSource } from '@hyperdx/common-utils/dist/metadata';
import {
SearchConditionLanguage,
TSource,
@ -18,7 +19,6 @@ import {
Text,
} from '@mantine/core';
import api from '@/api';
import {
convertDateRangeToGranularityString,
convertV1ChartConfigToV2,
@ -34,6 +34,9 @@ import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { formatUptime } from '@/utils';
import { useZIndex, ZIndexContext } from '@/zIndex';
import { useQueriedChartConfig } from './hooks/useChartConfig';
import { useGetKeyValues, useTableMetadata } from './hooks/useMetadata';
import styles from '../styles/LogSidePanel.module.scss';
const CHART_HEIGHT = 300;
@ -58,44 +61,55 @@ const PodDetailsProperty = React.memo(
const NodeDetails = ({
name,
dateRange,
metricSource,
}: {
name: string;
dateRange: [Date, Date];
metricSource: TSource;
}) => {
const where = `k8s.node.name:"${name}"`;
const where = `${metricSource.resourceAttributesExpression}.k8s.node.name:"${name}"`;
const groupBy = ['k8s.node.name'];
const { data } = api.useMultiSeriesChart({
series: [
const { data, isError, isLoading } = useQueriedChartConfig(
convertV1ChartConfigToV2(
{
table: 'metrics',
field: 'k8s.node.condition_ready - Gauge',
type: 'table',
aggFn: 'last_value',
where,
groupBy,
series: [
{
table: 'metrics',
field: 'k8s.node.condition_ready - Gauge',
type: 'table',
aggFn: 'last_value',
where,
groupBy,
},
{
table: 'metrics',
field: 'k8s.node.uptime - Sum',
type: 'table',
aggFn: undefined,
where,
groupBy,
},
],
dateRange,
seriesReturnType: 'column',
},
{
table: 'metrics',
field: 'k8s.node.uptime - Sum',
type: 'table',
aggFn: 'last_value',
where,
groupBy,
metric: metricSource,
},
],
endDate: dateRange[1] ?? new Date(),
startDate: dateRange[0] ?? new Date(),
seriesReturnType: 'column',
});
),
);
const properties = React.useMemo(() => {
const series: Record<string, any> = data?.data?.[0] || {};
if (!data) {
return {};
}
return {
ready: series['series_0.data'],
uptime: series['series_1.data'],
ready: data.data?.[0]?.['last_value(k8s.node.condition_ready)'],
uptime: data.data?.[0]?.['undefined(k8s.node.uptime)'],
};
}, [data?.data]);
}, [data]);
return (
<Grid.Col span={12}>
@ -234,8 +248,8 @@ export default function NodeDetailsSidePanel({
metricSource,
logSource,
}: {
metricSource?: TSource;
logSource?: TSource;
metricSource: TSource;
logSource: TSource;
}) {
const [nodeName, setNodeName] = useQueryParam(
'nodeName',
@ -248,7 +262,7 @@ export default function NodeDetailsSidePanel({
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
const where = React.useMemo(() => {
const metricsWhere = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`;
}, [nodeName, metricSource]);
@ -260,6 +274,72 @@ export default function NodeDetailsSidePanel({
],
});
const { data: logsTableMetadata } = useTableMetadata(tcFromSource(logSource));
let doesPrimaryOrSortingKeysContainServiceExpression = false;
if (
logSource?.serviceNameExpression &&
(logsTableMetadata?.primary_key || logsTableMetadata?.sorting_key)
) {
if (
logsTableMetadata.primary_key &&
logsTableMetadata.primary_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
} else if (
logsTableMetadata.sorting_key &&
logsTableMetadata.sorting_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
}
}
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`,
whereLanguage: 'lucene',
select: '',
timestampValueExpression: logSource.timestampValueExpression ?? '',
connection: logSource.connection,
dateRange,
},
keys: [logSource.serviceNameExpression ?? ''],
limit: 10,
disableRowLimit: false,
},
{
enabled:
!!nodeName &&
!!logSource.serviceNameExpression &&
doesPrimaryOrSortingKeysContainServiceExpression,
},
);
// HACK: craft where clause for logs given the ServiceName is part of the primary key
const logsWhere = React.useMemo(() => {
const _where = `${logSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`;
if (
logServiceNames &&
logServiceNames[0].value.length > 0 &&
doesPrimaryOrSortingKeysContainServiceExpression
) {
const _svs: string[] = logServiceNames[0].value;
const _key = logServiceNames[0].key;
return `(${_svs
.map(sv => `${_key}:"${sv}"`)
.join(' OR ')}) AND ${_where}`;
}
return _where;
}, [
nodeName,
logSource,
doesPrimaryOrSortingKeysContainServiceExpression,
logServiceNames,
]);
const handleClose = React.useCallback(() => {
setNodeName(undefined);
}, [setNodeName]);
@ -287,7 +367,11 @@ export default function NodeDetailsSidePanel({
/>
<DrawerBody>
<Grid>
<NodeDetails name={nodeName} dateRange={dateRange} />
<NodeDetails
name={nodeName}
dateRange={dateRange}
metricSource={metricSource}
/>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
@ -307,7 +391,7 @@ export default function NodeDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
@ -342,7 +426,7 @@ export default function NodeDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
@ -363,14 +447,14 @@ export default function NodeDetailsSidePanel({
<InfraPodsStatusTable
metricSource={metricSource}
dateRange={dateRange}
where={where}
where={metricsWhere}
/>
)}
</Grid.Col>
<Grid.Col span={12}>
{logSource && (
<NodeLogs
where={where}
where={logsWhere}
dateRange={dateRange}
logSource={logSource}
/>

View file

@ -2,6 +2,7 @@ import * as React from 'react';
import Link from 'next/link';
import Drawer from 'react-modern-drawer';
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
import { tcFromSource } from '@hyperdx/common-utils/dist/metadata';
import { TSource } from '@hyperdx/common-utils/dist/types';
import {
Anchor,
@ -27,6 +28,7 @@ import { KubeTimeline, useV2LogBatch } from '@/components/KubeComponents';
import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { useZIndex, ZIndexContext } from '@/zIndex';
import { useGetKeyValues, useTableMetadata } from './hooks/useMetadata';
import { getEventBody } from './source';
import styles from '../styles/LogSidePanel.module.scss';
@ -51,15 +53,15 @@ const PodDetailsProperty = React.memo(
);
const PodDetails = ({
podName,
dateRange,
logSource,
podName,
}: {
podName: string;
dateRange: [Date, Date];
logSource: TSource;
podName: string;
}) => {
const { data } = useV2LogBatch<{
const { data: logsData } = useV2LogBatch<{
'k8s.node.name': string;
'k8s.pod.name': string;
'k8s.pod.uid': string;
@ -96,11 +98,11 @@ const PodDetails = ({
],
});
if (data?.data?.[0] == null) {
if (logsData?.data?.[0] == null) {
return null;
}
const properties = data.data[0] ?? {};
const properties = logsData.data[0] ?? {};
// If all properties are empty, don't show the panel
if (Object.values(properties).every(v => !v)) {
@ -243,7 +245,7 @@ export default function PodDetailsSidePanel({
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10 + (isNested ? 100 : 0);
const where = React.useMemo(() => {
const metricsWhere = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`;
}, [podName, metricSource]);
@ -255,6 +257,72 @@ export default function PodDetailsSidePanel({
],
});
const { data: logsTableMetadata } = useTableMetadata(tcFromSource(logSource));
let doesPrimaryOrSortingKeysContainServiceExpression = false;
if (
logSource?.serviceNameExpression &&
(logsTableMetadata?.primary_key || logsTableMetadata?.sorting_key)
) {
if (
logsTableMetadata.primary_key &&
logsTableMetadata.primary_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
} else if (
logsTableMetadata.sorting_key &&
logsTableMetadata.sorting_key.includes(logSource.serviceNameExpression)
) {
doesPrimaryOrSortingKeysContainServiceExpression = true;
}
}
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`,
whereLanguage: 'lucene',
select: '',
timestampValueExpression: logSource.timestampValueExpression ?? '',
connection: logSource.connection,
dateRange,
},
keys: [logSource.serviceNameExpression ?? ''],
limit: 10,
disableRowLimit: false,
},
{
enabled:
!!podName &&
!!logSource.serviceNameExpression &&
doesPrimaryOrSortingKeysContainServiceExpression,
},
);
// HACK: craft where clause for logs given the ServiceName is part of the primary key
const logsWhere = React.useMemo(() => {
const _where = `${logSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`;
if (
logServiceNames &&
logServiceNames[0].value.length > 0 &&
doesPrimaryOrSortingKeysContainServiceExpression
) {
const _svs: string[] = logServiceNames[0].value;
const _key = logServiceNames[0].key;
return `(${_svs
.map(sv => `${_key}:"${sv}"`)
.join(' OR ')}) AND ${_where}`;
}
return _where;
}, [
nodeName,
logSource,
doesPrimaryOrSortingKeysContainServiceExpression,
logServiceNames,
]);
const handleClose = React.useCallback(() => {
setPodName(undefined);
}, [setPodName]);
@ -283,9 +351,9 @@ export default function PodDetailsSidePanel({
<DrawerBody>
<Grid>
<PodDetails
podName={podName}
dateRange={dateRange}
logSource={logSource}
podName={podName}
/>
<Grid.Col span={6}>
<Card p="md">
@ -306,7 +374,7 @@ export default function PodDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
@ -342,7 +410,7 @@ export default function PodDetailsSidePanel({
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
where: metricsWhere,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
@ -384,7 +452,7 @@ export default function PodDetailsSidePanel({
<Grid.Col span={12}>
<PodLogs
logSource={logSource}
where={where}
where={logsWhere}
dateRange={dateRange}
/>
</Grid.Col>

View file

@ -95,19 +95,22 @@ export function useTableMetadata(
});
}
export function useGetKeyValues({
chartConfig,
keys,
limit,
disableRowLimit,
}: {
chartConfig: ChartConfigWithDateRange;
keys: string[];
limit?: number;
disableRowLimit?: boolean;
}) {
export function useGetKeyValues(
{
chartConfig,
keys,
limit,
disableRowLimit,
}: {
chartConfig: ChartConfigWithDateRange;
keys: string[];
limit?: number;
disableRowLimit?: boolean;
},
options?: Omit<UseQueryOptions<any, Error>, 'queryKey'>,
) {
const metadata = getMetadata();
return useQuery({
return useQuery<{ key: string; value: string[] }[]>({
queryKey: ['useMetadata.useGetKeyValues', { chartConfig, keys }],
queryFn: async () => {
return metadata.getKeyValues({
@ -120,6 +123,7 @@ export function useGetKeyValues({
staleTime: 1000 * 60 * 5, // Cache every 5 min
enabled: !!keys.length,
placeholderData: keepPreviousData,
...options,
});
}

View file

@ -158,7 +158,7 @@ export type TimeChartSeries = {
displayName?: string;
table: SourceTable;
type: 'time';
aggFn: AggFn; // TODO: Type
aggFn?: AggFn; // TODO: Type
field?: string | undefined;
where: string;
groupBy: string[];
@ -177,7 +177,7 @@ export type TableChartSeries = {
displayName?: string;
type: 'table';
table: SourceTable;
aggFn: AggFn;
aggFn?: AggFn;
field?: string | undefined;
where: string;
groupBy: string[];