mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add previous period comparisons to line chart (#1414)
Closes HDX-2777 # Summary This PR adds a toggle that enables showing "previous period" data on line charts, overlayed with the current period data. 1. The "previous period" is a date range of the same length as the selected "current" date range, immediately prior to the current date range. 2. This feature is only enabled for line charts, bar charts are not enabled when this option is toggled on. **This PR is organized into a number of commits which may be easier to review one at a time.** ## Followup work - Improve layout of the DBEditTimeChartForm, pending design review ## Demo https://github.com/user-attachments/assets/76b220da-810e-4280-8fb3-fa20a9919685
This commit is contained in:
parent
087ff4008b
commit
586bcce7f1
9 changed files with 1064 additions and 234 deletions
6
.changeset/afraid-bananas-vanish.md
Normal file
6
.changeset/afraid-bananas-vanish.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add previous period comparisons to line chart
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { add } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ColumnMetaType,
|
||||
filterColumnMetaByType,
|
||||
inferTimestampColumn,
|
||||
JSDataType,
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
SQLInterval,
|
||||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { SegmentedControl, Select as MSelect } from '@mantine/core';
|
||||
import { SegmentedControl } from '@mantine/core';
|
||||
|
||||
import { getMetricNameSql } from './otelSemanticConventions';
|
||||
import {
|
||||
|
|
@ -29,7 +30,7 @@ import {
|
|||
TimeChartSeries,
|
||||
} from './types';
|
||||
import { NumberFormat } from './types';
|
||||
import { logLevelColor, logLevelColorOrder } from './utils';
|
||||
import { getColorProps, logLevelColor, logLevelColorOrder } from './utils';
|
||||
|
||||
export const SORT_ORDER = [
|
||||
{ value: 'asc' as const, label: 'Ascending' },
|
||||
|
|
@ -520,56 +521,109 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
|
|||
]);
|
||||
}
|
||||
|
||||
// Input: { ts, value1, value2, groupBy1, groupBy2 },
|
||||
// Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 }
|
||||
export function formatResponseForTimeChart({
|
||||
res,
|
||||
dateRange,
|
||||
granularity,
|
||||
generateEmptyBuckets = true,
|
||||
source,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
granularity?: SQLInterval;
|
||||
res: ResponseJSON<Record<string, any>>;
|
||||
generateEmptyBuckets?: boolean;
|
||||
source?: TSource;
|
||||
}) {
|
||||
const meta = res.meta;
|
||||
const data = res.data;
|
||||
export function getPreviousPeriodOffset(dateRange: [Date, Date]): number {
|
||||
const [start, end] = dateRange;
|
||||
return end.getTime() - start.getTime();
|
||||
}
|
||||
|
||||
export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] {
|
||||
const [start, end] = currentRange;
|
||||
const offset = getPreviousPeriodOffset(currentRange);
|
||||
return [new Date(start.getTime() - offset), new Date(end.getTime() - offset)];
|
||||
}
|
||||
|
||||
export interface LineData {
|
||||
dataKey: string;
|
||||
currentPeriodKey: string;
|
||||
previousPeriodKey: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
isDashed?: boolean;
|
||||
}
|
||||
|
||||
interface LineDataWithOptionalColor extends Omit<LineData, 'color'> {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function setLineColors(
|
||||
sortedLineData: LineDataWithOptionalColor[],
|
||||
): LineData[] {
|
||||
// Ensure that the current and previous period lines are the same color
|
||||
const lineColorByCurrentPeriodKey = new Map<string, string>();
|
||||
|
||||
let colorIndex = 0;
|
||||
return sortedLineData.map(line => {
|
||||
const currentPeriodKey = line.currentPeriodKey;
|
||||
if (lineColorByCurrentPeriodKey.has(currentPeriodKey)) {
|
||||
line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey);
|
||||
} else if (!line.color) {
|
||||
line.color = getColorProps(
|
||||
colorIndex++,
|
||||
line.displayName ?? line.dataKey,
|
||||
);
|
||||
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
|
||||
} else {
|
||||
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
|
||||
}
|
||||
|
||||
return line as LineData;
|
||||
});
|
||||
}
|
||||
|
||||
function firstGroupColumnIsLogLevel(
|
||||
source: TSource | undefined,
|
||||
groupColumns: ColumnMetaType[],
|
||||
) {
|
||||
return (
|
||||
source &&
|
||||
groupColumns.length === 1 &&
|
||||
groupColumns[0].name ===
|
||||
(source.kind === SourceKind.Log
|
||||
? source.severityTextExpression
|
||||
: source.statusCodeExpression)
|
||||
);
|
||||
}
|
||||
|
||||
function addResponseToFormattedData({
|
||||
response,
|
||||
lineDataMap,
|
||||
tsBucketMap,
|
||||
source,
|
||||
currentPeriodDateRange,
|
||||
isPreviousPeriod,
|
||||
}: {
|
||||
tsBucketMap: Map<number, Record<string, any>>;
|
||||
lineDataMap: { [keyName: string]: LineDataWithOptionalColor };
|
||||
response: ResponseJSON<Record<string, any>>;
|
||||
source?: TSource;
|
||||
isPreviousPeriod: boolean;
|
||||
currentPeriodDateRange: [Date, Date];
|
||||
}) {
|
||||
const { meta, data } = response;
|
||||
if (meta == null) {
|
||||
throw new Error('No meta data found in response');
|
||||
}
|
||||
|
||||
const timestampColumn = inferTimestampColumn(meta);
|
||||
const valueColumns = inferValueColumns(meta) ?? [];
|
||||
const groupColumns = inferGroupColumns(meta) ?? [];
|
||||
|
||||
if (timestampColumn == null) {
|
||||
throw new Error(
|
||||
`No timestamp column found with meta: ${JSON.stringify(meta)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Timestamp -> { tsCol, line1, line2, ...}
|
||||
const tsBucketMap: Map<number, Record<string, any>> = new Map();
|
||||
const lineDataMap: {
|
||||
[keyName: string]: {
|
||||
dataKey: string;
|
||||
displayName: string;
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
color: string | undefined;
|
||||
};
|
||||
} = {};
|
||||
|
||||
const valueColumns = inferValueColumns(meta) ?? [];
|
||||
const groupColumns = inferGroupColumns(meta) ?? [];
|
||||
const isSingleValueColumn = valueColumns.length === 1;
|
||||
const hasGroupColumns = groupColumns.length > 0;
|
||||
|
||||
for (const row of data) {
|
||||
const date = new Date(row[timestampColumn.name]);
|
||||
const ts = date.getTime() / 1000;
|
||||
|
||||
// Previous period data needs to be shifted forward to align with current period
|
||||
const offset = isPreviousPeriod
|
||||
? getPreviousPeriodOffset(currentPeriodDateRange)
|
||||
: 0;
|
||||
const ts = Math.round((date.getTime() + offset) / 1000);
|
||||
|
||||
for (const valueColumn of valueColumns) {
|
||||
let tsBucket = tsBucketMap.get(ts);
|
||||
|
|
@ -578,11 +632,13 @@ export function formatResponseForTimeChart({
|
|||
tsBucketMap.set(ts, tsBucket);
|
||||
}
|
||||
|
||||
const keyName = [
|
||||
const currentPeriodKey = [
|
||||
// Simplify the display name if there's only one series and a group by
|
||||
...(isSingleValueColumn && hasGroupColumns ? [] : [valueColumn.name]),
|
||||
...groupColumns.map(g => row[g.name]),
|
||||
].join(' · ');
|
||||
const previousPeriodKey = `${currentPeriodKey} (previous)`;
|
||||
const keyName = isPreviousPeriod ? previousPeriodKey : currentPeriodKey;
|
||||
|
||||
// UInt64 are returned as strings, we'll convert to number
|
||||
// and accept a bit of floating point error
|
||||
|
|
@ -593,36 +649,82 @@ export function formatResponseForTimeChart({
|
|||
// Mutate the existing bucket object to avoid repeated large object copies
|
||||
tsBucket[keyName] = value;
|
||||
|
||||
// Special handling for log level / trace severity colors
|
||||
let color: string | undefined = undefined;
|
||||
if (
|
||||
source &&
|
||||
groupColumns.length === 1 &&
|
||||
groupColumns[0].name ===
|
||||
(source.kind === SourceKind.Log
|
||||
? source.severityTextExpression
|
||||
: source.statusCodeExpression)
|
||||
) {
|
||||
if (firstGroupColumnIsLogLevel(source, groupColumns)) {
|
||||
color = logLevelColor(row[groupColumns[0].name]);
|
||||
}
|
||||
// TODO: Set name and color correctly
|
||||
|
||||
lineDataMap[keyName] = {
|
||||
dataKey: keyName,
|
||||
currentPeriodKey,
|
||||
previousPeriodKey,
|
||||
displayName: keyName,
|
||||
color,
|
||||
maxValue: Math.max(
|
||||
lineDataMap[keyName]?.maxValue ?? Number.NEGATIVE_INFINITY,
|
||||
value,
|
||||
),
|
||||
minValue: Math.min(
|
||||
lineDataMap[keyName]?.minValue ?? Number.POSITIVE_INFINITY,
|
||||
value,
|
||||
),
|
||||
isDashed: isPreviousPeriod,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Custom sort and truncate top N lines
|
||||
const sortedLineDataMap = Object.values(lineDataMap).sort((a, b) => {
|
||||
// Input: { ts, value1, value2, groupBy1, groupBy2 },
|
||||
// Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 }
|
||||
export function formatResponseForTimeChart({
|
||||
currentPeriodResponse,
|
||||
previousPeriodResponse,
|
||||
dateRange,
|
||||
granularity,
|
||||
generateEmptyBuckets = true,
|
||||
source,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
granularity?: SQLInterval;
|
||||
currentPeriodResponse: ResponseJSON<Record<string, any>>;
|
||||
previousPeriodResponse?: ResponseJSON<Record<string, any>>;
|
||||
generateEmptyBuckets?: boolean;
|
||||
source?: TSource;
|
||||
}) {
|
||||
const meta = currentPeriodResponse.meta;
|
||||
|
||||
if (meta == null) {
|
||||
throw new Error('No meta data found in response');
|
||||
}
|
||||
|
||||
const timestampColumn = inferTimestampColumn(meta);
|
||||
|
||||
if (timestampColumn == null) {
|
||||
throw new Error(
|
||||
`No timestamp column found with meta: ${JSON.stringify(meta)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Timestamp -> { tsCol, line1, line2, ...}
|
||||
const tsBucketMap: Map<number, Record<string, any>> = new Map();
|
||||
const lineDataMap: {
|
||||
[keyName: string]: LineDataWithOptionalColor;
|
||||
} = {};
|
||||
|
||||
addResponseToFormattedData({
|
||||
response: currentPeriodResponse,
|
||||
lineDataMap,
|
||||
tsBucketMap,
|
||||
source,
|
||||
isPreviousPeriod: false,
|
||||
currentPeriodDateRange: dateRange,
|
||||
});
|
||||
|
||||
if (previousPeriodResponse != null) {
|
||||
addResponseToFormattedData({
|
||||
response: previousPeriodResponse,
|
||||
lineDataMap,
|
||||
tsBucketMap,
|
||||
source,
|
||||
isPreviousPeriod: true,
|
||||
currentPeriodDateRange: dateRange,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedLineData = Object.values(lineDataMap).sort((a, b) => {
|
||||
return (
|
||||
logLevelColorOrder.findIndex(color => color === a.color) -
|
||||
logLevelColorOrder.findIndex(color => color === b.color)
|
||||
|
|
@ -630,7 +732,6 @@ export function formatResponseForTimeChart({
|
|||
});
|
||||
|
||||
if (generateEmptyBuckets && granularity != null) {
|
||||
// Zero fill TODO: Make this an option
|
||||
const generatedTsBuckets = timeBucketByGranularity(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
|
|
@ -646,13 +747,13 @@ export function formatResponseForTimeChart({
|
|||
[timestampColumn.name]: ts,
|
||||
};
|
||||
|
||||
for (const line of sortedLineDataMap) {
|
||||
for (const line of sortedLineData) {
|
||||
tsBucket[line.dataKey] = 0;
|
||||
}
|
||||
|
||||
tsBucketMap.set(ts, tsBucket);
|
||||
} else {
|
||||
for (const line of sortedLineDataMap) {
|
||||
for (const line of sortedLineData) {
|
||||
if (tsBucket[line.dataKey] == null) {
|
||||
tsBucket[line.dataKey] = 0;
|
||||
}
|
||||
|
|
@ -669,14 +770,12 @@ export function formatResponseForTimeChart({
|
|||
(a, b) => a[timestampColumn.name] - b[timestampColumn.name],
|
||||
);
|
||||
|
||||
// TODO: Return line color and names
|
||||
const sortedLineDataWithColors = setLineColors(sortedLineData);
|
||||
|
||||
return {
|
||||
// dateRange: [minDate, maxDate],
|
||||
graphResults,
|
||||
timestampColumn,
|
||||
groupKeys: sortedLineDataMap.map(l => l.dataKey),
|
||||
lineNames: sortedLineDataMap.map(l => l.displayName),
|
||||
lineColors: sortedLineDataMap.map(l => l.color),
|
||||
lineData: sortedLineDataWithColors,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { memo, useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { add } from 'date-fns';
|
||||
import { withErrorBoundary } from 'react-error-boundary';
|
||||
import {
|
||||
Area,
|
||||
|
|
@ -18,10 +8,7 @@ import {
|
|||
BarChart,
|
||||
BarProps,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
|
|
@ -32,10 +19,12 @@ import {
|
|||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
import { Popover } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
|
||||
|
||||
import type { NumberFormat } from '@/types';
|
||||
import { COLORS, formatNumber, getColorProps, truncateMiddle } from '@/utils';
|
||||
import { COLORS, formatNumber, truncateMiddle } from '@/utils';
|
||||
|
||||
import { LineData } from './ChartUtils';
|
||||
import { FormatTime, useFormatTime } from './useFormatTime';
|
||||
|
||||
import styles from '../styles/HDXLineChart.module.scss';
|
||||
|
|
@ -53,8 +42,50 @@ type TooltipPayload = {
|
|||
opacity?: number;
|
||||
};
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const calculatePercentChange = (current: number, previous: number) => {
|
||||
if (previous === 0) {
|
||||
return current === 0 ? 0 : undefined;
|
||||
}
|
||||
return (current - previous) / previous;
|
||||
};
|
||||
|
||||
const PercentChange = ({
|
||||
current,
|
||||
previous,
|
||||
}: {
|
||||
current: number;
|
||||
previous: number;
|
||||
}) => {
|
||||
const percentChange = calculatePercentChange(current, previous);
|
||||
if (percentChange == undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 0 }}>
|
||||
(<Icon size={12} />
|
||||
{percentFormatter.format(Math.abs(percentChange))})
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const TooltipItem = memo(
|
||||
({ p, numberFormat }: { p: TooltipPayload; numberFormat?: NumberFormat }) => {
|
||||
({
|
||||
p,
|
||||
previous,
|
||||
numberFormat,
|
||||
}: {
|
||||
p: TooltipPayload;
|
||||
previous?: TooltipPayload;
|
||||
numberFormat?: NumberFormat;
|
||||
}) => {
|
||||
return (
|
||||
<div className="d-flex gap-2 items-center justify-center">
|
||||
<div>
|
||||
|
|
@ -74,32 +105,72 @@ export const TooltipItem = memo(
|
|||
<span style={{ color: p.color }}>
|
||||
{truncateMiddle(p.name ?? p.dataKey, 50)}
|
||||
</span>
|
||||
: {numberFormat ? formatNumber(p.value, numberFormat) : p.value}
|
||||
: {numberFormat ? formatNumber(p.value, numberFormat) : p.value}{' '}
|
||||
{previous && (
|
||||
<PercentChange current={p.value} previous={previous?.value} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type HDXLineChartTooltipProps = {
|
||||
lineDataMap: { [keyName: string]: LineData };
|
||||
previousPeriodOffset?: number;
|
||||
numberFormat?: NumberFormat;
|
||||
} & Record<string, any>;
|
||||
|
||||
const HDXLineChartTooltip = withErrorBoundary(
|
||||
memo((props: any) => {
|
||||
const { active, payload, label, numberFormat } = props;
|
||||
memo((props: HDXLineChartTooltipProps) => {
|
||||
const {
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
numberFormat,
|
||||
lineDataMap,
|
||||
previousPeriodOffset,
|
||||
} = props;
|
||||
const typedPayload = payload as TooltipPayload[];
|
||||
|
||||
const payloadByKey = useMemo(
|
||||
() => new Map(typedPayload.map(p => [p.dataKey, p])),
|
||||
[typedPayload],
|
||||
);
|
||||
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className={styles.chartTooltip}>
|
||||
<div className={styles.chartTooltipHeader}>
|
||||
<FormatTime value={label * 1000} />
|
||||
{previousPeriodOffset != null && (
|
||||
<>
|
||||
{' (vs '}
|
||||
<FormatTime value={label * 1000 - previousPeriodOffset} />
|
||||
{')'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.chartTooltipContent}>
|
||||
{payload
|
||||
.sort((a: any, b: any) => b.value - a.value)
|
||||
.map((p: any) => (
|
||||
<TooltipItem
|
||||
key={p.dataKey}
|
||||
p={p}
|
||||
numberFormat={numberFormat}
|
||||
/>
|
||||
))}
|
||||
.sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value)
|
||||
.map((p: TooltipPayload) => {
|
||||
const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey;
|
||||
const isPreviousPeriod = previousKey === p.dataKey;
|
||||
const previousPayload =
|
||||
!isPreviousPeriod && previousKey
|
||||
? payloadByKey.get(previousKey)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TooltipItem
|
||||
key={p.dataKey}
|
||||
p={p}
|
||||
numberFormat={numberFormat}
|
||||
previous={previousPayload}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -180,14 +251,38 @@ function ExpandableLegendItem({ entry, expanded }: any) {
|
|||
|
||||
export const LegendRenderer = memo<{
|
||||
payload?: {
|
||||
dataKey: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}[];
|
||||
lineDataMap: { [key: string]: LineData };
|
||||
}>(props => {
|
||||
const payload = props.payload ?? [];
|
||||
const { payload = [], lineDataMap } = props;
|
||||
|
||||
const shownItems = payload.slice(0, MAX_LEGEND_ITEMS);
|
||||
const restItems = payload.slice(MAX_LEGEND_ITEMS);
|
||||
const sortedLegendItems = useMemo(() => {
|
||||
// Order items such that current and previous period lines are consecutive
|
||||
const currentPeriodKeyIndex = new Map<string, number>();
|
||||
payload.forEach((line, index) => {
|
||||
const currentPeriodKey =
|
||||
lineDataMap[line.dataKey]?.currentPeriodKey || '';
|
||||
if (!currentPeriodKeyIndex.has(currentPeriodKey)) {
|
||||
currentPeriodKeyIndex.set(currentPeriodKey, index);
|
||||
}
|
||||
});
|
||||
|
||||
return payload.sort((a, b) => {
|
||||
const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? '';
|
||||
const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? '';
|
||||
|
||||
const indexA = currentPeriodKeyIndex.get(keyA) ?? 0;
|
||||
const indexB = currentPeriodKeyIndex.get(keyB) ?? 0;
|
||||
|
||||
return indexB - indexA || a.dataKey.localeCompare(b.dataKey);
|
||||
});
|
||||
}, [payload, lineDataMap]);
|
||||
|
||||
const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS);
|
||||
const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS);
|
||||
|
||||
return (
|
||||
<div className={styles.legend}>
|
||||
|
|
@ -228,9 +323,7 @@ export const MemoChart = memo(function MemoChart({
|
|||
setIsClickActive,
|
||||
isClickActive,
|
||||
dateRange,
|
||||
groupKeys,
|
||||
lineNames,
|
||||
lineColors,
|
||||
lineData,
|
||||
referenceLines,
|
||||
logReferenceTimestamp,
|
||||
displayType = DisplayType.Line,
|
||||
|
|
@ -239,14 +332,13 @@ export const MemoChart = memo(function MemoChart({
|
|||
timestampKey = 'ts_bucket',
|
||||
onTimeRangeSelect,
|
||||
showLegend = true,
|
||||
previousPeriodOffset,
|
||||
}: {
|
||||
graphResults: any[];
|
||||
setIsClickActive: (v: any) => void;
|
||||
isClickActive: any;
|
||||
dateRange: [Date, Date] | Readonly<[Date, Date]>;
|
||||
groupKeys: string[];
|
||||
lineNames: string[];
|
||||
lineColors: Array<string | undefined>;
|
||||
lineData: LineData[];
|
||||
referenceLines?: React.ReactNode;
|
||||
displayType?: DisplayType;
|
||||
numberFormat?: NumberFormat;
|
||||
|
|
@ -255,6 +347,7 @@ export const MemoChart = memo(function MemoChart({
|
|||
timestampKey?: string;
|
||||
onTimeRangeSelect?: (start: Date, end: Date) => void;
|
||||
showLegend?: boolean;
|
||||
previousPeriodOffset?: number;
|
||||
}) {
|
||||
const _id = useId();
|
||||
const id = _id.replace(/:/g, '');
|
||||
|
|
@ -265,25 +358,13 @@ export const MemoChart = memo(function MemoChart({
|
|||
displayType === DisplayType.StackedBar ? BarChart : AreaChart; // LineChart;
|
||||
|
||||
const lines = useMemo(() => {
|
||||
const limitedGroupKeys = groupKeys.slice(0, HARD_LINES_LIMIT);
|
||||
|
||||
// Check if any group is missing from any row
|
||||
const isContinuousGroup = graphResults.reduce((acc, row) => {
|
||||
limitedGroupKeys.forEach(key => {
|
||||
acc[key] = row[key] != null ? acc[key] : false;
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
const limitedGroupKeys = lineData
|
||||
.map(ld => ld.dataKey)
|
||||
.slice(0, HARD_LINES_LIMIT);
|
||||
|
||||
return limitedGroupKeys.map((key, i) => {
|
||||
const {
|
||||
color: _color,
|
||||
opacity,
|
||||
strokeDasharray,
|
||||
strokeWidth,
|
||||
} = getColorProps(i, lineNames[i] ?? key);
|
||||
|
||||
const color = lineColors[i] ?? _color;
|
||||
const color = lineData[i]?.color;
|
||||
const strokeDasharray = lineData[i]?.isDashed ? '4 3' : '0';
|
||||
|
||||
const StackedBarWithOverlap = (props: BarProps) => {
|
||||
const { x, y, width, height, fill } = props;
|
||||
|
|
@ -304,9 +385,9 @@ export const MemoChart = memo(function MemoChart({
|
|||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={lineNames[i] ?? key}
|
||||
name={lineData[i]?.displayName ?? key}
|
||||
fill={color}
|
||||
opacity={opacity}
|
||||
opacity={1}
|
||||
stackId="1"
|
||||
isAnimationActive={false}
|
||||
shape={<StackedBarWithOverlap dataKey={key} />}
|
||||
|
|
@ -319,24 +400,17 @@ export const MemoChart = memo(function MemoChart({
|
|||
stroke={color}
|
||||
fillOpacity={1}
|
||||
{...(isHovered
|
||||
? { fill: 'none' }
|
||||
? { fill: 'none', strokeDasharray }
|
||||
: {
|
||||
fill: `url(#time-chart-lin-grad-${id}-${color.replace('#', '').toLowerCase()})`,
|
||||
strokeDasharray,
|
||||
})}
|
||||
name={lineNames[i] ?? key}
|
||||
name={lineData[i]?.displayName ?? key}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [
|
||||
groupKeys,
|
||||
graphResults,
|
||||
displayType,
|
||||
lineNames,
|
||||
lineColors,
|
||||
id,
|
||||
isHovered,
|
||||
]);
|
||||
}, [lineData, displayType, id, isHovered]);
|
||||
|
||||
const sizeRef = useRef<[number, number]>([0, 0]);
|
||||
|
||||
|
|
@ -370,6 +444,14 @@ export const MemoChart = memo(function MemoChart({
|
|||
const [highlightStart, setHighlightStart] = useState<string | undefined>();
|
||||
const [highlightEnd, setHighlightEnd] = useState<string | undefined>();
|
||||
|
||||
const lineDataMap = useMemo(() => {
|
||||
const map: { [key: string]: LineData } = {};
|
||||
lineData.forEach(ld => {
|
||||
map[ld.dataKey] = ld;
|
||||
});
|
||||
return map;
|
||||
}, [lineData]);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
|
|
@ -499,7 +581,13 @@ export const MemoChart = memo(function MemoChart({
|
|||
/>
|
||||
{lines}
|
||||
<Tooltip
|
||||
content={<HDXLineChartTooltip numberFormat={numberFormat} />}
|
||||
content={
|
||||
<HDXLineChartTooltip
|
||||
numberFormat={numberFormat}
|
||||
lineDataMap={lineDataMap}
|
||||
previousPeriodOffset={previousPeriodOffset}
|
||||
/>
|
||||
}
|
||||
wrapperStyle={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
|
|
@ -518,7 +606,7 @@ export const MemoChart = memo(function MemoChart({
|
|||
<Legend
|
||||
iconSize={10}
|
||||
verticalAlign="bottom"
|
||||
content={<LegendRenderer />}
|
||||
content={<LegendRenderer lineDataMap={lineDataMap} />}
|
||||
offset={-100}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
532
packages/app/src/__tests__/ChartUtils.test.ts
Normal file
532
packages/app/src/__tests__/ChartUtils.test.ts
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import { formatResponseForTimeChart } from '@/ChartUtils';
|
||||
|
||||
describe('ChartUtils', () => {
|
||||
describe('formatResponseForTimeChart', () => {
|
||||
it('should throw an error if there is no timestamp column', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233,
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182,
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
}),
|
||||
).toThrow(
|
||||
'No timestamp column found with meta: [{"name":"AVG(toFloat64OrDefault(toString(Duration)))","type":"Float64"}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty results for an empty response', () => {
|
||||
const res = {
|
||||
data: [],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([]);
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.lineData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should format a response with a single value column and no group by', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233,
|
||||
__hdx_time_bucket: '2025-11-26T11:12:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182,
|
||||
__hdx_time_bucket: '2025-11-26T11:13:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764155520,
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764155580,
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: '#20c997',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
isDashed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should format a response with multiple value columns and a group by', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 43828228.21181263,
|
||||
max: 563518061,
|
||||
ServiceName: 'checkout',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 6759697.6283185845,
|
||||
max: 42092944,
|
||||
ServiceName: 'shipping',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 36209980.6533264,
|
||||
max: 795111023,
|
||||
ServiceName: 'checkout',
|
||||
__hdx_time_bucket: '2025-11-26T12:24:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 6479038.598323171,
|
||||
max: 63136666,
|
||||
ServiceName: 'shipping',
|
||||
__hdx_time_bucket: '2025-11-26T12:24:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: 'ServiceName',
|
||||
type: 'LowCardinality(String)',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764159780,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 43828228.21181263,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6759697.6283185845,
|
||||
'max · checkout': 563518061,
|
||||
'max · shipping': 42092944,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764159840,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 36209980.6533264,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6479038.598323171,
|
||||
'max · checkout': 795111023,
|
||||
'max · shipping': 63136666,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: '#20c997',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
currentPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#8250dc',
|
||||
dataKey: 'max · checkout',
|
||||
currentPeriodKey: 'max · checkout',
|
||||
previousPeriodKey: 'max · checkout (previous)',
|
||||
displayName: 'max · checkout',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#cdad7a',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
currentPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#0d6efd',
|
||||
dataKey: 'max · shipping',
|
||||
currentPeriodKey: 'max · shipping',
|
||||
previousPeriodKey: 'max · shipping (previous)',
|
||||
displayName: 'max · shipping',
|
||||
isDashed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should assign colors to log levels', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'count()': '1',
|
||||
SeverityText: 'info',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'count()': '3',
|
||||
SeverityText: 'debug',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'count()': '1',
|
||||
SeverityText: 'error',
|
||||
__hdx_time_bucket: '2025-11-26T12:24:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'count()',
|
||||
type: 'UInt64',
|
||||
},
|
||||
{
|
||||
name: 'SeverityText',
|
||||
type: 'LowCardinality(String)',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const source = {
|
||||
kind: SourceKind.Log,
|
||||
severityTextExpression: 'SeverityText',
|
||||
} as TSource;
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
source,
|
||||
});
|
||||
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: '#20c997',
|
||||
dataKey: 'info',
|
||||
currentPeriodKey: 'info',
|
||||
previousPeriodKey: 'info (previous)',
|
||||
displayName: 'info',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#20c997',
|
||||
dataKey: 'debug',
|
||||
currentPeriodKey: 'debug',
|
||||
previousPeriodKey: 'debug (previous)',
|
||||
displayName: 'debug',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#F81358',
|
||||
dataKey: 'error',
|
||||
currentPeriodKey: 'error',
|
||||
previousPeriodKey: 'error (previous)',
|
||||
displayName: 'error',
|
||||
isDashed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should zero-fill missing time buckets', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 43828228.21181263,
|
||||
max: 563518061,
|
||||
ServiceName: 'checkout',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 6759697.6283185845,
|
||||
max: 42092944,
|
||||
ServiceName: 'shipping',
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 6479038.598323171,
|
||||
max: 63136666,
|
||||
ServiceName: 'shipping',
|
||||
__hdx_time_bucket: '2025-11-26T12:25:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: 'ServiceName',
|
||||
type: 'LowCardinality(String)',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(1764159780000), new Date(1764159900000)],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: true,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764159780,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 43828228.21181263,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6759697.6283185845,
|
||||
'max · checkout': 563518061,
|
||||
'max · shipping': 42092944,
|
||||
},
|
||||
// Generated bucket with zeros
|
||||
{
|
||||
__hdx_time_bucket: 1764159840,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 0,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 0,
|
||||
'max · checkout': 0,
|
||||
'max · shipping': 0,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764159900,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6479038.598323171,
|
||||
'max · shipping': 63136666,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: '#20c997',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
currentPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · checkout (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#8250dc',
|
||||
dataKey: 'max · checkout',
|
||||
currentPeriodKey: 'max · checkout',
|
||||
previousPeriodKey: 'max · checkout (previous)',
|
||||
displayName: 'max · checkout',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#cdad7a',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
currentPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) · shipping (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#0d6efd',
|
||||
dataKey: 'max · shipping',
|
||||
currentPeriodKey: 'max · shipping',
|
||||
previousPeriodKey: 'max · shipping (previous)',
|
||||
displayName: 'max · shipping',
|
||||
isDashed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should plot previous period data when provided, shifted to align with current period', () => {
|
||||
const currentPeriodResponse = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233,
|
||||
__hdx_time_bucket: '2025-11-26T11:12:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182,
|
||||
__hdx_time_bucket: '2025-11-26T11:13:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const previousPeriodResponse = {
|
||||
data: [
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 123.45,
|
||||
__hdx_time_bucket: '2025-11-26T11:10:00Z',
|
||||
},
|
||||
{
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 678.9,
|
||||
__hdx_time_bucket: '2025-11-26T11:11:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
type: 'Float64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse,
|
||||
previousPeriodResponse,
|
||||
dateRange: [
|
||||
new Date('2025-11-26T11:12:00Z'),
|
||||
new Date('2025-11-26T11:14:00Z'),
|
||||
],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764155520,
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) (previous)': 123.45,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764155580,
|
||||
'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182,
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) (previous)': 678.9,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: '#20c997',
|
||||
currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) (previous)',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
isDashed: false,
|
||||
},
|
||||
{
|
||||
color: '#20c997',
|
||||
currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))',
|
||||
previousPeriodKey:
|
||||
'AVG(toFloat64OrDefault(toString(Duration))) (previous)',
|
||||
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) (previous)',
|
||||
displayName: 'AVG(toFloat64OrDefault(toString(Duration))) (previous)',
|
||||
isDashed: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
memo,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import {
|
||||
Control,
|
||||
|
|
@ -26,11 +18,9 @@ import {
|
|||
DateRange,
|
||||
DisplayType,
|
||||
Filter,
|
||||
MetricsDataType,
|
||||
SavedChartConfig,
|
||||
SelectList,
|
||||
SourceKind,
|
||||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Accordion,
|
||||
|
|
@ -49,7 +39,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { IconPlayerPlay } from '@tabler/icons-react';
|
||||
|
||||
import { AGG_FNS } from '@/ChartUtils';
|
||||
import { AGG_FNS, getPreviousDateRange } from '@/ChartUtils';
|
||||
import { AlertChannelForm, getAlertReferenceLines } from '@/components/Alerts';
|
||||
import ChartSQLPreview from '@/components/ChartSQLPreview';
|
||||
import DBTableChart from '@/components/DBTableChart';
|
||||
|
|
@ -62,6 +52,7 @@ import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs
|
|||
import SearchInputV2 from '@/SearchInputV2';
|
||||
import { getFirstTimestampValueExpression, useSource } from '@/source';
|
||||
import { parseTimeQuery } from '@/timeQuery';
|
||||
import { FormatTime } from '@/useFormatTime';
|
||||
import { getMetricTableName, optionsToSelectData } from '@/utils';
|
||||
import {
|
||||
ALERT_CHANNEL_OPTIONS,
|
||||
|
|
@ -80,6 +71,7 @@ import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar';
|
|||
import {
|
||||
CheckBoxControlled,
|
||||
InputControlled,
|
||||
SwitchControlled,
|
||||
TextInputControlled,
|
||||
} from './InputControlled';
|
||||
import { MetricNameSelect } from './MetricNameSelect';
|
||||
|
|
@ -451,6 +443,7 @@ export default function EditTimeChartForm({
|
|||
const whereLanguage = watch('whereLanguage');
|
||||
const alert = watch('alert');
|
||||
const seriesReturnType = watch('seriesReturnType');
|
||||
const compareToPreviousPeriod = watch('compareToPreviousPeriod');
|
||||
|
||||
const { data: tableSource } = useSource({ id: sourceId });
|
||||
const databaseName = tableSource?.from.databaseName;
|
||||
|
|
@ -601,8 +594,24 @@ export default function EditTimeChartForm({
|
|||
});
|
||||
}, [dateRange]);
|
||||
|
||||
// Trigger a search when "compare to previous period" changes
|
||||
useEffect(() => {
|
||||
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
|
||||
if (config == null) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
compareToPreviousPeriod,
|
||||
};
|
||||
});
|
||||
}, [compareToPreviousPeriod]);
|
||||
|
||||
const queryReady = isQueryReady(queriedConfig);
|
||||
|
||||
const previousDateRange = getPreviousDateRange(dateRange);
|
||||
|
||||
const sampleEventsConfig = useMemo(
|
||||
() =>
|
||||
tableSource != null && queriedConfig != null && queryReady
|
||||
|
|
@ -690,7 +699,6 @@ export default function EditTimeChartForm({
|
|||
/>
|
||||
</Flex>
|
||||
<Divider my="md" />
|
||||
|
||||
{activeTab === 'markdown' ? (
|
||||
<div>
|
||||
<Textarea
|
||||
|
|
@ -883,7 +891,6 @@ export default function EditTimeChartForm({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{alert && (
|
||||
<Paper my="sm">
|
||||
<Stack gap="xs">
|
||||
|
|
@ -945,7 +952,6 @@ export default function EditTimeChartForm({
|
|||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between" mt="sm">
|
||||
<Flex gap="sm">
|
||||
{onSave != null && (
|
||||
|
|
@ -1000,6 +1006,28 @@ export default function EditTimeChartForm({
|
|||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
{activeTab === 'time' && (
|
||||
<Group justify="end" mb="xs">
|
||||
<SwitchControlled
|
||||
control={control}
|
||||
name="compareToPreviousPeriod"
|
||||
label={
|
||||
<>
|
||||
Compare to Previous Period{' '}
|
||||
{!dashboardId && (
|
||||
<>
|
||||
(
|
||||
<FormatTime value={previousDateRange?.[0]} format="short" />
|
||||
{' - '}
|
||||
<FormatTime value={previousDateRange?.[1]} format="short" />
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{!queryReady && activeTab !== 'markdown' ? (
|
||||
<Paper shadow="xs" p="xl">
|
||||
<Center mih={400}>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,29 @@ import {
|
|||
ChartConfigWithDateRange,
|
||||
DisplayType,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Button, Code, Group, Modal, Text } from '@mantine/core';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconArrowsDiagonal } from '@tabler/icons-react';
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { formatResponseForTimeChart, useTimeChartSettings } from '@/ChartUtils';
|
||||
import {
|
||||
formatResponseForTimeChart,
|
||||
getPreviousDateRange,
|
||||
getPreviousPeriodOffset,
|
||||
useTimeChartSettings,
|
||||
} from '@/ChartUtils';
|
||||
import { convertGranularityToSeconds } from '@/ChartUtils';
|
||||
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
|
|
@ -27,7 +44,6 @@ function DBTimeChartComponent({
|
|||
config,
|
||||
enabled = true,
|
||||
logReferenceTimestamp,
|
||||
onSettled,
|
||||
onTimeRangeSelect,
|
||||
queryKeyPrefix,
|
||||
referenceLines,
|
||||
|
|
@ -56,11 +72,14 @@ function DBTimeChartComponent({
|
|||
fillNulls,
|
||||
} = useTimeChartSettings(config);
|
||||
|
||||
const queriedConfig = {
|
||||
...config,
|
||||
granularity,
|
||||
limit: { limit: 100000 },
|
||||
};
|
||||
const queriedConfig = useMemo(
|
||||
() => ({
|
||||
...config,
|
||||
granularity,
|
||||
limit: { limit: 100000 },
|
||||
}),
|
||||
[config, granularity],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
|
||||
useQueriedChartConfig(queriedConfig, {
|
||||
|
|
@ -70,6 +89,27 @@ function DBTimeChartComponent({
|
|||
enableQueryChunking: true,
|
||||
});
|
||||
|
||||
const previousPeriodChartConfig: ChartConfigWithDateRange = useMemo(() => {
|
||||
return {
|
||||
...queriedConfig,
|
||||
dateRange: getPreviousDateRange(dateRange),
|
||||
};
|
||||
}, [queriedConfig, dateRange]);
|
||||
|
||||
const previousPeriodOffset = useMemo(() => {
|
||||
return config.compareToPreviousPeriod
|
||||
? getPreviousPeriodOffset(dateRange)
|
||||
: undefined;
|
||||
}, [dateRange, config.compareToPreviousPeriod]);
|
||||
|
||||
const { data: previousPeriodData, isLoading: isPreviousPeriodLoading } =
|
||||
useQueriedChartConfig(previousPeriodChartConfig, {
|
||||
placeholderData: (prev: any) => prev,
|
||||
queryKey: [queryKeyPrefix, previousPeriodChartConfig, 'chunked'],
|
||||
enabled: enabled && config.compareToPreviousPeriod,
|
||||
enableQueryChunking: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isError && isErrorExpanded) {
|
||||
errorExpansion.close();
|
||||
|
|
@ -77,36 +117,49 @@ function DBTimeChartComponent({
|
|||
}, [isError, isErrorExpanded, errorExpansion]);
|
||||
|
||||
const isLoadingOrPlaceholder =
|
||||
isLoading || !data?.isComplete || isPlaceholderData;
|
||||
isLoading ||
|
||||
isPreviousPeriodLoading ||
|
||||
!data?.isComplete ||
|
||||
(config.compareToPreviousPeriod && !previousPeriodData?.isComplete) ||
|
||||
isPlaceholderData;
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
|
||||
const { graphResults, timestampColumn, groupKeys, lineNames, lineColors } =
|
||||
useMemo(() => {
|
||||
const defaultResponse = {
|
||||
graphResults: [],
|
||||
timestampColumn: undefined,
|
||||
groupKeys: [],
|
||||
lineNames: [],
|
||||
lineColors: [],
|
||||
};
|
||||
const { graphResults, timestampColumn, lineData } = useMemo(() => {
|
||||
const defaultResponse = {
|
||||
graphResults: [],
|
||||
timestampColumn: undefined,
|
||||
lineData: [],
|
||||
};
|
||||
|
||||
if (data == null || !isSuccess) {
|
||||
return defaultResponse;
|
||||
}
|
||||
if (data == null || !isSuccess) {
|
||||
return defaultResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
return formatResponseForTimeChart({
|
||||
res: data,
|
||||
dateRange,
|
||||
granularity,
|
||||
generateEmptyBuckets: fillNulls !== false,
|
||||
source,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return defaultResponse;
|
||||
}
|
||||
}, [data, dateRange, granularity, isSuccess, fillNulls, source]);
|
||||
try {
|
||||
return formatResponseForTimeChart({
|
||||
currentPeriodResponse: data,
|
||||
previousPeriodResponse: config.compareToPreviousPeriod
|
||||
? previousPeriodData
|
||||
: undefined,
|
||||
dateRange,
|
||||
granularity,
|
||||
generateEmptyBuckets: fillNulls !== false,
|
||||
source,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return defaultResponse;
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
dateRange,
|
||||
granularity,
|
||||
isSuccess,
|
||||
fillNulls,
|
||||
source,
|
||||
config.compareToPreviousPeriod,
|
||||
previousPeriodData,
|
||||
]);
|
||||
|
||||
// To enable backward compatibility, allow non-controlled usage of displayType
|
||||
const [displayTypeLocal, setDisplayTypeLocal] = useState(displayTypeProp);
|
||||
|
|
@ -127,6 +180,12 @@ function DBTimeChartComponent({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (config.compareToPreviousPeriod) {
|
||||
setDisplayTypeLocal(DisplayType.Line);
|
||||
}
|
||||
}, [config.compareToPreviousPeriod]);
|
||||
|
||||
const [activeClickPayload, setActiveClickPayload] = useState<
|
||||
| {
|
||||
x: number;
|
||||
|
|
@ -310,7 +369,7 @@ function DBTimeChartComponent({
|
|||
) : null*/}
|
||||
{showDisplaySwitcher && (
|
||||
<div
|
||||
className="bg-muted px-3 py-2 rounded fs-8"
|
||||
className="bg-muted px-2 py-1 rounded fs-8"
|
||||
style={{
|
||||
zIndex: 5,
|
||||
position: 'absolute',
|
||||
|
|
@ -319,39 +378,48 @@ function DBTimeChartComponent({
|
|||
visibility: 'visible',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cx('text-decoration-none fs-7 cursor-pointer me-2', {
|
||||
'text-success': displayType === 'line',
|
||||
'text-muted-hover': displayType !== 'line',
|
||||
})}
|
||||
role="button"
|
||||
title="Display as line chart"
|
||||
onClick={() => handleSetDisplayType(DisplayType.Line)}
|
||||
<Tooltip label="Display as Line Chart">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
me={2}
|
||||
className={cx({
|
||||
'text-success': displayType === 'line',
|
||||
'text-muted-hover': displayType !== 'line',
|
||||
})}
|
||||
onClick={() => handleSetDisplayType(DisplayType.Line)}
|
||||
>
|
||||
<IconChartLine />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
config.compareToPreviousPeriod
|
||||
? 'Bar Chart Unavailable When Comparing to Previous Period'
|
||||
: 'Display as Bar Chart'
|
||||
}
|
||||
>
|
||||
<i className="bi bi-graph-up"></i>
|
||||
</span>
|
||||
<span
|
||||
className={cx('text-decoration-none fs-7 cursor-pointer', {
|
||||
'text-success': displayType === 'stacked_bar',
|
||||
'text-muted-hover': displayType !== 'stacked_bar',
|
||||
})}
|
||||
role="button"
|
||||
title="Display as bar chart"
|
||||
onClick={() => handleSetDisplayType(DisplayType.StackedBar)}
|
||||
>
|
||||
<i className="bi bi-bar-chart"></i>
|
||||
</span>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
className={cx({
|
||||
'text-success': displayType === 'stacked_bar',
|
||||
'text-muted-hover': displayType !== 'stacked_bar',
|
||||
})}
|
||||
disabled={config.compareToPreviousPeriod}
|
||||
onClick={() => handleSetDisplayType(DisplayType.StackedBar)}
|
||||
>
|
||||
<IconChartBar />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<MemoChart
|
||||
dateRange={dateRange}
|
||||
displayType={displayType}
|
||||
graphResults={graphResults}
|
||||
groupKeys={groupKeys}
|
||||
lineData={lineData}
|
||||
isClickActive={false}
|
||||
isLoading={isLoadingOrPlaceholder}
|
||||
lineColors={lineColors}
|
||||
lineNames={lineNames}
|
||||
logReferenceTimestamp={logReferenceTimestamp}
|
||||
numberFormat={config.numberFormat}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
|
|
@ -359,6 +427,7 @@ function DBTimeChartComponent({
|
|||
setIsClickActive={setActiveClickPayload}
|
||||
showLegend={showLegend}
|
||||
timestampKey={timestampColumn?.name}
|
||||
previousPeriodOffset={previousPeriodOffset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
InputProps,
|
||||
PasswordInput,
|
||||
PasswordInputProps,
|
||||
Switch,
|
||||
SwitchProps,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
} from '@mantine/core';
|
||||
|
|
@ -46,6 +48,17 @@ interface CheckboxControlledProps<T extends FieldValues>
|
|||
rules?: Parameters<Control<T>['register']>[1];
|
||||
}
|
||||
|
||||
interface SwitchControlledProps<T extends FieldValues>
|
||||
extends Omit<SwitchProps, 'name' | 'style'>,
|
||||
Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'name' | 'size' | 'color'
|
||||
> {
|
||||
name: Path<T>;
|
||||
control: Control<T>;
|
||||
rules?: Parameters<Control<T>['register']>[1];
|
||||
}
|
||||
|
||||
export function TextInputControlled<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
|
|
@ -122,3 +135,21 @@ export function CheckBoxControlled<T extends FieldValues>({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchControlled<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
...props
|
||||
}: SwitchControlledProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field: { value, ...field }, fieldState: { error } }) => (
|
||||
<Switch {...props} {...field} checked={value} error={error?.message} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { format as fnsFormat, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import numbro from 'numbro';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
|
|
@ -410,12 +409,6 @@ export const COLORS = [
|
|||
'#ffa600', // Yellow
|
||||
];
|
||||
|
||||
const STROKE_DASHARRAYS = ['0', '4 3', '5 5'];
|
||||
|
||||
const STROKE_WIDTHS = [1.25];
|
||||
|
||||
const STROKE_OPACITIES = [1];
|
||||
|
||||
export function hashCode(str: string) {
|
||||
let hash = 0,
|
||||
i,
|
||||
|
|
@ -473,28 +466,11 @@ const getLevelColor = (logLevel?: string) => {
|
|||
: '#20c997'; // green;
|
||||
};
|
||||
|
||||
export const getColorProps = (
|
||||
index: number,
|
||||
level: string,
|
||||
): {
|
||||
color: string;
|
||||
strokeDasharray: string;
|
||||
opacity: number;
|
||||
strokeWidth: number;
|
||||
} => {
|
||||
export const getColorProps = (index: number, level: string): string => {
|
||||
const logLevel = getLogLevelClass(level);
|
||||
const colorOverride = getLevelColor(logLevel);
|
||||
|
||||
// How many same colored lines we already have
|
||||
const colorStep = Math.floor(index / COLORS.length);
|
||||
|
||||
return {
|
||||
color: colorOverride ?? COLORS[index % COLORS.length],
|
||||
strokeDasharray:
|
||||
STROKE_DASHARRAYS[Math.min(STROKE_DASHARRAYS.length, colorStep)],
|
||||
opacity: STROKE_OPACITIES[Math.min(STROKE_OPACITIES.length, colorStep)],
|
||||
strokeWidth: STROKE_WIDTHS[Math.min(STROKE_WIDTHS.length, colorStep)],
|
||||
};
|
||||
return colorOverride ?? COLORS[index % COLORS.length];
|
||||
};
|
||||
|
||||
export const truncateMiddle = (str: string, maxLen = 10) => {
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ export const _ChartConfigSchema = z.object({
|
|||
seriesReturnType: z.enum(['ratio', 'column']).optional(),
|
||||
// Used to preserve original table select string when chart overrides it (e.g., histograms)
|
||||
eventTableSelect: z.string().optional(),
|
||||
compareToPreviousPeriod: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// This is a ChartConfig type without the `with` CTE clause included.
|
||||
|
|
|
|||
Loading…
Reference in a new issue