mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Align date ranges to MV Granularity (#1575)
Closes HDX-3124 # Summary This PR makes the following changes 1. Date ranges for all MV queries are now aligned to the MV Granularity 2. Each chart type now has an indicator when the date range has been adjusted to align with either the MV Granularity or (in the case of Line/Bar charts) the Chart Granularity. 3. The useQueriedChartConfig, useRenderedSqlChartConfig, and useOffsetPaginatedQuery hooks have been updated to get the MV-optimized chart configuration from the useMVOptimizationExplanation, which allows us to share the `EXPLAIN ESTIMATE` query results between the MV Optimization Indicator (the lightning bolt icon on each chart) and the chart itself. This roughly halves the number of EXPLAIN ESTIMATE queries that are made. ## Demo <img width="1628" height="1220" alt="Screenshot 2026-01-08 at 11 42 39 AM" src="https://github.com/user-attachments/assets/80a06e3a-bbfc-4193-b6b7-5e0056c588d3" /> <img width="1627" height="1131" alt="Screenshot 2026-01-08 at 11 40 54 AM" src="https://github.com/user-attachments/assets/69879e3d-3a83-4c4d-9604-0552a01c17d7" /> ## Testing To test locally with an MV, you can use the following DDL <details> <summary>DDL For an MV</summary> ```sql CREATE TABLE default.metrics_rollup_1m ( `Timestamp` DateTime, `ServiceName` LowCardinality(String), `SpanKind` LowCardinality(String), `StatusCode` LowCardinality(String), `count` SimpleAggregateFunction(sum, UInt64), `sum__Duration` SimpleAggregateFunction(sum, UInt64), `avg__Duration` AggregateFunction(avg, UInt64), `quantile__Duration` AggregateFunction(quantileTDigest(0.5), UInt64), `min__Duration` SimpleAggregateFunction(min, UInt64), `max__Duration` SimpleAggregateFunction(max, UInt64) ) ENGINE = AggregatingMergeTree PARTITION BY toDate(Timestamp) ORDER BY (Timestamp, StatusCode, SpanKind, ServiceName) SETTINGS index_granularity = 8192; CREATE MATERIALIZED VIEW default.metrics_rollup_1m_mv TO default.metrics_rollup_1m ( `Timestamp` DateTime, `ServiceName` LowCardinality(String), `SpanKind` LowCardinality(String), `version` LowCardinality(String), `StatusCode` LowCardinality(String), `count` UInt64, `sum__Duration` Int64, `avg__Duration` AggregateFunction(avg, UInt64), `quantile__Duration` AggregateFunction(quantileTDigest(0.5), UInt64), `min__Duration` SimpleAggregateFunction(min, UInt64), `max__Duration` SimpleAggregateFunction(max, UInt64) ) AS SELECT toStartOfMinute(Timestamp) AS Timestamp, ServiceName, SpanKind, StatusCode, count() AS count, sum(Duration) AS sum__Duration, avgState(Duration) AS avg__Duration, quantileTDigestState(0.5)(Duration) AS quantile__Duration, minSimpleState(Duration) AS min__Duration, maxSimpleState(Duration) AS max__Duration FROM default.otel_traces GROUP BY Timestamp, ServiceName, SpanKind, StatusCode; ``` </details>
This commit is contained in:
parent
9f9629e4cf
commit
0c16a4b3cf
31 changed files with 1518 additions and 224 deletions
6
.changeset/sweet-pears-hang.md
Normal file
6
.changeset/sweet-pears-hang.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Align date ranges to MV Granularity
|
||||
|
|
@ -10,8 +10,10 @@
|
|||
"@changesets/cli": "^2.26.2",
|
||||
"@dotenvx/dotenvx": "^1.51.1",
|
||||
"@nx/workspace": "21.3.11",
|
||||
"@types/ungap__structured-clone": "^1.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ResponseJSON,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { isMetricChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
AggregateFunction as AggFnV2,
|
||||
ChartConfigWithDateRange,
|
||||
|
|
@ -26,6 +27,8 @@ import {
|
|||
import { SegmentedControl } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import DateRangeIndicator from './components/charts/DateRangeIndicator';
|
||||
import { MVOptimizationExplanationResult } from './hooks/useMVOptimizationExplanation';
|
||||
import { getMetricNameSql } from './otelSemanticConventions';
|
||||
import {
|
||||
AggFn,
|
||||
|
|
@ -142,23 +145,6 @@ export const isGranularity = (value: string): value is Granularity => {
|
|||
return Object.values(Granularity).includes(value as Granularity);
|
||||
};
|
||||
|
||||
export function getAlignedDateRange(
|
||||
[originalStart, originalEnd]: [Date, Date],
|
||||
granularity: SQLInterval,
|
||||
): [Date, Date] {
|
||||
// Round the start time down to the previous interval boundary
|
||||
const alignedStart = toStartOfInterval(originalStart, granularity);
|
||||
|
||||
// Round the end time up to the next interval boundary
|
||||
let alignedEnd = toStartOfInterval(originalEnd, granularity);
|
||||
if (alignedEnd.getTime() < originalEnd.getTime()) {
|
||||
const intervalSeconds = convertGranularityToSeconds(granularity);
|
||||
alignedEnd = add(alignedEnd, { seconds: intervalSeconds });
|
||||
}
|
||||
|
||||
return [alignedStart, alignedEnd];
|
||||
}
|
||||
|
||||
export function convertToTimeChartConfig(config: ChartConfigWithDateRange) {
|
||||
const granularity =
|
||||
config.granularity === 'auto' || config.granularity == null
|
||||
|
|
@ -1206,3 +1192,26 @@ export function convertToTableChartConfig(
|
|||
|
||||
return convertedConfig;
|
||||
}
|
||||
|
||||
export function buildMVDateRangeIndicator({
|
||||
mvOptimizationData,
|
||||
originalDateRange,
|
||||
}: {
|
||||
mvOptimizationData?: MVOptimizationExplanationResult;
|
||||
originalDateRange: [Date, Date];
|
||||
}) {
|
||||
const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange;
|
||||
if (!mvDateRange) return null;
|
||||
|
||||
const mvGranularity = mvOptimizationData?.explanations.find(e => e.success)
|
||||
?.mvConfig.minGranularity;
|
||||
|
||||
return (
|
||||
<DateRangeIndicator
|
||||
key="date-range-indicator"
|
||||
originalDateRange={originalDateRange}
|
||||
effectiveDateRange={mvDateRange}
|
||||
mvGranularity={mvGranularity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1847,6 +1847,7 @@ function DBSearchPage() {
|
|||
enabled={isReady}
|
||||
showDisplaySwitcher={false}
|
||||
showMVOptimizationIndicator={false}
|
||||
showDateRangeIndicator={false}
|
||||
queryKeyPrefix={QUERY_KEY_PREFIX}
|
||||
onTimeRangeSelect={handleTimeRangeSelect}
|
||||
/>
|
||||
|
|
@ -1921,6 +1922,7 @@ function DBSearchPage() {
|
|||
enabled={isReady}
|
||||
showDisplaySwitcher={false}
|
||||
showMVOptimizationIndicator={false}
|
||||
showDateRangeIndicator={false}
|
||||
queryKeyPrefix={QUERY_KEY_PREFIX}
|
||||
onTimeRangeSelect={handleTimeRangeSelect}
|
||||
enableParallelQueries
|
||||
|
|
|
|||
|
|
@ -9,15 +9,8 @@ import {
|
|||
convertToTableChartConfig,
|
||||
convertToTimeChartConfig,
|
||||
formatResponseForTimeChart,
|
||||
getAlignedDateRange,
|
||||
} from '@/ChartUtils';
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (obj: any) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChartUtils', () => {
|
||||
describe('formatResponseForTimeChart', () => {
|
||||
it('should throw an error if there is no timestamp column', () => {
|
||||
|
|
@ -654,111 +647,4 @@ describe('ChartUtils', () => {
|
|||
expect(convertedConfig.limit).toEqual({ limit: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlignedDateRange', () => {
|
||||
it('should align start time down to the previous minute boundary', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:37Z'), // 37 seconds
|
||||
new Date('2025-11-26T12:25:00Z'),
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
|
||||
});
|
||||
|
||||
it('should align end time up to the next minute boundary', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:00Z'),
|
||||
new Date('2025-11-26T12:25:42Z'), // 42 seconds
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
|
||||
});
|
||||
|
||||
it('should align both start and end times with 5 minute granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:20:00
|
||||
new Date('2025-11-26T12:27:42Z'), // Should round up to 12:30:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'5 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:20:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 30 second granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:23:00
|
||||
new Date('2025-11-26T12:25:42Z'), // Should round up to 12:26:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'30 second',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 1 day granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to start of day
|
||||
new Date('2025-11-28T08:15:00Z'), // Should round up to start of next day
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 day',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T00:00:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-29T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should not change range when already aligned to the interval', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:00Z'), // Already aligned
|
||||
new Date('2025-11-26T12:25:00Z'), // Already aligned
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 15 minute granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:15:00
|
||||
new Date('2025-11-26T12:47:42Z'), // Should round up to 13:00:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'15 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:15:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T13:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ jest.mock('@/api', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useChartConfig', () => ({
|
||||
useQueriedChartConfig: jest.fn(() => ({
|
||||
data: { data: [], isComplete: true },
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const AlertPreviewChart = ({
|
|||
sourceId={source.id}
|
||||
showDisplaySwitcher={false}
|
||||
showMVOptimizationIndicator={false}
|
||||
showDateRangeIndicator={false}
|
||||
referenceLines={getAlertReferenceLines({ threshold, thresholdType })}
|
||||
config={{
|
||||
where: where || '',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
|
|
@ -14,7 +14,9 @@ import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
|||
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Code, Text } from '@mantine/core';
|
||||
|
||||
import { buildMVDateRangeIndicator } from '@/ChartUtils';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
import { omit } from '@/utils';
|
||||
|
||||
|
|
@ -216,6 +218,9 @@ export default function DBHistogramChart({
|
|||
},
|
||||
);
|
||||
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
|
||||
// Don't ask me why...
|
||||
const buckets = data?.data?.[0]?.data;
|
||||
|
||||
|
|
@ -232,24 +237,34 @@ export default function DBHistogramChart({
|
|||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-histogram-chart-mv-indicator"
|
||||
config={config}
|
||||
config={queriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const dateRangeIndicator = buildMVDateRangeIndicator({
|
||||
mvOptimizationData,
|
||||
originalDateRange: queriedConfig.dateRange,
|
||||
});
|
||||
|
||||
if (dateRangeIndicator) {
|
||||
allToolbarItems.push(dateRangeIndicator);
|
||||
}
|
||||
|
||||
if (toolbarSuffix && toolbarSuffix.length > 0) {
|
||||
allToolbarItems.push(...toolbarSuffix);
|
||||
}
|
||||
|
||||
return allToolbarItems;
|
||||
}, [
|
||||
config,
|
||||
queriedConfig,
|
||||
toolbarPrefix,
|
||||
toolbarSuffix,
|
||||
source,
|
||||
showMVOptimizationIndicator,
|
||||
mvOptimizationData,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
|||
import type { FloatingPosition } from '@mantine/core';
|
||||
import { Box, Code, Flex, HoverCard, Text } from '@mantine/core';
|
||||
|
||||
import { buildMVDateRangeIndicator } from '@/ChartUtils';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
import type { NumberFormat } from '@/types';
|
||||
import { omit } from '@/utils';
|
||||
|
|
@ -206,6 +208,9 @@ export default function DBListBarChart({
|
|||
},
|
||||
);
|
||||
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
|
||||
const { data: source } = useSource({ id: config.source });
|
||||
|
||||
const columns = useMemo(() => {
|
||||
|
|
@ -230,19 +235,34 @@ export default function DBListBarChart({
|
|||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-list-bar-chart-mv-indicator"
|
||||
config={config}
|
||||
config={queriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const dateRangeIndicator = buildMVDateRangeIndicator({
|
||||
mvOptimizationData,
|
||||
originalDateRange: queriedConfig.dateRange,
|
||||
});
|
||||
|
||||
if (dateRangeIndicator) {
|
||||
allToolbarItems.push(dateRangeIndicator);
|
||||
}
|
||||
|
||||
if (toolbarItems && toolbarItems.length > 0) {
|
||||
allToolbarItems.push(...toolbarItems);
|
||||
}
|
||||
|
||||
return allToolbarItems;
|
||||
}, [config, source, toolbarItems, showMVOptimizationIndicator]);
|
||||
}, [
|
||||
queriedConfig,
|
||||
source,
|
||||
toolbarItems,
|
||||
showMVOptimizationIndicator,
|
||||
mvOptimizationData,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChartContainer title={title} toolbarItems={toolbarItemsMemo}>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
|||
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Code, Flex, Text } from '@mantine/core';
|
||||
|
||||
import { convertToNumberChartConfig } from '@/ChartUtils';
|
||||
import {
|
||||
buildMVDateRangeIndicator,
|
||||
convertToNumberChartConfig,
|
||||
} from '@/ChartUtils';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
import { formatNumber } from '@/utils';
|
||||
|
||||
|
|
@ -34,6 +38,9 @@ export default function DBNumberChart({
|
|||
[config],
|
||||
);
|
||||
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
|
||||
const { data, isLoading, isError, error } = useQueriedChartConfig(
|
||||
queriedConfig,
|
||||
{
|
||||
|
|
@ -61,24 +68,34 @@ export default function DBNumberChart({
|
|||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-number-chart-mv-indicator"
|
||||
config={config}
|
||||
config={queriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const dateRangeIndicator = buildMVDateRangeIndicator({
|
||||
mvOptimizationData,
|
||||
originalDateRange: queriedConfig.dateRange,
|
||||
});
|
||||
|
||||
if (dateRangeIndicator) {
|
||||
allToolbarItems.push(dateRangeIndicator);
|
||||
}
|
||||
|
||||
if (toolbarSuffix && toolbarSuffix.length > 0) {
|
||||
allToolbarItems.push(...toolbarSuffix);
|
||||
}
|
||||
|
||||
return allToolbarItems;
|
||||
}, [
|
||||
config,
|
||||
toolbarPrefix,
|
||||
toolbarSuffix,
|
||||
source,
|
||||
showMVOptimizationIndicator,
|
||||
mvOptimizationData,
|
||||
queriedConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import {
|
||||
ChartConfigWithDateRange,
|
||||
ChartConfigWithOptTimestamp,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Code, Text } from '@mantine/core';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { convertToTableChartConfig } from '@/ChartUtils';
|
||||
import {
|
||||
buildMVDateRangeIndicator,
|
||||
convertToTableChartConfig,
|
||||
} from '@/ChartUtils';
|
||||
import { Table } from '@/HDXMultiSeriesTableChart';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import { useSource } from '@/source';
|
||||
import { useIntersectionObserver } from '@/utils';
|
||||
|
|
@ -76,8 +77,11 @@ export default function DBTableChart({
|
|||
return _config;
|
||||
}, [config, effectiveSort]);
|
||||
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isLoading, isError, error } =
|
||||
useOffsetPaginatedQuery(queriedConfig as ChartConfigWithDateRange, {
|
||||
useOffsetPaginatedQuery(queriedConfig, {
|
||||
enabled,
|
||||
queryKeyPrefix,
|
||||
});
|
||||
|
|
@ -139,24 +143,34 @@ export default function DBTableChart({
|
|||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-table-chart-mv-indicator"
|
||||
config={config}
|
||||
config={queriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const dateRangeIndicator = buildMVDateRangeIndicator({
|
||||
mvOptimizationData,
|
||||
originalDateRange: queriedConfig.dateRange,
|
||||
});
|
||||
|
||||
if (dateRangeIndicator) {
|
||||
allToolbarItems.push(dateRangeIndicator);
|
||||
}
|
||||
|
||||
if (toolbarSuffix && toolbarSuffix.length > 0) {
|
||||
allToolbarItems.push(...toolbarSuffix);
|
||||
}
|
||||
|
||||
return allToolbarItems;
|
||||
}, [
|
||||
config,
|
||||
toolbarPrefix,
|
||||
toolbarSuffix,
|
||||
source,
|
||||
showMVOptimizationIndicator,
|
||||
mvOptimizationData,
|
||||
queriedConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { add, differenceInSeconds } from 'date-fns';
|
||||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
ChartConfigWithDateRange,
|
||||
DisplayType,
|
||||
|
|
@ -22,7 +23,6 @@ import {
|
|||
IconArrowsDiagonal,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconClock,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
|
|
@ -34,16 +34,17 @@ import {
|
|||
convertGranularityToSeconds,
|
||||
convertToTimeChartConfig,
|
||||
formatResponseForTimeChart,
|
||||
getAlignedDateRange,
|
||||
getPreviousDateRange,
|
||||
PreviousPeriodSuffix,
|
||||
useTimeChartSettings,
|
||||
} from '@/ChartUtils';
|
||||
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
|
||||
import ChartContainer from './charts/ChartContainer';
|
||||
import DateRangeIndicator from './charts/DateRangeIndicator';
|
||||
import DisplaySwitcher from './charts/DisplaySwitcher';
|
||||
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
|
||||
import { SQLPreview } from './ChartSQLPreview';
|
||||
|
|
@ -220,6 +221,7 @@ type DBTimeChartComponentProps = {
|
|||
toolbarPrefix?: React.ReactNode[];
|
||||
toolbarSuffix?: React.ReactNode[];
|
||||
showMVOptimizationIndicator?: boolean;
|
||||
showDateRangeIndicator?: boolean;
|
||||
};
|
||||
|
||||
function DBTimeChartComponent({
|
||||
|
|
@ -241,6 +243,7 @@ function DBTimeChartComponent({
|
|||
toolbarPrefix,
|
||||
toolbarSuffix,
|
||||
showMVOptimizationIndicator = true,
|
||||
showDateRangeIndicator = true,
|
||||
}: DBTimeChartComponentProps) {
|
||||
const [isErrorExpanded, errorExpansion] = useDisclosure(false);
|
||||
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
|
||||
|
|
@ -290,6 +293,9 @@ function DBTimeChartComponent({
|
|||
[config],
|
||||
);
|
||||
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
|
||||
const { data: me, isLoading: isLoadingMe } = api.useMe();
|
||||
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
|
||||
useQueriedChartConfig(queriedConfig, {
|
||||
|
|
@ -592,13 +598,36 @@ function DBTimeChartComponent({
|
|||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-time-chart-mv-indicator"
|
||||
config={config}
|
||||
config={queriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange;
|
||||
const isAlignedToChartGranularity =
|
||||
queriedConfig.alignDateRangeToGranularity !== false;
|
||||
|
||||
if (
|
||||
showDateRangeIndicator &&
|
||||
(mvDateRange || isAlignedToChartGranularity)
|
||||
) {
|
||||
const mvGranularity = isAlignedToChartGranularity
|
||||
? undefined
|
||||
: mvOptimizationData?.explanations.find(e => e.success)?.mvConfig
|
||||
.minGranularity;
|
||||
|
||||
allToolbarItems.push(
|
||||
<DateRangeIndicator
|
||||
key="db-time-chart-date-range-indicator"
|
||||
originalDateRange={config.dateRange}
|
||||
effectiveDateRange={mvDateRange || queriedConfig.dateRange}
|
||||
mvGranularity={mvGranularity}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (showDisplaySwitcher) {
|
||||
allToolbarItems.push(
|
||||
<DisplaySwitcher
|
||||
|
|
@ -638,6 +667,9 @@ function DBTimeChartComponent({
|
|||
toolbarPrefix,
|
||||
toolbarSuffix,
|
||||
showMVOptimizationIndicator,
|
||||
showDateRangeIndicator,
|
||||
mvOptimizationData,
|
||||
queriedConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
155
packages/app/src/components/__tests__/DBHistogramChart.test.tsx
Normal file
155
packages/app/src/components/__tests__/DBHistogramChart.test.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
|
||||
import DateRangeIndicator from '../charts/DateRangeIndicator';
|
||||
import DBHistogramChart from '../DBHistogramChart';
|
||||
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useChartConfig', () => ({
|
||||
useQueriedChartConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/source', () => ({
|
||||
useSource: jest.fn().mockReturnValue({ data: null }),
|
||||
}));
|
||||
|
||||
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
|
||||
jest.fn(() => null),
|
||||
);
|
||||
|
||||
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
|
||||
|
||||
describe('DBHistogramChart', () => {
|
||||
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
|
||||
|
||||
const baseTestConfig = {
|
||||
dateRange: [new Date(), new Date()] as [Date, Date],
|
||||
from: { databaseName: 'test', tableName: 'test' },
|
||||
timestampValueExpression: 'timestamp',
|
||||
connection: 'test-connection',
|
||||
select: '',
|
||||
where: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseQueriedChartConfig.mockReturnValue({
|
||||
data: {
|
||||
data: [{ bucket: '0-10', count: 5 }],
|
||||
meta: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
|
||||
// Mock useSource to return a source so MVOptimizationIndicator is rendered
|
||||
jest.mocked(useSource).mockReturnValue({
|
||||
data: { id: 'test-source', name: 'Test Source' },
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBHistogramChart config={baseTestConfig} />);
|
||||
|
||||
// Get the config that was passed to useMVOptimizationExplanation
|
||||
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
|
||||
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
|
||||
.mock.calls[0][0];
|
||||
|
||||
// Get the config that was passed to useQueriedChartConfig
|
||||
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
|
||||
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Get the config that was passed to MVOptimizationIndicator
|
||||
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
|
||||
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
|
||||
.calls[0][0].config;
|
||||
|
||||
// All three should receive the same config object reference
|
||||
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
|
||||
expect(queriedChartConfig).toBe(indicatorConfig);
|
||||
expect(mvOptExplanationConfig).toBe(indicatorConfig);
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: {
|
||||
...config,
|
||||
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
|
||||
},
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 minute',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBHistogramChart config={config} />);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBHistogramChart config={baseTestConfig} />);
|
||||
|
||||
// Verify DateRangeIndicator was not called
|
||||
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
170
packages/app/src/components/__tests__/DBListBarChart.test.tsx
Normal file
170
packages/app/src/components/__tests__/DBListBarChart.test.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
|
||||
import DateRangeIndicator from '../charts/DateRangeIndicator';
|
||||
import DBListBarChart from '../DBListBarChart';
|
||||
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useChartConfig', () => ({
|
||||
useQueriedChartConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/source', () => ({
|
||||
useSource: jest.fn().mockReturnValue({ data: null }),
|
||||
}));
|
||||
|
||||
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
|
||||
jest.fn(() => null),
|
||||
);
|
||||
|
||||
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
|
||||
|
||||
describe('DBListBarChart', () => {
|
||||
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
|
||||
|
||||
const baseTestConfig = {
|
||||
dateRange: [new Date(), new Date()] as [Date, Date],
|
||||
from: { databaseName: 'test', tableName: 'test' },
|
||||
timestampValueExpression: 'timestamp',
|
||||
connection: 'test-connection',
|
||||
select: '',
|
||||
where: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseQueriedChartConfig.mockReturnValue({
|
||||
data: { data: [{ group: 'test', value: 100 }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
|
||||
// Mock useSource to return a source so MVOptimizationIndicator is rendered
|
||||
jest.mocked(useSource).mockReturnValue({
|
||||
data: { id: 'test-source', name: 'Test Source' },
|
||||
} as any);
|
||||
|
||||
renderWithMantine(
|
||||
<DBListBarChart
|
||||
config={baseTestConfig}
|
||||
valueColumn="value"
|
||||
groupColumn="group"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Get the config that was passed to useMVOptimizationExplanation
|
||||
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
|
||||
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
|
||||
.mock.calls[0][0];
|
||||
|
||||
// Get the config that was passed to useQueriedChartConfig
|
||||
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
|
||||
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Get the config that was passed to MVOptimizationIndicator
|
||||
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
|
||||
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
|
||||
.calls[0][0].config;
|
||||
|
||||
// All three should receive the same config object reference
|
||||
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
|
||||
expect(queriedChartConfig).toBe(indicatorConfig);
|
||||
expect(mvOptExplanationConfig).toBe(indicatorConfig);
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: {
|
||||
...config,
|
||||
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
|
||||
},
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 minute',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(
|
||||
<DBListBarChart
|
||||
config={config}
|
||||
valueColumn="value"
|
||||
groupColumn="group"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(
|
||||
<DBListBarChart
|
||||
config={baseTestConfig}
|
||||
valueColumn="value"
|
||||
groupColumn="group"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify DateRangeIndicator was not called
|
||||
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,16 +2,28 @@ import React from 'react';
|
|||
import { act, screen } from '@testing-library/react';
|
||||
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
import { formatNumber } from '@/utils';
|
||||
|
||||
import { NumberFormat } from '../../types';
|
||||
import DateRangeIndicator from '../charts/DateRangeIndicator';
|
||||
import DBNumberChart from '../DBNumberChart';
|
||||
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useChartConfig', () => ({
|
||||
useQueriedChartConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/source', () => ({
|
||||
useSource: jest.fn().mockReturnValue({ data: null }),
|
||||
}));
|
||||
|
|
@ -25,6 +37,12 @@ jest.mock('@/utils', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
|
||||
jest.fn(() => null),
|
||||
);
|
||||
|
||||
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
|
||||
|
||||
describe('DBNumberChart', () => {
|
||||
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
|
||||
const mockFormatNumber = formatNumber as jest.Mock;
|
||||
|
|
@ -185,4 +203,101 @@ describe('DBNumberChart', () => {
|
|||
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
|
||||
expect(screen.getByText(/Error loading chart/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
|
||||
// Mock useSource to return a source so MVOptimizationIndicator is rendered
|
||||
jest.mocked(useSource).mockReturnValue({
|
||||
data: { id: 'test-source', name: 'Test Source' },
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
|
||||
|
||||
// Get the config that was passed to useMVOptimizationExplanation
|
||||
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
|
||||
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
|
||||
.mock.calls[0][0];
|
||||
|
||||
// Get the config that was passed to useQueriedChartConfig
|
||||
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
|
||||
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Get the config that was passed to MVOptimizationIndicator
|
||||
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
|
||||
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
|
||||
.calls[0][0].config;
|
||||
|
||||
// All three should receive the same config object reference
|
||||
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
|
||||
expect(queriedChartConfig).toBe(indicatorConfig);
|
||||
expect(mvOptExplanationConfig).toBe(indicatorConfig);
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: {
|
||||
...config,
|
||||
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
|
||||
},
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 minute',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBNumberChart config={config} />);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
|
||||
|
||||
// Verify DateRangeIndicator was not called
|
||||
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
168
packages/app/src/components/__tests__/DBTableChart.test.tsx
Normal file
168
packages/app/src/components/__tests__/DBTableChart.test.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import { useSource } from '@/source';
|
||||
|
||||
import DateRangeIndicator from '../charts/DateRangeIndicator';
|
||||
import DBTableChart from '../DBTableChart';
|
||||
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useOffsetPaginatedQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/source', () => ({
|
||||
useSource: jest.fn().mockReturnValue({ data: null }),
|
||||
}));
|
||||
|
||||
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
|
||||
jest.fn(() => null),
|
||||
);
|
||||
|
||||
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
|
||||
|
||||
describe('DBTableChart', () => {
|
||||
const baseTestConfig = {
|
||||
dateRange: [new Date(), new Date()] as [Date, Date],
|
||||
from: { databaseName: 'test', tableName: 'test' },
|
||||
timestampValueExpression: 'timestamp',
|
||||
connection: 'test-connection',
|
||||
select: '',
|
||||
where: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(useOffsetPaginatedQuery).mockReturnValue({
|
||||
data: {
|
||||
data: [{ column1: 'value1', column2: 'value2' }],
|
||||
meta: [
|
||||
{ name: 'column1', type: 'String' },
|
||||
{ name: 'column2', type: 'String' },
|
||||
],
|
||||
chSql: { sql: '', params: {} },
|
||||
window: {
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
windowIndex: 0,
|
||||
direction: 'DESC' as const,
|
||||
},
|
||||
},
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('passes the same config to useMVOptimizationExplanation, useOffsetPaginatedQuery, and MVOptimizationIndicator', () => {
|
||||
// Mock useSource to return a source so MVOptimizationIndicator is rendered
|
||||
jest.mocked(useSource).mockReturnValue({
|
||||
data: { id: 'test-source', name: 'Test Source' },
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTableChart config={baseTestConfig} />);
|
||||
|
||||
// Get the config that was passed to useMVOptimizationExplanation
|
||||
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
|
||||
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
|
||||
.mock.calls[0][0];
|
||||
|
||||
// Get the config that was passed to useOffsetPaginatedQuery
|
||||
expect(jest.mocked(useOffsetPaginatedQuery)).toHaveBeenCalled();
|
||||
const paginatedQueryConfig = jest.mocked(useOffsetPaginatedQuery).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Get the config that was passed to MVOptimizationIndicator
|
||||
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
|
||||
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
|
||||
.calls[0][0].config;
|
||||
|
||||
// All three should receive the same config object reference
|
||||
expect(mvOptExplanationConfig).toBe(paginatedQueryConfig);
|
||||
expect(paginatedQueryConfig).toBe(indicatorConfig);
|
||||
expect(mvOptExplanationConfig).toBe(indicatorConfig);
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: {
|
||||
...config,
|
||||
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
|
||||
},
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 minute',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTableChart config={config} />);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTableChart config={baseTestConfig} />);
|
||||
|
||||
// Verify DateRangeIndicator was not called
|
||||
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,15 +2,26 @@ import React from 'react';
|
|||
|
||||
import api from '@/api';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { useSource } from '@/source';
|
||||
|
||||
import DateRangeIndicator from '../charts/DateRangeIndicator';
|
||||
import { DBTimeChart } from '../DBTimeChart';
|
||||
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useChartConfig', () => ({
|
||||
useQueriedChartConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/api', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
|
@ -22,6 +33,12 @@ jest.mock('@/source', () => ({
|
|||
useSource: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
|
||||
jest.fn(() => null),
|
||||
);
|
||||
|
||||
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
|
||||
|
||||
describe('DBTimeChart', () => {
|
||||
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
|
||||
const mockUseMe = api.useMe as jest.Mock;
|
||||
|
|
@ -124,4 +141,146 @@ describe('DBTimeChart', () => {
|
|||
// because the enabled prop is false
|
||||
expect(secondCallOptions.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
|
||||
// Mock useSource to return a source so MVOptimizationIndicator is rendered
|
||||
jest.mocked(useSource).mockReturnValue({
|
||||
data: { id: 'test-source', name: 'Test Source' },
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTimeChart config={baseTestConfig} />);
|
||||
|
||||
// Get the config that was passed to useMVOptimizationExplanation
|
||||
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
|
||||
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
|
||||
.mock.calls[0][0];
|
||||
|
||||
// Get the config that was passed to useQueriedChartConfig (first call is the main query)
|
||||
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
|
||||
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Get the config that was passed to MVOptimizationIndicator
|
||||
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
|
||||
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
|
||||
.calls[0][0].config;
|
||||
|
||||
// All three should receive the same config object reference
|
||||
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
|
||||
expect(queriedChartConfig).toBe(indicatorConfig);
|
||||
expect(mvOptExplanationConfig).toBe(indicatorConfig);
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
alignDateRangeToGranularity: false,
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: {
|
||||
...config,
|
||||
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
|
||||
},
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 minute',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTimeChart config={config} />);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
|
||||
});
|
||||
|
||||
it('renders DateRangeIndicator when alignDateRangeToGranularity is true and results in a different date range', () => {
|
||||
const originalStartDate = new Date('2024-01-01T00:00:30Z');
|
||||
const originalEndDate = new Date('2024-01-01T01:30:45Z');
|
||||
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
|
||||
const alignedEndDate = new Date('2024-01-01T01:35:00Z');
|
||||
|
||||
const config = {
|
||||
...baseTestConfig,
|
||||
alignDateRangeToGranularity: true,
|
||||
granularity: '5 minute',
|
||||
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
|
||||
};
|
||||
|
||||
// Mock useMVOptimizationExplanation to return no optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(<DBTimeChart config={config} />);
|
||||
|
||||
// Verify DateRangeIndicator was called
|
||||
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
|
||||
|
||||
// Verify it was called with the correct props
|
||||
const dateRangeIndicatorCall =
|
||||
jest.mocked(DateRangeIndicator).mock.calls[0][0];
|
||||
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
|
||||
originalStartDate,
|
||||
originalEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
|
||||
alignedStartDate,
|
||||
alignedEndDate,
|
||||
]);
|
||||
expect(dateRangeIndicatorCall.mvGranularity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range and showDateRangeIndicator is false', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: undefined,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as any);
|
||||
|
||||
renderWithMantine(
|
||||
<DBTimeChart config={baseTestConfig} showDateRangeIndicator={false} />,
|
||||
);
|
||||
|
||||
// Verify DateRangeIndicator was not called
|
||||
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function ChartContainer({
|
|||
{title}
|
||||
</span>
|
||||
{toolbarItems && (
|
||||
<Group flex={0} wrap="nowrap">
|
||||
<Group flex={0} wrap="nowrap" gap={5}>
|
||||
{toolbarItems}
|
||||
</Group>
|
||||
)}
|
||||
|
|
|
|||
42
packages/app/src/components/charts/DateRangeIndicator.tsx
Normal file
42
packages/app/src/components/charts/DateRangeIndicator.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { isDateRangeEqual } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { SQLInterval } from '@hyperdx/common-utils/dist/types';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
|
||||
import { useFormatTime } from '@/useFormatTime';
|
||||
|
||||
interface DateRangeIndicatorProps {
|
||||
originalDateRange: [Date, Date];
|
||||
effectiveDateRange?: [Date, Date];
|
||||
mvGranularity?: SQLInterval;
|
||||
}
|
||||
|
||||
export default function DateRangeIndicator({
|
||||
originalDateRange,
|
||||
effectiveDateRange,
|
||||
mvGranularity,
|
||||
}: DateRangeIndicatorProps) {
|
||||
const formatTime = useFormatTime();
|
||||
|
||||
if (
|
||||
!effectiveDateRange ||
|
||||
isDateRangeEqual(effectiveDateRange, originalDateRange)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [start, end] = [
|
||||
formatTime(effectiveDateRange[0]),
|
||||
formatTime(effectiveDateRange[1]),
|
||||
];
|
||||
|
||||
const label = mvGranularity
|
||||
? `Querying ${start} - ${end} due to ${mvGranularity} rollups in query acceleration.`
|
||||
: `Querying ${start} - ${end} to show complete intervals.`;
|
||||
|
||||
return (
|
||||
<Tooltip multiline maw={500} label={label}>
|
||||
<IconClock size={16} color="var(--color-text)" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,12 +10,6 @@ import {
|
|||
useMultipleGetKeyValues,
|
||||
} from '../useMetadata';
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (obj: any) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../useMetadata', () => ({
|
||||
...jest.requireActual('../useMetadata.tsx'),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getGranularityAlignedTimeWindows,
|
||||
useQueriedChartConfig,
|
||||
} from '../useChartConfig';
|
||||
import { useMVOptimizationExplanation } from '../useMVOptimizationExplanation';
|
||||
|
||||
// Mock the clickhouse module
|
||||
jest.mock('@/clickhouse', () => ({
|
||||
|
|
@ -34,6 +35,14 @@ jest.mock('@/config', () => ({
|
|||
IS_MTVIEWS_ENABLED: false,
|
||||
}));
|
||||
|
||||
// Mock the MV optimization module
|
||||
jest.mock('../useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Create a mock ChartConfig
|
||||
const createMockChartConfig = (
|
||||
overrides: Partial<ChartConfigWithOptDateRange> = {},
|
||||
|
|
@ -1291,5 +1300,107 @@ describe('useChartConfig', () => {
|
|||
isComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not execute query while useMVOptimizationExplanation is in loading state', async () => {
|
||||
const config = createMockChartConfig({
|
||||
dateRange: [
|
||||
new Date('2025-10-01 00:00:00Z'),
|
||||
new Date('2025-10-02 00:00:00Z'),
|
||||
],
|
||||
granularity: '1 hour',
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
aggCondition: '',
|
||||
aggFn: 'count',
|
||||
valueExpression: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const optimizedConfig = createMockChartConfig({
|
||||
...config,
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'metrics_rollup_1h',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
aggCondition: '',
|
||||
aggFn: 'countMerge',
|
||||
valueExpression: 'count__',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock useMVOptimizationExplanation to be in loading state
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true, // MV optimization is still loading
|
||||
} as any);
|
||||
|
||||
renderHook(
|
||||
() => useQueriedChartConfig(config, { enableQueryChunking: true }),
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait a bit to ensure query doesn't execute
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify the query hasn't started because MV optimization is loading
|
||||
expect(mockClickhouseClient.queryChartConfig).not.toHaveBeenCalled();
|
||||
|
||||
// Now mock the MV optimization to finish loading with an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: optimizedConfig,
|
||||
explanations: [
|
||||
{
|
||||
success: true,
|
||||
mvConfig: {
|
||||
minGranularity: '1 hour',
|
||||
tableName: 'metrics_rollup_1h',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false, // MV optimization finished loading
|
||||
} as any);
|
||||
|
||||
const mockResponse = createMockQueryResponse([
|
||||
{
|
||||
'count()': '71',
|
||||
SeverityText: 'info',
|
||||
__hdx_time_bucket: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
mockClickhouseClient.queryChartConfig.mockResolvedValue(mockResponse);
|
||||
|
||||
// Re-render with MV optimization completed
|
||||
const { result: result2 } = renderHook(
|
||||
() => useQueriedChartConfig(config, { enableQueryChunking: true }),
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result2.current.isSuccess).toBe(true));
|
||||
await waitFor(() => expect(result2.current.isFetching).toBe(false));
|
||||
|
||||
// Verify the query was executed after MV optimization finished
|
||||
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalled();
|
||||
|
||||
// Verify the query used the optimized config (materialized view)
|
||||
const queryCall = mockClickhouseClient.queryChartConfig.mock.calls[0][0];
|
||||
expect(queryCall.config.from.tableName).toBe('metrics_rollup_1h');
|
||||
|
||||
expect(result2.current.data?.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { act } from 'react';
|
||||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import useOffsetPaginatedQuery from '../useOffsetPaginatedQuery';
|
||||
|
|
@ -27,6 +31,15 @@ jest.mock('@hyperdx/app/src/metadata', () => ({
|
|||
getMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useMVOptimizationExplanation hook
|
||||
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
|
||||
useMVOptimizationExplanation: jest.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the renderChartConfig function
|
||||
jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({
|
||||
renderChartConfig: jest.fn(),
|
||||
|
|
@ -34,8 +47,14 @@ jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({
|
|||
|
||||
// Import mocked modules after jest.mock calls
|
||||
import { getClickhouseClient } from '@hyperdx/app/src/clickhouse';
|
||||
import { MVOptimizationExplanation } from '@hyperdx/common-utils/dist/core/materializedViews';
|
||||
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
|
||||
import {
|
||||
MVOptimizationExplanationResult,
|
||||
useMVOptimizationExplanation,
|
||||
} from '@/hooks/useMVOptimizationExplanation';
|
||||
|
||||
// Create a mock ChartConfig based on the Zod schema
|
||||
const createMockChartConfig = (
|
||||
overrides: Partial<ChartConfigWithDateRange> = {},
|
||||
|
|
@ -743,4 +762,168 @@ describe('useOffsetPaginatedQuery', () => {
|
|||
expect(result2.current.data?.data[0].message).toBe('config2 log');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MV Optimization Integration', () => {
|
||||
it('should optimize queries using MVs when possible', async () => {
|
||||
const config = createMockChartConfig({
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_spans',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: 'Duration',
|
||||
aggFn: 'avg',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const optimizedConfig = createMockChartConfig({
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: 'avg__Duration',
|
||||
aggFn: 'avgMerge',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock MV optimization to return an optimized config
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
|
||||
|
||||
// Mock the reader to return data
|
||||
mockReader.read
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: [
|
||||
{ json: () => ['timestamp', 'avg_duration'] },
|
||||
{ json: () => ['DateTime', 'Float64'] },
|
||||
{ json: () => ['2024-01-01T01:00:00Z', 123.45] },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true });
|
||||
|
||||
const { result } = renderHook(() => useOffsetPaginatedQuery(config), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
// Verify the query was executed with the optimized config
|
||||
expect(renderChartConfig).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest
|
||||
.mocked(renderChartConfig)
|
||||
.mock.calls.every(
|
||||
call => call[0].from.tableName === 'metrics_rollup_1m',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Verify data was returned successfully
|
||||
expect(result.current.data?.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not execute query while useMVOptimizationExplanation is in loading state', async () => {
|
||||
const config = createMockChartConfig({
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_spans',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: 'Duration',
|
||||
aggFn: 'avg',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const optimizedConfig = createMockChartConfig({
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: 'avg__Duration',
|
||||
aggFn: 'avgMerge',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock MV optimization to be in loading state
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true, // MV optimization is still loading
|
||||
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
|
||||
|
||||
const { result } = renderHook(() => useOffsetPaginatedQuery(config), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait a bit to ensure query doesn't execute
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify the query is still in loading state because MV optimization is loading
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// Verify that renderChartConfig was NOT called because MV optimization is still loading
|
||||
expect(renderChartConfig).not.toHaveBeenCalled();
|
||||
|
||||
// Now mock the MV optimization to finish loading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
data: {
|
||||
optimizedConfig: optimizedConfig,
|
||||
explanations: [],
|
||||
},
|
||||
isLoading: false, // MV optimization finished loading
|
||||
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
|
||||
|
||||
// Mock the reader to return data
|
||||
mockReader.read
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: [
|
||||
{ json: () => ['timestamp', 'avg_duration'] },
|
||||
{ json: () => ['DateTime', 'Float64'] },
|
||||
{ json: () => ['2024-01-01T01:00:00Z', 123.45] },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true });
|
||||
|
||||
// Force a re-render to pick up the new mock value
|
||||
const { result: result2 } = renderHook(
|
||||
() => useOffsetPaginatedQuery(config),
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result2.current.isLoading).toBe(false));
|
||||
|
||||
// Verify the query was executed with the optimized config after MV optimization finished
|
||||
expect(renderChartConfig).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest
|
||||
.mocked(renderChartConfig)
|
||||
.mock.calls.every(
|
||||
call => call[0].from.tableName === 'metrics_rollup_1m',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(result2.current.data?.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import { getMetadata } from '@/metadata';
|
|||
import { useSource } from '@/source';
|
||||
import { generateTimeWindowsDescending } from '@/utils/searchWindows';
|
||||
|
||||
import { useMVOptimizationExplanation } from './useMVOptimizationExplanation';
|
||||
|
||||
interface AdditionalUseQueriedChartConfigOptions {
|
||||
onError?: (error: Error | ClickHouseQueryError) => void;
|
||||
/**
|
||||
|
|
@ -259,9 +261,11 @@ export function useQueriedChartConfig(
|
|||
const queryClient = useQueryClient();
|
||||
const metadata = useMetadataWithSettings();
|
||||
|
||||
const { data: source, isLoading: isLoadingSource } = useSource({
|
||||
id: config.source,
|
||||
});
|
||||
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
|
||||
useMVOptimizationExplanation(config, {
|
||||
enabled: !!enabled,
|
||||
placeholderData: undefined,
|
||||
});
|
||||
|
||||
const query = useQuery<TQueryFnData, ClickHouseQueryError | Error>({
|
||||
// Include enableQueryChunking in the query key to ensure that queries with the
|
||||
|
|
@ -274,16 +278,7 @@ export function useQueriedChartConfig(
|
|||
// TODO: Replace this with `streamedQuery` when it is no longer experimental. Use 'replace' refetch mode.
|
||||
// https://tanstack.com/query/latest/docs/reference/streamedQuery
|
||||
queryFn: async context => {
|
||||
const optimizedConfig = source?.materializedViews?.length
|
||||
? await tryOptimizeConfigWithMaterializedView(
|
||||
config,
|
||||
metadata,
|
||||
clickhouseClient,
|
||||
context.signal,
|
||||
source,
|
||||
)
|
||||
: config;
|
||||
|
||||
const optimizedConfig = mvOptimizationData?.optimizedConfig ?? config;
|
||||
const query = queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: context.queryKey, exact: true });
|
||||
|
|
@ -334,7 +329,7 @@ export function useQueriedChartConfig(
|
|||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
enabled: enabled && !isLoadingSource,
|
||||
enabled: enabled && !isLoadingMVOptimization,
|
||||
});
|
||||
|
||||
if (query.isError && options?.onError) {
|
||||
|
|
@ -342,7 +337,7 @@ export function useQueriedChartConfig(
|
|||
}
|
||||
return {
|
||||
...query,
|
||||
isLoading: query.isLoading || isLoadingSource,
|
||||
isLoading: query.isLoading || isLoadingMVOptimization,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -351,36 +346,27 @@ export function useRenderedSqlChartConfig(
|
|||
options?: UseQueryOptions<string>,
|
||||
) {
|
||||
const { enabled = true } = options ?? {};
|
||||
const metadata = useMetadataWithSettings();
|
||||
const clickhouseClient = useClickhouseClient();
|
||||
|
||||
const { data: source, isLoading: isLoadingSource } = useSource({
|
||||
id: config.source,
|
||||
});
|
||||
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
|
||||
useMVOptimizationExplanation(config, {
|
||||
enabled: !!enabled,
|
||||
placeholderData: undefined,
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['renderedSql', config],
|
||||
queryFn: async ({ signal }) => {
|
||||
const optimizedConfig = source?.materializedViews?.length
|
||||
? await tryOptimizeConfigWithMaterializedView(
|
||||
config,
|
||||
metadata,
|
||||
clickhouseClient,
|
||||
signal,
|
||||
source,
|
||||
)
|
||||
: config;
|
||||
|
||||
queryFn: async () => {
|
||||
const optimizedConfig = mvOptimizationData?.optimizedConfig ?? config;
|
||||
const query = await renderChartConfig(optimizedConfig, getMetadata());
|
||||
return format(parameterizedQueryToSql(query));
|
||||
},
|
||||
...options,
|
||||
enabled: enabled && !isLoadingSource,
|
||||
enabled: enabled && !isLoadingMVOptimization,
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
isLoading: query.isLoading || isLoadingSource,
|
||||
isLoading: query.isLoading || isLoadingMVOptimization,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,18 @@ import { useSource } from '@/source';
|
|||
|
||||
import { useMetadataWithSettings } from './useMetadata';
|
||||
|
||||
export interface MVOptimizationExplanationResult {
|
||||
optimizedConfig?: ChartConfigWithOptDateRange;
|
||||
export interface MVOptimizationExplanationResult<
|
||||
C extends ChartConfigWithOptDateRange = ChartConfigWithOptDateRange,
|
||||
> {
|
||||
optimizedConfig?: C;
|
||||
explanations: MVOptimizationExplanation[];
|
||||
}
|
||||
|
||||
export function useMVOptimizationExplanation(
|
||||
config: ChartConfigWithOptDateRange | undefined,
|
||||
options?: UseQueryOptions<MVOptimizationExplanationResult>,
|
||||
export function useMVOptimizationExplanation<
|
||||
C extends ChartConfigWithOptDateRange,
|
||||
>(
|
||||
config: C | undefined,
|
||||
options?: Partial<UseQueryOptions<MVOptimizationExplanationResult<C>>>,
|
||||
) {
|
||||
const { enabled = true } = options || {};
|
||||
const metadata = useMetadataWithSettings();
|
||||
|
|
@ -31,7 +35,7 @@ export function useMVOptimizationExplanation(
|
|||
id: config?.source,
|
||||
});
|
||||
|
||||
return useQuery<MVOptimizationExplanationResult>({
|
||||
return useQuery<MVOptimizationExplanationResult<C>>({
|
||||
queryKey: ['optimizationExplanation', config],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!config || !source) {
|
||||
|
|
@ -49,6 +53,7 @@ export function useMVOptimizationExplanation(
|
|||
);
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 5000,
|
||||
...options,
|
||||
enabled: enabled && !isLoadingSource && !!config && !!source,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,17 +6,13 @@ import {
|
|||
ClickHouseQueryError,
|
||||
ColumnMetaType,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { tryOptimizeConfigWithMaterializedView } from '@hyperdx/common-utils/dist/core/materializedViews';
|
||||
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
import {
|
||||
isFirstOrderByAscending,
|
||||
isTimestampExpressionInFirstOrderBy,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
ChartConfigWithOptTimestamp,
|
||||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
|
|
@ -27,7 +23,7 @@ import {
|
|||
import api from '@/api';
|
||||
import { getClickhouseClient } from '@/clickhouse';
|
||||
import { useMetadataWithSettings } from '@/hooks/useMetadata';
|
||||
import { useSource } from '@/source';
|
||||
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
|
||||
import { omit } from '@/utils';
|
||||
import {
|
||||
generateTimeWindowsAscending,
|
||||
|
|
@ -70,7 +66,7 @@ type QueryMeta = {
|
|||
queryClient: QueryClient;
|
||||
hasPreviousQueries: boolean;
|
||||
metadata: Metadata;
|
||||
source?: TSource;
|
||||
optimizedConfig?: ChartConfigWithOptTimestamp;
|
||||
};
|
||||
|
||||
// Get time window from page param
|
||||
|
|
@ -145,7 +141,7 @@ const queryFn: QueryFunction<TQueryFnData, TQueryKey, TPageParam> = async ({
|
|||
throw new Error('Query missing client meta');
|
||||
}
|
||||
|
||||
const { queryClient, metadata, hasPreviousQueries, source } =
|
||||
const { queryClient, metadata, hasPreviousQueries, optimizedConfig } =
|
||||
meta as QueryMeta;
|
||||
|
||||
// Only stream incrementally if this is a fresh query with no previous
|
||||
|
|
@ -158,15 +154,7 @@ const queryFn: QueryFunction<TQueryFnData, TQueryKey, TPageParam> = async ({
|
|||
const clickhouseClient = getClickhouseClient({ queryTimeout });
|
||||
|
||||
const rawConfig = queryKey[1];
|
||||
const config = source?.materializedViews?.length
|
||||
? await tryOptimizeConfigWithMaterializedView(
|
||||
rawConfig,
|
||||
metadata,
|
||||
clickhouseClient,
|
||||
signal,
|
||||
source,
|
||||
)
|
||||
: rawConfig;
|
||||
const config = optimizedConfig ?? rawConfig;
|
||||
|
||||
// Get the time window for this page
|
||||
const shouldUseWindowing = isTimestampExpressionInFirstOrderBy(config);
|
||||
|
|
@ -405,9 +393,11 @@ export default function useOffsetPaginatedQuery(
|
|||
const hasPreviousQueries =
|
||||
matchedQueries.filter(([_, data]) => data != null).length > 0;
|
||||
|
||||
const { data: source, isLoading: isLoadingSource } = useSource({
|
||||
id: config.source,
|
||||
});
|
||||
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
|
||||
useMVOptimizationExplanation(config, {
|
||||
enabled: !!enabled,
|
||||
placeholderData: undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
|
|
@ -429,7 +419,7 @@ export default function useOffsetPaginatedQuery(
|
|||
// Only preserve previous query in live mode
|
||||
return isLive ? prev : undefined;
|
||||
},
|
||||
enabled: enabled && !isLoadingMe && !isLoadingSource,
|
||||
enabled: enabled && !isLoadingMe && !isLoadingMVOptimization,
|
||||
initialPageParam: { windowIndex: 0, offset: 0 } as TPageParam,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
return getNextPageParam(lastPage, allPages, config);
|
||||
|
|
@ -439,7 +429,7 @@ export default function useOffsetPaginatedQuery(
|
|||
queryClient,
|
||||
hasPreviousQueries,
|
||||
metadata,
|
||||
source,
|
||||
optimizedConfig: mvOptimizationData?.optimizedConfig,
|
||||
} satisfies QueryMeta,
|
||||
queryFn,
|
||||
gcTime: isLive ? ms('30s') : ms('5m'), // more aggressive gc for live data, since it can end up holding lots of data
|
||||
|
|
@ -456,7 +446,7 @@ export default function useOffsetPaginatedQuery(
|
|||
data: flattenedData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching: isFetching || isLoadingMe || isLoadingSource,
|
||||
isLoading: isLoading || isLoadingMe || isLoadingSource,
|
||||
isFetching: isFetching || isLoadingMe || isLoadingMVOptimization,
|
||||
isLoading: isLoading || isLoadingMe || isLoadingMVOptimization,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { TextDecoder, TextEncoder } from 'util';
|
|||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { render } from '@testing-library/react';
|
||||
import structuredClone from '@ungap/structured-clone';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
global.TextEncoder = TextEncoder as any;
|
||||
|
|
@ -35,6 +36,11 @@ global.renderWithMantine = (ui: React.ReactElement) => {
|
|||
);
|
||||
};
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
// @ts-expect-error this is a correct polyfill
|
||||
globalThis.structuredClone = structuredClone;
|
||||
}
|
||||
|
||||
declare global {
|
||||
function renderWithMantine(ui: React.ReactElement): ReturnType<typeof render>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
convertToDashboardTemplate,
|
||||
findJsonExpressions,
|
||||
formatDate,
|
||||
getAlignedDateRange,
|
||||
getFirstOrderingItem,
|
||||
isFirstOrderByAscending,
|
||||
isJsonExpression,
|
||||
|
|
@ -1315,4 +1316,111 @@ describe('utils', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getAlignedDateRange', () => {
|
||||
it('should align start time down to the previous minute boundary', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:37Z'), // 37 seconds
|
||||
new Date('2025-11-26T12:25:00Z'),
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
|
||||
});
|
||||
|
||||
it('should align end time up to the next minute boundary', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:00Z'),
|
||||
new Date('2025-11-26T12:25:42Z'), // 42 seconds
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
|
||||
});
|
||||
|
||||
it('should align both start and end times with 5 minute granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:20:00
|
||||
new Date('2025-11-26T12:27:42Z'), // Should round up to 12:30:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'5 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:20:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 30 second granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:23:00
|
||||
new Date('2025-11-26T12:25:42Z'), // Should round up to 12:26:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'30 second',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 1 day granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to start of day
|
||||
new Date('2025-11-28T08:15:00Z'), // Should round up to start of next day
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 day',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T00:00:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-29T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should not change range when already aligned to the interval', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:00Z'), // Already aligned
|
||||
new Date('2025-11-26T12:25:00Z'), // Already aligned
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'1 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
|
||||
});
|
||||
|
||||
it('should align with 15 minute granularity', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:15:00
|
||||
new Date('2025-11-26T12:47:42Z'), // Should round up to 13:00:00
|
||||
];
|
||||
|
||||
const [alignedStart, alignedEnd] = getAlignedDateRange(
|
||||
dateRange,
|
||||
'15 minute',
|
||||
);
|
||||
|
||||
expect(alignedStart.toISOString()).toBe('2025-11-26T12:15:00.000Z');
|
||||
expect(alignedEnd.toISOString()).toBe('2025-11-26T13:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -895,7 +895,7 @@ describe('materializedViews', () => {
|
|||
connection: 'test-connection',
|
||||
dateRange: [
|
||||
new Date('2023-12-01T00:00:00Z'),
|
||||
new Date('2023-12-31T23:59:59Z'),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -915,7 +915,7 @@ describe('materializedViews', () => {
|
|||
},
|
||||
dateRange: [
|
||||
new Date('2023-12-01T00:00:00Z'),
|
||||
new Date('2023-12-31T23:59:59Z'),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
],
|
||||
select: [
|
||||
{
|
||||
|
|
@ -976,6 +976,53 @@ describe('materializedViews', () => {
|
|||
});
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should align dateRange to MV granularity when optimizing a config with dateRange', async () => {
|
||||
const chartConfig: ChartConfigWithOptDateRange = {
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_spans',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: '',
|
||||
aggFn: 'count',
|
||||
},
|
||||
],
|
||||
where: '',
|
||||
connection: 'test-connection',
|
||||
dateRange: [
|
||||
new Date('2023-01-01T00:00:30Z'),
|
||||
new Date('2023-01-02T01:00:45Z'),
|
||||
],
|
||||
};
|
||||
|
||||
const result = await tryConvertConfigToMaterializedViewSelect(
|
||||
chartConfig,
|
||||
MV_CONFIG_METRIC_ROLLUP_1M,
|
||||
metadata,
|
||||
);
|
||||
|
||||
expect(result.optimizedConfig).toEqual({
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'metrics_rollup_1m',
|
||||
},
|
||||
select: [
|
||||
{
|
||||
valueExpression: 'count',
|
||||
aggFn: 'sum',
|
||||
},
|
||||
],
|
||||
where: '',
|
||||
connection: 'test-connection',
|
||||
dateRange: [
|
||||
new Date('2023-01-01T00:00:00Z'),
|
||||
new Date('2023-01-02T01:01:00Z'),
|
||||
],
|
||||
dateRangeEndInclusive: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnsupportedCountFunction', () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS } from './renderChartConfig';
|
|||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
convertGranularityToSeconds,
|
||||
getAlignedDateRange,
|
||||
} from './utils';
|
||||
|
||||
type SelectItem = Exclude<
|
||||
|
|
@ -348,7 +349,16 @@ export async function tryConvertConfigToMaterializedViewSelect<
|
|||
tableName: mvConfig.tableName,
|
||||
},
|
||||
// Make the date range end exclusive to avoid selecting the entire next time bucket from the MV
|
||||
...('dateRange' in chartConfig ? { dateRangeEndInclusive: false } : {}),
|
||||
// Align the date range to the MV granularity to avoid excluding the first time bucket
|
||||
...('dateRange' in chartConfig && chartConfig.dateRange
|
||||
? {
|
||||
dateRangeEndInclusive: false,
|
||||
dateRange: getAlignedDateRange(
|
||||
chartConfig.dateRange,
|
||||
mvConfig.minGranularity,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -662,3 +662,27 @@ export function optimizeTimestampValueExpression(
|
|||
|
||||
return timestampValueExprs.join(', ');
|
||||
}
|
||||
|
||||
export function getAlignedDateRange(
|
||||
[originalStart, originalEnd]: [Date, Date],
|
||||
granularity: SQLInterval,
|
||||
): [Date, Date] {
|
||||
// Round the start time down to the previous interval boundary
|
||||
const alignedStart = toStartOfInterval(originalStart, granularity);
|
||||
|
||||
// Round the end time up to the next interval boundary
|
||||
let alignedEnd = toStartOfInterval(originalEnd, granularity);
|
||||
if (alignedEnd.getTime() < originalEnd.getTime()) {
|
||||
const intervalSeconds = convertGranularityToSeconds(granularity);
|
||||
alignedEnd = fnsAdd(alignedEnd, { seconds: intervalSeconds });
|
||||
}
|
||||
|
||||
return [alignedStart, alignedEnd];
|
||||
}
|
||||
|
||||
export function isDateRangeEqual(range1: [Date, Date], range2: [Date, Date]) {
|
||||
return (
|
||||
range1[0].getTime() === range2[0].getTime() &&
|
||||
range1[1].getTime() === range2[1].getTime()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9701,6 +9701,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/ungap__structured-clone@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "@types/ungap__structured-clone@npm:1.2.0"
|
||||
checksum: 10c0/52f341ded16708603e5631769b26acf0e9ed7c556ce81abb4e55962110b30442576c631c8f7e298b561b8ff77c7823f0edc9f5e313e1c5e1441825a590e5b0f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/unist@npm:3.0.2"
|
||||
|
|
@ -16825,8 +16832,10 @@ __metadata:
|
|||
"@changesets/cli": "npm:^2.26.2"
|
||||
"@dotenvx/dotenvx": "npm:^1.51.1"
|
||||
"@nx/workspace": "npm:21.3.11"
|
||||
"@types/ungap__structured-clone": "npm:^1.2.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^8.48.1"
|
||||
"@typescript-eslint/parser": "npm:^8.48.1"
|
||||
"@ungap/structured-clone": "npm:^1.3.0"
|
||||
babel-plugin-react-compiler: "npm:^1.0.0"
|
||||
concurrently: "npm:^9.1.2"
|
||||
dotenv: "npm:^16.4.7"
|
||||
|
|
|
|||
Loading…
Reference in a new issue