feat: introduce k8s preset dashboard - Pt1 (#715)

Features including
Ref: HDX-1541

### Pods Overview
<img width="1495" alt="Screenshot 2025-03-26 at 10 58 03 PM" src="https://github.com/user-attachments/assets/ab8a824f-a731-4ea9-9542-6c1906dd284d" />

### Nodes Overview
<img width="1488" alt="Screenshot 2025-03-26 at 10 58 08 PM" src="https://github.com/user-attachments/assets/a1b3a4a3-f14b-4ad6-b872-2bb108671f5b" />

### Namespace Overview
<img width="1500" alt="Screenshot 2025-03-26 at 10 58 13 PM" src="https://github.com/user-attachments/assets/6b26062b-9dd0-499c-bb8c-5b5cfa2efbff" />

### Side Panels
<img width="1417" alt="Screenshot 2025-03-26 at 11 01 23 PM" src="https://github.com/user-attachments/assets/df01a6ea-fed1-47ca-8f10-f2a14c029953" />


Coming up....

- Top level filtering
- Logs table side panel
- Pull cumulative sum metrics for `Age` column
- Revisit performance regression in Logs table
This commit is contained in:
Warren 2025-03-27 14:44:00 -07:00 committed by GitHub
parent b99236d774
commit a6fd5e3535
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2636 additions and 77 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: introduce k8s preset dashboard

View file

@ -0,0 +1,3 @@
import KubernetesDashboardPage from '@/KubernetesDashboardPage';
export default KubernetesDashboardPage;

View file

@ -41,7 +41,7 @@ import {
AppNavLink,
AppNavUserMenu,
} from './AppNav.components';
import { IS_LOCAL_MODE } from './config';
import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from './config';
import Icon from './Icon';
import Logo from './Logo';
import { useSavedSearches, useUpdateSavedSearch } from './savedSearch';
@ -791,6 +791,18 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
>
Services
</Link>
{IS_K8S_DASHBOARD_ENABLED && (
<Link
href={`/kubernetes`}
tabIndex={0}
className={cx(styles.listLink, {
[styles.listLinkActive]:
pathname.startsWith('/kubernetes'),
})}
>
Kubernetes
</Link>
)}
</Collapse>
</div>
</Collapse>

View file

@ -958,28 +958,49 @@ export const mapV1AggFnToV2 = (aggFn: AggFn): AggFnV2 => {
return 'count';
}
if (aggFn === 'last_value') {
throw new Error('last_value is not supported in v2');
}
// For standard aggregations that exist in both, return as is
if (['avg', 'count', 'count_distinct', 'max', 'min', 'sum'].includes(aggFn)) {
if (
[
'avg',
'count',
'count_distinct',
'last_value',
'max',
'min',
'sum',
].includes(aggFn)
) {
return aggFn as AggFnV2;
}
throw new Error(`Unsupported aggregation function in v2: ${aggFn}`);
};
export const convertV1GroupByToV2 = (
metricSource: TSource,
groupBy: string[],
): string => {
return groupBy
.map(g => {
if (g.startsWith('k8s')) {
return `${metricSource.resourceAttributesExpression}['${g}']`;
}
return g;
})
.join(',');
};
export const convertV1ChartConfigToV2 = (
chartConfig: {
// only support time or table series
series: (TimeChartSeries | TableChartSeries)[];
granularity: Granularity;
granularity?: Granularity;
dateRange: [Date, Date];
seriesReturnType: 'ratio' | 'column';
displayType?: 'stacked_bar' | 'line';
name?: string;
fillNulls?: number | false;
sortOrder?: SortOrder;
},
source: {
log?: TSource;
@ -995,8 +1016,8 @@ export const convertV1ChartConfigToV2 = (
fillNulls,
} = chartConfig;
if (series.length !== 1) {
throw new Error('only one series is supported in v2');
if (series.length < 1) {
throw new Error('series is required');
}
const firstSeries = series[0];
@ -1010,25 +1031,27 @@ export const convertV1ChartConfigToV2 = (
if (source.metric == null) {
throw new Error('source.metric is required for metrics');
}
const [metricName, rawMetricDataType] = (firstSeries.field ?? '').split(
' - ',
);
const metricDataType = z
.nativeEnum(MetricsDataTypeV2)
.parse(rawMetricDataType?.toLowerCase());
return {
select: [
{
aggFn: mapV1AggFnToV2(firstSeries.aggFn),
select: series.map(s => {
const field = s.field ?? '';
const [metricName, rawMetricDataType] = field
.split(' - ')
.map(s => s.trim());
const metricDataType = z
.nativeEnum(MetricsDataTypeV2)
.parse(rawMetricDataType?.toLowerCase());
return {
aggFn: mapV1AggFnToV2(s.aggFn),
metricType: metricDataType,
valueExpression: 'Value',
valueExpression: field,
metricName,
aggConditionLanguage: 'lucene',
aggCondition: firstSeries.where,
},
],
aggCondition: s.where,
};
}),
from: source.metric?.from,
numberFormat: firstSeries.numberFormat,
groupBy: convertV1GroupByToV2(source.metric, firstSeries.groupBy),
dateRange,
connection: source.metric?.connection,
metricTables: source.metric?.metricTables,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
import React, { useMemo } from 'react';
import { Select } from '@mantine/core';
import api from '@/api';
import { legacyMetricNameToNameAndDataType } from '@/utils';
export default function MetricTagValueSelect({
metricName,
metricAttribute,
value,
onChange,
dropdownOpenWidth,
dropdownClosedWidth,
...selectProps
}: {
metricName: string;
metricAttribute: string;
value: string;
dropdownOpenWidth?: number;
dropdownClosedWidth?: number;
onChange: (value: string) => void;
} & Partial<React.ComponentProps<typeof Select>>) {
const { name: mName, dataType: mDataType } =
legacyMetricNameToNameAndDataType(metricName);
const { data: metricTagsData, isLoading: isMetricTagsLoading } =
api.useMetricsTags([
{
name: mName,
dataType: mDataType,
},
]);
const options = useMemo(() => {
const tags =
metricTagsData?.data?.filter(metric => metric.name === metricName)?.[0]
?.tags ?? [];
const valueSet = new Set<string>();
tags.forEach(tag => {
Object.entries(tag).forEach(([name, value]) => {
if (name === metricAttribute) {
valueSet.add(value);
}
});
});
return Array.from(valueSet);
}, [metricTagsData, metricName, metricAttribute]);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
return (
<Select
searchable
clearable
allowDeselect
maxDropdownHeight={280}
disabled={isMetricTagsLoading}
radius="md"
variant="filled"
value={value}
onChange={onChange}
w={
dropdownOpen ? (dropdownOpenWidth ?? 200) : (dropdownClosedWidth ?? 200)
}
limit={20}
data={options}
onDropdownOpen={() => setDropdownOpen(true)}
onDropdownClose={() => setDropdownOpen(false)}
{...selectProps}
/>
);
}

View file

@ -0,0 +1,381 @@
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 { TSource } from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Badge,
Box,
Card,
Flex,
Grid,
ScrollArea,
SegmentedControl,
Text,
} from '@mantine/core';
import {
convertDateRangeToGranularityString,
convertV1ChartConfigToV2,
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
K8S_MEM_NUMBER_FORMAT,
} from '@/ChartUtils';
import { DBSqlRowTable } from '@/components/DBRowTable';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { InfraPodsStatusTable } from '@/KubernetesDashboardPage';
import { getEventBody } from '@/source';
import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { useZIndex, ZIndexContext } from '@/zIndex';
import styles from '../styles/LogSidePanel.module.scss';
const CHART_HEIGHT = 300;
const defaultTimeRange = parseTimeQuery('Past 1h', false);
const PodDetailsProperty = React.memo(
({ label, value }: { label: string; value?: React.ReactNode }) => {
if (!value) return null;
return (
<div className="pe-4">
<Text size="xs" color="gray.6">
{label}
</Text>
<Text size="sm" color="gray.3">
{value}
</Text>
</div>
);
},
);
const NamespaceDetails = ({
name,
dateRange,
metricSource,
}: {
name: string;
dateRange: [Date, Date];
metricSource?: TSource;
}) => {
const where = `${metricSource?.resourceAttributesExpression}.k8s.namespace.name:"${name}"`;
const groupBy = ['k8s.namespace.name'];
const { data, isError, isLoading } = useQueriedChartConfig(
convertV1ChartConfigToV2(
{
series: [
{
table: 'metrics',
field: 'k8s.namespace.phase - Gauge',
type: 'table',
aggFn: 'last_value',
where,
groupBy,
},
],
dateRange,
seriesReturnType: 'column',
},
{
metric: metricSource,
},
),
);
const properties = React.useMemo(() => {
if (!data) {
return {};
}
return {
ready: data.data?.[0]?.['last_value(k8s.namespace.phase)'],
};
}, [data]);
return (
<Grid.Col span={12}>
<div className="p-2 gap-2 d-flex flex-wrap">
<PodDetailsProperty label="Namespace" value={name} />
{properties.ready !== undefined && (
<PodDetailsProperty
label="Status"
value={
properties.ready === 1 ? (
<Badge
variant="light"
color="green"
fw="normal"
tt="none"
size="md"
>
Ready
</Badge>
) : (
<Badge
variant="light"
color="red"
fw="normal"
tt="none"
size="md"
>
Not Ready
</Badge>
)
}
/>
)}
</div>
</Grid.Col>
);
};
function NamespaceLogs({
dateRange,
logSource,
where,
}: {
dateRange: [Date, Date];
logSource: TSource;
where: string;
}) {
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
const _where = where + (resultType === 'error' ? ' Severity:err' : '');
return (
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
<Flex justify="space-between" align="center">
Latest Namespace Logs & Spans
<Flex gap="xs" align="center">
<SegmentedControl
size="xs"
value={resultType}
onChange={(value: string) => {
if (value === 'all' || value === 'error') {
setResultType(value);
}
}}
data={[
{ label: 'All', value: 'all' },
{ label: 'Errors', value: 'error' },
]}
/>
{/*
<Link
href={`/search?q=${encodeURIComponent(_where)}`}
passHref
legacyBehavior
>
<Anchor size="xs" color="dimmed">
Search <i className="bi bi-box-arrow-up-right"></i>
</Anchor>
</Link>
*/}
</Flex>
</Flex>
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBSqlRowTable
config={{
...logSource,
where: _where,
whereLanguage: 'lucene',
select: [
{
valueExpression: logSource.timestampValueExpression,
alias: 'Timestamp',
},
{
valueExpression: `${logSource.severityTextExpression}`,
alias: 'Severity',
},
{
valueExpression: `${logSource.serviceNameExpression}`,
alias: 'Service',
},
{
valueExpression: `${getEventBody(logSource)}`,
alias: 'Message',
},
],
orderBy: [
{
valueExpression: logSource.timestampValueExpression,
ordering: 'DESC',
},
],
limit: { limit: 200, offset: 0 },
dateRange,
}}
onRowExpandClick={() => {}}
highlightedLineId={undefined}
isLive={false}
queryKeyPrefix="k8s-dashboard-namespace-logs"
onScroll={() => {}}
/>
</Card.Section>
</Card>
);
}
export default function NamespaceDetailsSidePanel({
metricSource,
logSource,
}: {
metricSource?: TSource;
logSource?: TSource;
}) {
const [namespaceName, setNamespaceName] = useQueryParam(
'namespaceName',
withDefault(StringParam, ''),
{
updateType: 'replaceIn',
},
);
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
const where = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`;
}, [namespaceName]);
const { searchedTimeRange: dateRange } = useTimeQuery({
defaultValue: 'Past 1h',
defaultTimeRange: [
defaultTimeRange?.[0]?.getTime() ?? -1,
defaultTimeRange?.[1]?.getTime() ?? -1,
],
});
const handleClose = React.useCallback(() => {
setNamespaceName(undefined);
}, [setNamespaceName]);
if (!namespaceName) {
return null;
}
return (
<Drawer
enableOverlay
overlayOpacity={0.1}
duration={0}
open={!!namespaceName}
onClose={handleClose}
direction="right"
size={'80vw'}
zIndex={drawerZIndex}
>
<ZIndexContext.Provider value={drawerZIndex}>
<div className={styles.panel}>
<DrawerHeader
header={`Details for ${namespaceName}`}
onClose={handleClose}
/>
<DrawerBody>
<Grid>
<NamespaceDetails
name={namespaceName}
dateRange={dateRange}
metricSource={metricSource}
/>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
CPU Usage by Pod
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
Memory Usage by Pod
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
numberFormat: K8S_MEM_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={12}>
{metricSource && (
<InfraPodsStatusTable
dateRange={dateRange}
metricSource={metricSource}
where={where}
/>
)}
</Grid.Col>
<Grid.Col span={12}>
{logSource && (
<NamespaceLogs
where={where}
dateRange={dateRange}
logSource={logSource}
/>
)}
</Grid.Col>
</Grid>
</DrawerBody>
</div>
</ZIndexContext.Provider>
</Drawer>
);
}

View file

@ -0,0 +1,385 @@
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 {
SearchConditionLanguage,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Badge,
Box,
Card,
Flex,
Grid,
ScrollArea,
SegmentedControl,
Text,
} from '@mantine/core';
import api from '@/api';
import {
convertDateRangeToGranularityString,
convertV1ChartConfigToV2,
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
K8S_MEM_NUMBER_FORMAT,
} from '@/ChartUtils';
import { DBSqlRowTable } from '@/components/DBRowTable';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import { InfraPodsStatusTable } from '@/KubernetesDashboardPage';
import { getEventBody } from '@/source';
import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { formatUptime } from '@/utils';
import { useZIndex, ZIndexContext } from '@/zIndex';
import styles from '../styles/LogSidePanel.module.scss';
const CHART_HEIGHT = 300;
const defaultTimeRange = parseTimeQuery('Past 1h', false);
const PodDetailsProperty = React.memo(
({ label, value }: { label: string; value?: React.ReactNode }) => {
if (!value) return null;
return (
<div className="pe-4">
<Text size="xs" color="gray.6">
{label}
</Text>
<Text size="sm" color="gray.3">
{value}
</Text>
</div>
);
},
);
const NodeDetails = ({
name,
dateRange,
}: {
name: string;
dateRange: [Date, Date];
}) => {
const where = `k8s.node.name:"${name}"`;
const groupBy = ['k8s.node.name'];
const { data } = api.useMultiSeriesChart({
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: 'last_value',
where,
groupBy,
},
],
endDate: dateRange[1] ?? new Date(),
startDate: dateRange[0] ?? new Date(),
seriesReturnType: 'column',
});
const properties = React.useMemo(() => {
const series: Record<string, any> = data?.data?.[0] || {};
return {
ready: series['series_0.data'],
uptime: series['series_1.data'],
};
}, [data?.data]);
return (
<Grid.Col span={12}>
<div className="p-2 gap-2 d-flex flex-wrap">
<PodDetailsProperty label="Node" value={name} />
{properties.ready !== undefined && (
<PodDetailsProperty
label="Status"
value={
properties.ready === 1 ? (
<Badge
variant="light"
color="green"
fw="normal"
tt="none"
size="md"
>
Ready
</Badge>
) : (
<Badge
variant="light"
color="red"
fw="normal"
tt="none"
size="md"
>
Not Ready
</Badge>
)
}
/>
)}
{properties.uptime && (
<PodDetailsProperty
label="Uptime"
value={formatUptime(properties.uptime)}
/>
)}
</div>
</Grid.Col>
);
};
function NodeLogs({
dateRange,
logSource,
where,
}: {
dateRange: [Date, Date];
logSource: TSource;
where: string;
}) {
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
const _where = where + (resultType === 'error' ? ' Severity:err' : '');
return (
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
<Flex justify="space-between" align="center">
Latest Node Logs & Spans
<Flex gap="xs" align="center">
<SegmentedControl
size="xs"
value={resultType}
onChange={(value: string) => {
if (value === 'all' || value === 'error') {
setResultType(value);
}
}}
data={[
{ label: 'All', value: 'all' },
{ label: 'Errors', value: 'error' },
]}
/>
{/*
<Link
href={`/search?q=${encodeURIComponent(_where)}`}
passHref
legacyBehavior
>
<Anchor size="xs" color="dimmed">
Search <i className="bi bi-box-arrow-up-right"></i>
</Anchor>
</Link>
*/}
</Flex>
</Flex>
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBSqlRowTable
config={{
...logSource,
where: _where,
whereLanguage: 'lucene',
select: [
{
valueExpression: logSource.timestampValueExpression,
alias: 'Timestamp',
},
{
valueExpression: `${logSource.severityTextExpression}`,
alias: 'Severity',
},
{
valueExpression: `${logSource.serviceNameExpression}`,
alias: 'Service',
},
{
valueExpression: `${getEventBody(logSource)}`,
alias: 'Message',
},
],
orderBy: [
{
valueExpression: logSource.timestampValueExpression,
ordering: 'DESC',
},
],
limit: { limit: 200, offset: 0 },
dateRange,
}}
onRowExpandClick={() => {}}
highlightedLineId={undefined}
isLive={false}
queryKeyPrefix="k8s-dashboard-node-logs"
onScroll={() => {}}
/>
</Card.Section>
</Card>
);
}
export default function NodeDetailsSidePanel({
metricSource,
logSource,
}: {
metricSource?: TSource;
logSource?: TSource;
}) {
const [nodeName, setNodeName] = useQueryParam(
'nodeName',
withDefault(StringParam, ''),
{
updateType: 'replaceIn',
},
);
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
const where = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`;
}, [nodeName, metricSource]);
const { searchedTimeRange: dateRange } = useTimeQuery({
defaultValue: 'Past 1h',
defaultTimeRange: [
defaultTimeRange?.[0]?.getTime() ?? -1,
defaultTimeRange?.[1]?.getTime() ?? -1,
],
});
const handleClose = React.useCallback(() => {
setNodeName(undefined);
}, [setNodeName]);
if (!nodeName) {
return null;
}
return (
<Drawer
enableOverlay
overlayOpacity={0.1}
duration={0}
open={!!nodeName}
onClose={handleClose}
direction="right"
size={'80vw'}
zIndex={drawerZIndex}
>
<ZIndexContext.Provider value={drawerZIndex}>
<div className={styles.panel}>
<DrawerHeader
header={`Details for ${nodeName}`}
onClose={handleClose}
/>
<DrawerBody>
<Grid>
<NodeDetails name={nodeName} dateRange={dateRange} />
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
CPU Usage by Pod
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
Memory Usage by Pod
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
numberFormat: K8S_MEM_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={12}>
{metricSource && (
<InfraPodsStatusTable
metricSource={metricSource}
dateRange={dateRange}
where={where}
/>
)}
</Grid.Col>
<Grid.Col span={12}>
{logSource && (
<NodeLogs
where={where}
dateRange={dateRange}
logSource={logSource}
/>
)}
</Grid.Col>
</Grid>
</DrawerBody>
</div>
</ZIndexContext.Provider>
</Drawer>
);
}

View file

@ -0,0 +1,397 @@
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 { TSource } from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Box,
Card,
Flex,
Grid,
ScrollArea,
SegmentedControl,
Text,
} from '@mantine/core';
import {
convertDateRangeToGranularityString,
convertV1ChartConfigToV2,
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
K8S_MEM_NUMBER_FORMAT,
} from '@/ChartUtils';
import { DBSqlRowTable } from '@/components/DBRowTable';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import { KubeTimeline, useV2LogBatch } from '@/components/KubeComponents';
import { parseTimeQuery, useTimeQuery } from '@/timeQuery';
import { useZIndex, ZIndexContext } from '@/zIndex';
import { getEventBody } from './source';
import styles from '../styles/LogSidePanel.module.scss';
const CHART_HEIGHT = 300;
const defaultTimeRange = parseTimeQuery('Past 1h', false);
const PodDetailsProperty = React.memo(
({ label, value }: { label: string; value?: string }) => {
if (!value) return null;
return (
<div className="pe-4">
<Text size="xs" color="gray.6">
{label}
</Text>
<Text size="sm" color="gray.3">
{value}
</Text>
</div>
);
},
);
const PodDetails = ({
podName,
dateRange,
logSource,
}: {
podName: string;
dateRange: [Date, Date];
logSource: TSource;
}) => {
const { data } = useV2LogBatch<{
'k8s.node.name': string;
'k8s.pod.name': string;
'k8s.pod.uid': string;
'k8s.namespace.name': string;
'k8s.deployment.name': string;
}>({
where: `${logSource.resourceAttributesExpression}.k8s.pod.name:"${podName}"`,
whereLanguage: 'lucene',
limit: 1,
dateRange,
logSource,
order: 'desc',
extraSelects: [
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.node.name']`,
alias: 'k8s.node.name',
},
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.pod.name']`,
alias: 'k8s.pod.name',
},
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.pod.uid']`,
alias: 'k8s.pod.uid',
},
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.namespace.name']`,
alias: 'k8s.namespace.name',
},
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.deployment.name']`,
alias: 'k8s.deployment.name',
},
],
});
if (data?.data?.[0] == null) {
return null;
}
const properties = data.data[0] ?? {};
// If all properties are empty, don't show the panel
if (Object.values(properties).every(v => !v)) {
return null;
}
return (
<Grid.Col span={12}>
<div className="p-2 gap-2 d-flex flex-wrap">
<PodDetailsProperty label="Node" value={properties['k8s.node.name']} />
<PodDetailsProperty label="Pod" value={properties['k8s.pod.name']} />
<PodDetailsProperty label="Pod UID" value={properties['k8s.pod.uid']} />
<PodDetailsProperty
label="Namespace"
value={properties['k8s.namespace.name']}
/>
<PodDetailsProperty
label="Deployment"
value={properties['k8s.deployment.name']}
/>
</div>
</Grid.Col>
);
};
function PodLogs({
dateRange,
logSource,
where,
}: {
dateRange: [Date, Date];
logSource: TSource;
where: string;
}) {
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
const _where = where + (resultType === 'error' ? ' Severity:err' : '');
return (
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
<Flex justify="space-between" align="center">
Latest Pod Logs & Spans
<Flex gap="xs" align="center">
<SegmentedControl
size="xs"
value={resultType}
onChange={(value: string) => {
if (value === 'all' || value === 'error') {
setResultType(value);
}
}}
data={[
{ label: 'All', value: 'all' },
{ label: 'Errors', value: 'error' },
]}
/>
{/*
<Link
href={`/search?q=${encodeURIComponent(_where)}`}
passHref
legacyBehavior
>
<Anchor size="xs" color="dimmed">
Search <i className="bi bi-box-arrow-up-right"></i>
</Anchor>
</Link>
*/}
</Flex>
</Flex>
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBSqlRowTable
config={{
...logSource,
where: _where,
whereLanguage: 'lucene',
select: [
{
valueExpression: logSource.timestampValueExpression,
alias: 'Timestamp',
},
{
valueExpression: `${logSource.severityTextExpression}`,
alias: 'Severity',
},
{
valueExpression: `${logSource.serviceNameExpression}`,
alias: 'Service',
},
{
valueExpression: `${logSource.resourceAttributesExpression}['k8s.container.name']`,
alias: 'Container',
},
{
valueExpression: `${getEventBody(logSource)}`,
alias: 'Message',
},
],
orderBy: [
{
valueExpression: logSource.timestampValueExpression,
ordering: 'DESC',
},
],
limit: { limit: 200, offset: 0 },
dateRange,
}}
onRowExpandClick={() => {}}
highlightedLineId={undefined}
isLive={false}
queryKeyPrefix="k8s-dashboard-pod-logs"
onScroll={() => {}}
/>
</Card.Section>
</Card>
);
}
export default function PodDetailsSidePanel({
logSource,
metricSource,
}: {
logSource: TSource;
metricSource: TSource;
}) {
const [podName, setPodName] = useQueryParam(
'podName',
withDefault(StringParam, ''),
{
updateType: 'replaceIn',
},
);
// If we're in a nested side panel, we need to use a higher z-index
// TODO: This is a hack
const [nodeName] = useQueryParam('nodeName', StringParam);
const [namespaceName] = useQueryParam('namespaceName', StringParam);
const isNested = !!nodeName || !!namespaceName;
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10 + (isNested ? 100 : 0);
const where = React.useMemo(() => {
return `${metricSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`;
}, [podName, metricSource]);
const { searchedTimeRange: dateRange } = useTimeQuery({
defaultValue: 'Past 1h',
defaultTimeRange: [
defaultTimeRange?.[0]?.getTime() ?? -1,
defaultTimeRange?.[1]?.getTime() ?? -1,
],
});
const handleClose = React.useCallback(() => {
setPodName(undefined);
}, [setPodName]);
if (!podName) {
return null;
}
return (
<Drawer
enableOverlay
overlayOpacity={0.1}
duration={0}
open={!!podName}
onClose={handleClose}
direction="right"
size={isNested ? '70vw' : '80vw'}
zIndex={drawerZIndex}
>
<ZIndexContext.Provider value={drawerZIndex}>
<div className={styles.panel}>
<DrawerHeader
header={`Details for ${podName}`}
onClose={handleClose}
/>
<DrawerBody>
<Grid>
<PodDetails
podName={podName}
dateRange={dateRange}
logSource={logSource}
/>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
CPU Usage
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.cpu.utilization - Gauge',
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
showDisplaySwitcher={false}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={6}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
Memory Usage
</Card.Section>
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
<DBTimeChart
config={convertV1ChartConfigToV2(
{
dateRange,
granularity: convertDateRangeToGranularityString(
dateRange,
60,
),
seriesReturnType: 'column',
series: [
{
type: 'time',
groupBy: ['k8s.pod.name'],
where,
table: 'metrics',
aggFn: 'avg',
field: 'k8s.pod.memory.usage - Gauge',
numberFormat: K8S_MEM_NUMBER_FORMAT,
},
],
},
{
metric: metricSource,
},
)}
showDisplaySwitcher={false}
/>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={12}>
<Card p="md">
<Card.Section p="md" py="xs" withBorder>
Latest Pod Events
</Card.Section>
<Card.Section>
<ScrollArea
viewportProps={{
style: { maxHeight: CHART_HEIGHT },
}}
>
<Box p="md" py="sm">
<KubeTimeline
logSource={logSource}
q={`\`k8s.pod.name\`:"${podName}"`}
dateRange={dateRange}
/>
</Box>
</ScrollArea>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={12}>
<PodLogs
logSource={logSource}
where={where}
dateRange={dateRange}
/>
</Grid.Col>
</Grid>
</DrawerBody>
</div>
</ZIndexContext.Provider>
</Drawer>
);
}

View file

@ -1,49 +0,0 @@
import { generateCsvData } from '../HDXMultiSeriesTableChart';
describe('HDXMultiSeriesTableChart CSV functionality', () => {
test('generateCsvData should correctly format data for CSV export', () => {
const mockData = [
{ group: 'Group A', value1: 100, value2: 200 },
{ group: 'Group B', value1: 300, value2: 400 },
];
const mockColumns = [
{ dataKey: 'value1', displayName: 'Value 1' },
{ dataKey: 'value2', displayName: 'Value 2' },
];
const groupColumnName = 'Group Name';
const expected = [
{ 'Group Name': 'Group A', 'Value 1': 100, 'Value 2': 200 },
{ 'Group Name': 'Group B', 'Value 1': 300, 'Value 2': 400 },
];
const result = generateCsvData(mockData, mockColumns, groupColumnName);
expect(result).toEqual(expected);
});
test('generateCsvData should handle missing groupColumnName', () => {
// Test data without group column name
const mockData = [
{ group: 'Group A', value1: 100, value2: 200 },
{ group: 'Group B', value1: 300, value2: 400 },
];
const mockColumns = [
{ dataKey: 'value1', displayName: 'Value 1' },
{ dataKey: 'value2', displayName: 'Value 2' },
];
// Expected output without group column
const expected = [
{ 'Value 1': 100, 'Value 2': 200 },
{ 'Value 1': 300, 'Value 2': 400 },
];
const result = generateCsvData(mockData, mockColumns);
expect(result).toEqual(expected);
});
});

View file

@ -246,7 +246,7 @@ export default ({
<Box p="md" py="sm">
<KubeTimeline
logSource={source}
q={`${getEventBody(source)}.object.regarding.uid:"${podUid}"`}
q={`\`k8s.pod.uid\`:"${podUid}"`}
dateRange={[
sub(new Date(timestamp), { days: 1 }),
add(new Date(timestamp), { days: 1 }),

View file

@ -36,7 +36,7 @@ type AnchorEvent = {
label: React.ReactNode;
};
const useV2LogBatch = (
export const useV2LogBatch = <T = any,>(
{
dateRange,
extraSelects,
@ -57,7 +57,7 @@ const useV2LogBatch = (
options?: Omit<UseQueryOptions<any>, 'queryKey' | 'queryFn'>,
) => {
const clickhouseClient = getClickhouseClient();
return useQuery<ResponseJSON<KubeEvent>, Error>({
return useQuery<ResponseJSON<T>, Error>({
queryKey: [
'v2LogBatch',
logSource.id,
@ -108,7 +108,7 @@ const useV2LogBatch = (
})
.then(res => res.json());
return json as ResponseJSON<KubeEvent>;
return json as ResponseJSON<T>;
},
staleTime: 1000 * 60 * 5, // Cache every 5 min
...options,
@ -175,7 +175,7 @@ export const KubeTimeline = ({
[dateRange],
);
const { data, isLoading } = useV2LogBatch({
const { data, isLoading } = useV2LogBatch<KubeEvent>({
dateRange: [startDate, endDate],
limit: 50,
logSource,
@ -211,6 +211,10 @@ export const KubeTimeline = ({
valueExpression: `JSONExtractString(${getEventBody(logSource)}, 'object', 'regarding', 'name')`,
alias: 'k8s.pod.name',
},
{
valueExpression: `JSONExtractString(${getEventBody(logSource)}, 'object', 'regarding', 'uid')`,
alias: 'k8s.pod.uid',
},
{
valueExpression: `JSONExtractString(${getEventBody(logSource)}, 'type')`,
alias: 'type',

View file

@ -26,6 +26,7 @@ export const IS_LOCAL_MODE = //true;
(process.env.NEXT_PUBLIC_IS_LOCAL_MODE ?? 'false') === 'true';
// Features in development
export const IS_K8S_DASHBOARD_ENABLED = false || IS_DEV;
export const IS_METRICS_ENABLED = true;
export const IS_MTVIEWS_ENABLED = false;
export const IS_SESSIONS_ENABLED = true;

View file

@ -1,9 +1,11 @@
import { useEffect } from 'react';
import objectHash from 'object-hash';
import { ResponseJSON } from '@clickhouse/client-web';
import {
ChSql,
chSqlToAliasMap,
ClickHouseQueryError,
inferNumericColumn,
inferTimestampColumn,
parameterizedQueryToSql,
} from '@hyperdx/common-utils/dist/clickhouse';
@ -82,6 +84,8 @@ export function useQueriedChartConfig(
splitChartConfigs(config).map(c => renderChartConfig(c, getMetadata())),
);
const isTimeSeries = config.displayType === 'line';
const resultSets = await Promise.all(
queries.map(async query => {
const resp = await clickhouseClient.query<'JSON'>({
@ -114,11 +118,22 @@ export function useQueriedChartConfig(
}
const timestampColumn = inferTimestampColumn(resultSet.meta ?? []);
const numericColumn = inferNumericColumn(resultSet.meta ?? []);
const numericColumnName = numericColumn?.[0]?.name;
for (const row of resultSet.data) {
const _rowWithoutValue = numericColumnName
? Object.fromEntries(
Object.entries(row).filter(
([key]) => key !== numericColumnName,
),
)
: { ...row };
const ts =
timestampColumn != null
? row[timestampColumn.name]
: '__FIXED_TIMESTAMP__';
: isTimeSeries
? objectHash(_rowWithoutValue)
: '__FIXED_TIMESTAMP__';
if (tsBucketMap.has(ts)) {
const existingRow = tsBucketMap.get(ts);
tsBucketMap.set(ts, {

View file

@ -499,6 +499,10 @@ export function inferTimestampColumn(
return filterColumnMetaByType(meta, [JSDataType.Date])?.[0];
}
export function inferNumericColumn(meta: Array<ColumnMetaType>) {
return filterColumnMetaByType(meta, [JSDataType.Number]);
}
export type ColumnMeta = {
codec_expression: string;
comment: string;

View file

@ -43,6 +43,7 @@ export const AggregateFunctionSchema = z.enum([
'avg',
'count',
'count_distinct',
'last_value',
'max',
'min',
'quantile',