mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
b99236d774
commit
a6fd5e3535
16 changed files with 2636 additions and 77 deletions
6
.changeset/fifty-walls-wink.md
Normal file
6
.changeset/fifty-walls-wink.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: introduce k8s preset dashboard
|
||||
3
packages/app/pages/kubernetes.tsx
Normal file
3
packages/app/pages/kubernetes.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import KubernetesDashboardPage from '@/KubernetesDashboardPage';
|
||||
|
||||
export default KubernetesDashboardPage;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1302
packages/app/src/KubernetesDashboardPage.tsx
Normal file
1302
packages/app/src/KubernetesDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
74
packages/app/src/MetricTagValueSelect.tsx
Normal file
74
packages/app/src/MetricTagValueSelect.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
381
packages/app/src/NamespaceDetailsSidePanel.tsx
Normal file
381
packages/app/src/NamespaceDetailsSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
packages/app/src/NodeDetailsSidePanel.tsx
Normal file
385
packages/app/src/NodeDetailsSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
packages/app/src/PodDetailsSidePanel.tsx
Normal file
397
packages/app/src/PodDetailsSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const AggregateFunctionSchema = z.enum([
|
|||
'avg',
|
||||
'count',
|
||||
'count_distinct',
|
||||
'last_value',
|
||||
'max',
|
||||
'min',
|
||||
'quantile',
|
||||
|
|
|
|||
Loading…
Reference in a new issue