hyperdx/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx
Aaron Knudtson ce8506478d
fix: better source validation and refine required source fields (#1895)
## Summary

Large refactor changing the TSource type to a true discriminated union. This means that the expected fields for `kind: 'log'` will differ from those for `'trace', 'session', 'metrics'`.  This avoids the current laissez faire source type that currently exists, and required extensive changes across the api and app packages. Also includes a nice addition to `useSource` - you can now specify a `kind` field, which will properly infer the type of the returned source. 

This also makes use of discriminators in mongoose. This does change a bit of the way that we create and update sources. Obvious changes to sources have also been made, namely making `timeValueExpression` required on sources. Care has been taken to avoid requiring a migration.

### How to test locally or on Vercel

1. `yarn dev`
2. Play around with the app, especially around source creation, source edits, and loading existing sources from a previous version

### References

- Linear Issue: References HDX-3352
- Related PRs:

Ref: HDX-3352
2026-03-19 12:56:08 +00:00

217 lines
7.3 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { pick } from 'lodash';
import { parseAsString, useQueryState } from 'nuqs';
import {
DisplayType,
type Filter,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import { Drawer, Grid, Text } from '@mantine/core';
import { IconServer } from '@tabler/icons-react';
import {
ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
INTEGER_NUMBER_FORMAT,
} from '@/ChartUtils';
import { ChartBox } from '@/components/ChartBox';
import { DBTimeChart } from '@/components/DBTimeChart';
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
import ServiceDashboardEndpointPerformanceChart from '@/components/ServiceDashboardEndpointPerformanceChart';
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
import { useServiceDashboardExpressions } from '@/serviceDashboard';
import { EndpointLatencyChart } from '@/ServicesDashboardPage';
import { useSource } from '@/source';
import { useZIndex, ZIndexContext } from '@/zIndex';
import styles from '@/../styles/LogSidePanel.module.scss';
export default function ServiceDashboardEndpointSidePanel({
sourceId,
service,
searchedTimeRange,
}: {
sourceId?: string;
service?: string;
searchedTimeRange: [Date, Date];
}) {
const { data: source } = useSource({
id: sourceId,
kinds: [SourceKind.Trace],
});
const { expressions } = useServiceDashboardExpressions({ source });
const [endpoint, setEndpoint] = useQueryState('endpoint', parseAsString);
const onClose = useCallback(() => {
setEndpoint(null);
}, [setEndpoint]);
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
const endpointFilters = useMemo(() => {
if (!expressions) return [];
const filters: Filter[] = [
{
type: 'sql',
condition: `${expressions.endpoint} IN ('${endpoint}') AND ${expressions.isSpanKindServer}`,
},
];
if (service) {
filters.push({
type: 'sql',
condition: `${expressions.service} IN ('${service}')`,
});
}
return filters;
}, [endpoint, service, expressions]);
if (!endpoint || !source) {
return null;
}
return (
<Drawer
opened
onClose={onClose}
position="right"
size="80vw"
withCloseButton={false}
zIndex={drawerZIndex}
styles={{
body: {
padding: 0,
},
}}
>
<ZIndexContext.Provider value={drawerZIndex}>
<div className={styles.panel}>
<DrawerHeader
header={
<>
Details for {endpoint}
{service && (
<Text component="span" c="gray" fz="xs">
<IconServer size={14} className="ms-3 me-1" />
{service}
</Text>
)}
</>
}
onClose={onClose}
/>
<DrawerBody>
<Grid grow={false} w="100%" maw="100%" overflow="hidden">
<Grid.Col span={6}>
<ChartBox style={{ height: 350 }}>
{source && expressions && (
<DBTimeChart
title="Request Error Rate"
sourceId={source.id}
hiddenSeries={['total_count', 'error_count']}
config={{
source: source.id,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [
// Separate the aggregations from the conversion to rate so that AggregatingMergeTree MVs can be used
{
valueExpression: '',
aggFn: 'count',
alias: 'error_count',
aggCondition: expressions.isError,
aggConditionLanguage: 'sql',
},
{
valueExpression: '',
aggFn: 'count',
alias: 'total_count',
},
{
valueExpression: `error_count / total_count`,
alias: 'Error Rate %',
},
],
numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
filters: endpointFilters,
dateRange: searchedTimeRange,
}}
showDisplaySwitcher={false}
/>
)}
</ChartBox>
</Grid.Col>
<Grid.Col span={6}>
<ChartBox style={{ height: 350 }}>
{source && expressions && (
<DBTimeChart
title="Request Throughput"
sourceId={source.id}
config={{
source: source.id,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [
{
aggFn: 'count' as const,
valueExpression: 'value',
alias: 'Requests',
aggCondition: '',
aggConditionLanguage: 'sql',
},
],
displayType: DisplayType.Line,
numberFormat: {
...INTEGER_NUMBER_FORMAT,
unit: 'requests',
},
filters: endpointFilters,
dateRange: searchedTimeRange,
}}
/>
)}
</ChartBox>
</Grid.Col>
<Grid.Col span={6}>
<ServiceDashboardEndpointPerformanceChart
source={source}
dateRange={searchedTimeRange}
service={service}
endpoint={endpoint}
/>
</Grid.Col>
<Grid.Col span={6}>
<EndpointLatencyChart
source={source}
dateRange={searchedTimeRange}
extraFilters={endpointFilters}
/>
</Grid.Col>
<Grid.Col span={12}>
{/* Ensure expressions exists to ensure that endpointFilters has set */}
{expressions && (
<SlowestEventsTile
title="Slowest 5% of Transactions"
source={source}
dateRange={searchedTimeRange}
extraFilters={endpointFilters}
/>
)}
</Grid.Col>
</Grid>
</DrawerBody>
</div>
</ZIndexContext.Provider>
</Drawer>
);
}