From 3debf332ba53e6d2937c3c5ac126fe519e312b5a Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 18 May 2026 17:33:31 -0600 Subject: [PATCH 01/33] Prototype diff UI, document prototype API --- docs/SUMMARY.md | 1 + .../query_engine/query_profile_diff.md | 84 ++++ ui/packages/@quent/client/src/api.ts | 13 + ui/packages/@quent/client/src/index.ts | 14 + .../client/src/queryProfileDiff.test.ts | 16 + .../@quent/client/src/queryProfileDiff.ts | 39 ++ .../client/src/queryProfileDiffTypes.ts | 54 +++ .../src/pivot-table/PivotedStatTable.tsx | 37 +- .../components/src/pivot-table/types.ts | 15 + .../query-diff/QueryDiffTable.test.tsx | 40 ++ .../components/query-diff/QueryDiffTable.tsx | 191 ++++++++ .../query-diff/QueryDiffTable.utils.ts | 66 +++ ui/src/pages/DiffSelectionPage.tsx | 428 ++++++++++++++++++ ui/src/routes/__root.tsx | 14 + ...neId.query.$queryAId.compare.$queryBId.tsx | 20 + ui/src/routes/diff.index.tsx | 13 + ui/src/routes/diff.test.tsx | 82 ++++ ui/src/routes/diff.tsx | 12 + ui/src/test/mocks/handlers.ts | 16 + ui/src/test/mocks/queryProfileDiffFixtures.ts | 98 ++++ ui/vite.config.ts | 174 +++++++ 21 files changed, 1421 insertions(+), 6 deletions(-) create mode 100644 docs/domains/query_engine/query_profile_diff.md create mode 100644 ui/packages/@quent/client/src/queryProfileDiff.test.ts create mode 100644 ui/packages/@quent/client/src/queryProfileDiff.ts create mode 100644 ui/packages/@quent/client/src/queryProfileDiffTypes.ts create mode 100644 ui/src/components/query-diff/QueryDiffTable.test.tsx create mode 100644 ui/src/components/query-diff/QueryDiffTable.tsx create mode 100644 ui/src/components/query-diff/QueryDiffTable.utils.ts create mode 100644 ui/src/pages/DiffSelectionPage.tsx create mode 100644 ui/src/routes/diff.engine.$engineId.query.$queryAId.compare.$queryBId.tsx create mode 100644 ui/src/routes/diff.index.tsx create mode 100644 ui/src/routes/diff.test.tsx create mode 100644 ui/src/routes/diff.tsx create mode 100644 ui/src/test/mocks/queryProfileDiffFixtures.ts diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index adc34a89..9fdbf98a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -17,5 +17,6 @@ - [Channel](./modeling/common/channel.md) - [Domain-Specific Models](./domains/README.md) - [Query Engines](./domains/query_engine/README.md) + - [Query Profile Diff API](./domains/query_engine/query_profile_diff.md) - [Examples](./domains/query_engine/examples/README.md) - [Simulator](./domains/query_engine/examples/simulator.md) diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md new file mode 100644 index 00000000..f299e657 --- /dev/null +++ b/docs/domains/query_engine/query_profile_diff.md @@ -0,0 +1,84 @@ +# Query Profile Diff API + +The query profile diff API compares two query profiles from the same engine. +The UI uses this contract through a mock endpoint first; generated TypeScript +bindings should replace the temporary client-side types once the Rust endpoint +exports these shapes. + +## Endpoint + +```http +POST /api/engines/{engine_id}/query-profile-diff +``` + +## Request + +```ts +export interface QueryProfileDiffRequest { + query_a_id: string; + query_b_id: string; +} +``` + +Query A is the baseline. Numeric deltas are always `A - B`. + +## Response + +```ts +export type QueryProfileDiffScenario = + | "plans_equal" + | "plans_different" + | "plans_incomparable"; + +export interface QueryProfileDiffQuerySummary { + id: string; + instance_name: string | null; + query_group_id?: string | null; + query_group_name?: string | null; +} + +export interface QueryProfileDiffOperatorRef { + id: string; + label: string; + operator_type_name: string | null; + plan_id: string | null; +} + +export interface QueryProfileDiffStatDelta { + a: StatValue; + b: StatValue; + delta: number | null; + percent_delta: number | null; +} + +export interface QueryProfileDiffOperatorDelta { + operator_a: QueryProfileDiffOperatorRef | null; + operator_b: QueryProfileDiffOperatorRef | null; + stats: Record; +} + +export interface QueryProfileDiffPlanComparison { + match_kind: "structural" | "different" | "incomparable"; + matched_operator_count: number; + unmatched_operator_a_count: number; + unmatched_operator_b_count: number; +} + +export interface QueryProfileDiffResponse { + scenario: QueryProfileDiffScenario; + query_a: QueryProfileDiffQuerySummary; + query_b: QueryProfileDiffQuerySummary; + plan_comparison: QueryProfileDiffPlanComparison; + operator_diffs: QueryProfileDiffOperatorDelta[]; + warnings?: string[]; +} +``` + +## V1 Semantics + +- `plans_equal` means a structural match: topology plus ordered operator + type/name signatures match, ignoring run-specific IDs. +- `operator_diffs` contains matched operator pairs for equal plans. +- Numeric stats include `delta` and optional `percent_delta`; non-numeric or + missing values use `delta: null`. +- Different-plan aggregate rows and timeline deltas are planned follow-ups. diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 3199649e..cc8a75ec 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -16,6 +16,7 @@ import type { EntityRef, Engine, } from '@quent/utils'; +import type { QueryProfileDiffRequest, QueryProfileDiffResponse } from './queryProfileDiffTypes'; interface ApiFetchOptions { params?: Record; @@ -104,3 +105,15 @@ export async function fetchBulkTimelines( }, }); } + +export async function fetchQueryProfileDiff( + engineId: string, + request: QueryProfileDiffRequest +): Promise { + return apiFetch(`/engines/${engineId}/query-profile-diff`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); +} diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index 937b8538..90245e12 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -13,6 +13,7 @@ export { fetchListQueries, fetchSingleTimeline, fetchBulkTimelines, + fetchQueryProfileDiff, } from './api'; // queryOptions factories @@ -22,6 +23,7 @@ export { queryGroupsQueryOptions } from './queryGroups'; export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; +export { queryProfileDiffQueryOptions } from './queryProfileDiff'; // Hooks export { useQueryBundle } from './queryBundle'; @@ -29,3 +31,15 @@ export { useEngines } from './engines'; export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; +export { useQueryProfileDiff } from './queryProfileDiff'; + +export type { + QueryProfileDiffOperatorDelta, + QueryProfileDiffOperatorRef, + QueryProfileDiffPlanComparison, + QueryProfileDiffQuerySummary, + QueryProfileDiffRequest, + QueryProfileDiffResponse, + QueryProfileDiffScenario, + QueryProfileDiffStatDelta, +} from './queryProfileDiff'; diff --git a/ui/packages/@quent/client/src/queryProfileDiff.test.ts b/ui/packages/@quent/client/src/queryProfileDiff.test.ts new file mode 100644 index 00000000..5a89ef0d --- /dev/null +++ b/ui/packages/@quent/client/src/queryProfileDiff.test.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { queryProfileDiffQueryOptions } from './queryProfileDiff'; + +describe('queryProfileDiffQueryOptions', () => { + it('builds a stable key from engine and query ids', () => { + const options = queryProfileDiffQueryOptions({ + engineId: 'engine-1', + request: { query_a_id: 'query-a', query_b_id: 'query-b' }, + }); + + expect(options.queryKey).toEqual(['queryProfileDiff', 'engine-1', 'query-a', 'query-b']); + }); +}); diff --git a/ui/packages/@quent/client/src/queryProfileDiff.ts b/ui/packages/@quent/client/src/queryProfileDiff.ts new file mode 100644 index 00000000..fa0f806e --- /dev/null +++ b/ui/packages/@quent/client/src/queryProfileDiff.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { fetchQueryProfileDiff } from './api'; +import { DEFAULT_STALE_TIME } from './constants'; +import type { QueryProfileDiffRequest, QueryProfileDiffResponse } from './queryProfileDiffTypes'; + +interface QueryProfileDiffParams { + engineId: string; + request: QueryProfileDiffRequest; +} + +export const queryProfileDiffQueryOptions = ( + { engineId, request }: QueryProfileDiffParams, + options?: { staleTime?: number } +) => + queryOptions({ + queryKey: ['queryProfileDiff', engineId, request.query_a_id, request.query_b_id], + queryFn: (): Promise => fetchQueryProfileDiff(engineId, request), + staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, + enabled: Boolean(engineId && request.query_a_id && request.query_b_id), + }); + +export const useQueryProfileDiff = ( + params: QueryProfileDiffParams, + options?: { staleTime?: number } +) => useQuery(queryProfileDiffQueryOptions(params, options)); + +export type { + QueryProfileDiffOperatorDelta, + QueryProfileDiffOperatorRef, + QueryProfileDiffPlanComparison, + QueryProfileDiffQuerySummary, + QueryProfileDiffRequest, + QueryProfileDiffResponse, + QueryProfileDiffScenario, + QueryProfileDiffStatDelta, +} from './queryProfileDiffTypes'; diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts new file mode 100644 index 00000000..b78f956f --- /dev/null +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StatValue } from '@quent/utils'; + +export interface QueryProfileDiffRequest { + query_a_id: string; + query_b_id: string; +} + +export type QueryProfileDiffScenario = 'plans_equal' | 'plans_different' | 'plans_incomparable'; + +export interface QueryProfileDiffQuerySummary { + id: string; + instance_name: string | null; + query_group_id?: string | null; + query_group_name?: string | null; +} + +export interface QueryProfileDiffOperatorRef { + id: string; + label: string; + operator_type_name: string | null; + plan_id: string | null; +} + +export interface QueryProfileDiffStatDelta { + a: StatValue; + b: StatValue; + delta: number | null; + percent_delta: number | null; +} + +export interface QueryProfileDiffOperatorDelta { + operator_a: QueryProfileDiffOperatorRef | null; + operator_b: QueryProfileDiffOperatorRef | null; + stats: Record; +} + +export interface QueryProfileDiffPlanComparison { + match_kind: 'structural' | 'different' | 'incomparable'; + matched_operator_count: number; + unmatched_operator_a_count: number; + unmatched_operator_b_count: number; +} + +export interface QueryProfileDiffResponse { + scenario: QueryProfileDiffScenario; + query_a: QueryProfileDiffQuerySummary; + query_b: QueryProfileDiffQuerySummary; + plan_comparison: QueryProfileDiffPlanComparison; + operator_diffs: QueryProfileDiffOperatorDelta[]; + warnings?: string[]; +} diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index c2c4da1e..8fe46c97 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -158,7 +158,7 @@ function GroupCell({ } function DataCell({ row, stat }: DataCellProps) { - const { display, interaction, derived } = usePivotTableRenderContext(); + const { display, interaction, derived, renderConfig } = usePivotTableRenderContext(); const numVal = getSortValue(row, stat, display.isAggregating, display.aggMode); const range = derived.columnRanges.get(stat); const bg = @@ -176,6 +176,29 @@ function DataCell({ row, stat }: DataCellProps) { const rowHighlight = isRowHighlightedFromTable || isRowHighlightedFromDag ? HIGHLIGHT_WASH : undefined; const cellHighlight = rowHighlight ?? colHighlight; + const rawValue = display.isAggregating + ? (row.aggs.get(stat)?.[display.aggMode as Exclude] ?? null) + : (row.values.get(stat) ?? null); + const customStyle = renderConfig.getDataCellStyle?.({ + row, + stat, + value: rawValue, + numericValue: numVal, + isAggregating: display.isAggregating, + aggMode: display.aggMode, + }); + const cellStyle: React.CSSProperties = { + backgroundColor: bg, + ...customStyle, + boxShadow: cellHighlight, + }; + const customContent = renderConfig.formatDataCellValue?.({ + stat, + value: rawValue, + numericValue: numVal, + isAggregating: display.isAggregating, + aggMode: display.aggMode, + }); const statCellProps = { onMouseEnter: () => interaction.setHoveredStat(derived.buildHoveredStatInfo(stat)), onMouseLeave: () => interaction.setHoveredStat(null), @@ -185,10 +208,10 @@ function DataCell({ row, stat }: DataCellProps) { return ( - {formatStatValue(val, stat)} + {customContent ?? formatStatValue(val, stat)} ); } @@ -200,7 +223,7 @@ function DataCell({ row, stat }: DataCellProps) { style={{ boxShadow: cellHighlight }} {...statCellProps} > - - + {customContent ?? '-'} ); } @@ -208,10 +231,10 @@ function DataCell({ row, stat }: DataCellProps) { return ( - {formatNumericStat(displayVal, stat)} + {customContent ?? formatNumericStat(displayVal, stat)} ); } @@ -281,6 +304,8 @@ export function PivotedStatTable({ const effectiveRenderConfig = useMemo( (): PivotTableRenderConfig => ({ getGroupTypeColor: renderConfig?.getGroupTypeColor, + getDataCellStyle: renderConfig?.getDataCellStyle, + formatDataCellValue: renderConfig?.formatDataCellValue, }), [renderConfig] ); diff --git a/ui/packages/@quent/components/src/pivot-table/types.ts b/ui/packages/@quent/components/src/pivot-table/types.ts index bdac9a9c..4b119491 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -69,6 +69,21 @@ export interface PivotTableInteractionConfig string | undefined; + getDataCellStyle?: (args: { + row: PivotedRow; + stat: string; + value: StatValue; + numericValue: number | null; + isAggregating: boolean; + aggMode: AggMode; + }) => React.CSSProperties | undefined; + formatDataCellValue?: (args: { + stat: string; + value: StatValue; + numericValue: number | null; + isAggregating: boolean; + aggMode: AggMode; + }) => React.ReactNode; } export interface PivotTableDnDConfig { diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx new file mode 100644 index 00000000..e25bf222 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + buildQueryDiffRows, + formatSignedDiffValue, + getDeltaCellStyle, +} from './QueryDiffTable.utils'; +import { equalPlanQueryProfileDiffFixture } from '@/test/mocks/queryProfileDiffFixtures'; + +describe('QueryDiffTable helpers', () => { + it('converts matched operator diffs into pivot rows', () => { + const rows = buildQueryDiffRows(equalPlanQueryProfileDiffFixture); + + expect(rows).toHaveLength(3); + expect(rows[0]).toMatchObject({ + operatorType: 'Scan', + operatorLabel: 'Scan orders (scan-a) / Scan orders (scan-b)', + stats: { + duration_s: 2, + input_rows: -200, + }, + }); + }); + + it('formats numeric deltas with signs', () => { + expect(formatSignedDiffValue(12)).toBe('+12'); + expect(formatSignedDiffValue(-12)).toBe('-12'); + expect(formatSignedDiffValue(0)).toBe('0'); + expect(formatSignedDiffValue(null)).toBeNull(); + }); + + it('returns diverging styles for positive and negative deltas only', () => { + expect(getDeltaCellStyle(5, 10)?.backgroundColor).toContain('#14b8a6'); + expect(getDeltaCellStyle(-5, 10)?.backgroundColor).toContain('#ef4444'); + expect(getDeltaCellStyle(0, 10)).toBeUndefined(); + expect(getDeltaCellStyle(null, 10)).toBeUndefined(); + }); +}); diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx new file mode 100644 index 00000000..790016d7 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useState } from 'react'; +import { + DataText, + PivotedStatTable, + PivotTableToolbar, + getSchemaStatNames, + type HoveredStatInfo, + type PivotedRow, + type PivotedStatTableSchema, + type PivotTableInteractionConfig, + type PivotTableRenderConfig, +} from '@quent/components'; +import { useStatGroupTableControls } from '@quent/hooks'; +import type { QueryProfileDiffResponse } from '@quent/client'; +import { getOperationTypeColor } from '@quent/utils'; +import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; +import { + buildMaxAbsByStat, + buildQueryDiffRows, + formatSignedDiffValue, + getDeltaCellStyle, + type QueryDiffTableRow, +} from './QueryDiffTable.utils'; + +type IndexKey = 'operator_type' | 'operator'; + +const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { + groups: { + operator_type: { + id: row => row.operatorType, + }, + operator: { + id: row => row.operatorPairId, + label: row => row.operatorLabel, + }, + }, + itemId: row => row.operatorPairId, + scopeId: row => row.operatorType, + itemType: row => row.operatorType, + stats: row => row.stats, +}; + +const INDEX_ORDER: IndexKey[] = ['operator_type', 'operator']; + +const DEFAULT_ENABLED: Record = { + operator_type: true, + operator: true, +}; + +const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; + +const getOperatorTypeColor = (key: string, id: string): string | undefined => + key === 'operator_type' ? getOperationTypeColor(id.toLowerCase()) : undefined; + +export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { + const rows = useMemo(() => buildQueryDiffRows(diff), [diff]); + const allStatNames = useMemo(() => getSchemaStatNames(rows, DIFF_TABLE_SCHEMA), [rows]); + const maxAbsByStat = useMemo(() => buildMaxAbsByStat(rows), [rows]); + const [hoveredStat, setHoveredStat] = useState(null); + const { theme } = useTheme(); + const isDark = theme === THEME_DARK; + + const { + aggMode, + setAggMode, + selectedStats, + orderedStatNames, + visibleStats, + visibleIndexOrder, + activeIndexKeys, + isAggregating, + enabledIndices, + handleToggleIndex, + handleReorderIndex, + handleToggleStat, + handleSelectAllStats, + handleSelectNoStats, + sorting, + setSorting, + } = useStatGroupTableControls({ + baseIndexOrder: INDEX_ORDER, + defaultEnabled: DEFAULT_ENABLED, + allStatNames, + defaultStatSelector: stats => stats, + persistKey: 'queryDiffTable', + rows, + getRowIndexId: (row, key) => DIFF_TABLE_SCHEMA.groups[key].id(row), + }); + + const indexLabels: Record = useMemo( + () => ({ + operator_type: 'Operator Type', + operator: 'Operator Pair', + }), + [] + ); + + const indexConfig = useMemo( + () => + visibleIndexOrder.map(key => ({ + key, + label: indexLabels[key], + enabled: enabledIndices[key], + })), + [enabledIndices, indexLabels, visibleIndexOrder] + ); + + const interactionConfig = useMemo( + (): PivotTableInteractionConfig => ({ + hoveredStat, + setHoveredStat, + selectedItemIds: new Set(), + }), + [hoveredStat] + ); + + const renderConfig = useMemo( + (): PivotTableRenderConfig => ({ + getGroupTypeColor: getOperatorTypeColor, + getDataCellStyle: ({ stat, value }) => getDeltaCellStyle(value, maxAbsByStat.get(stat)), + formatDataCellValue: ({ value }) => formatSignedDiffValue(value) ?? '-', + }), + [maxAbsByStat] + ); + + if (diff.scenario !== 'plans_equal') { + return ( +
+ {diff.warnings?.[0] ?? 'Plans are not structurally equal; operator diff is unavailable.'} +
+ ); + } + + if (rows.length === 0) { + return ( +
+ No matched operator deltas are available. +
+ ); + } + + return ( +
+
+
+ Operator Stat Deltas +
+
+ {diff.query_a.instance_name ?? diff.query_a.id} + {' minus '} + {diff.query_b.instance_name ?? diff.query_b.id} +
+
+
+ +
+
+ +
+
+ ); +} diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts new file mode 100644 index 00000000..e942d885 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { QueryProfileDiffResponse } from '@quent/client'; +import type { StatValue } from '@quent/utils'; + +export interface QueryDiffTableRow { + operatorType: string; + operatorLabel: string; + operatorPairId: string; + stats: Record; +} + +function formatOperatorRef(label: string, id: string): string { + return `${label} (${id})`; +} + +export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTableRow[] { + return diff.operator_diffs.flatMap(entry => { + if (!entry.operator_a || !entry.operator_b) return []; + const operatorType = + entry.operator_a.operator_type_name ?? entry.operator_b.operator_type_name ?? '-'; + const operatorLabel = `${formatOperatorRef(entry.operator_a.label, entry.operator_a.id)} / ${formatOperatorRef(entry.operator_b.label, entry.operator_b.id)}`; + const stats = Object.fromEntries( + Object.entries(entry.stats).map(([statName, stat]) => [statName, stat.delta]) + ); + return [ + { + operatorType, + operatorLabel, + operatorPairId: `${entry.operator_a.id}:${entry.operator_b.id}`, + stats, + }, + ]; + }); +} + +export function formatSignedDiffValue(value: StatValue): string | null { + if (typeof value !== 'number') return null; + if (Object.is(value, -0) || value === 0) return '0'; + return value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString(); +} + +export function getDeltaCellStyle( + value: StatValue, + maxAbs: number | undefined +): React.CSSProperties | undefined { + if (typeof value !== 'number' || value === 0 || !maxAbs) return undefined; + const intensity = Math.min(1, Math.abs(value) / maxAbs); + const mix = Math.round(14 + intensity * 42); + const color = value > 0 ? '#14b8a6' : '#ef4444'; + return { + backgroundColor: `color-mix(in srgb, ${color} ${mix}%, hsl(var(--card)))`, + }; +} + +export function buildMaxAbsByStat(rows: QueryDiffTableRow[]): Map { + const result = new Map(); + for (const row of rows) { + for (const [stat, value] of Object.entries(row.stats)) { + if (typeof value !== 'number') continue; + result.set(stat, Math.max(result.get(stat) ?? 0, Math.abs(value))); + } + } + return result; +} diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx new file mode 100644 index 00000000..0c3b509b --- /dev/null +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { ChevronDown } from 'lucide-react'; +import { + fetchListCoordinators, + fetchListEngines, + fetchListQueries, + useQueryProfileDiff, +} from '@quent/client'; +import type { Query, QueryGroup } from '@quent/utils'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + DataText, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@quent/components'; +import { cn } from '@quent/utils'; +import { QueryDiffTable } from '@/components/query-diff/QueryDiffTable'; + +interface DiffSelectionPageProps { + initialEngineId?: string; + initialQueryAId?: string; + initialQueryBId?: string; +} + +interface QuerySideState { + groupId: string; + queryId: string; +} + +interface QuerySelectorColumnProps { + label: string; + side: QuerySideState; + queryGroups: QueryGroup[]; + queriesByGroup: Record; + disabled: boolean; + onGroupChange: (groupId: string) => void; + onQueryChange: (queryId: string) => void; +} + +const COMPACT_SELECT_TRIGGER_CLASS = + 'h-7 min-w-0 rounded px-2 py-1 text-xs [&_svg]:h-3 [&_svg]:w-3'; +const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; + +function findGroupForQuery( + queryId: string, + queriesByGroup: Record +): string | undefined { + if (!queryId) return undefined; + return Object.entries(queriesByGroup).find(([, queries]) => + queries.some(query => query.id === queryId) + )?.[0]; +} + +function queryLabel(query: Query): string { + return query.instance_name ?? query.id; +} + +function findQueryById( + queryId: string, + queriesByGroup: Record +): Query | undefined { + if (!queryId) return undefined; + return Object.values(queriesByGroup) + .flat() + .find(query => query.id === queryId); +} + +function queryDisplayLabel( + queryId: string, + queriesByGroup: Record, + emptyLabel: string +): string { + if (!queryId) return emptyLabel; + const query = findQueryById(queryId, queriesByGroup); + return query ? queryLabel(query) : queryId; +} + +function QuerySelectorColumn({ + label, + side, + queryGroups, + queriesByGroup, + disabled, + onGroupChange, + onQueryChange, +}: QuerySelectorColumnProps) { + const queries = side.groupId ? (queriesByGroup[side.groupId] ?? []) : []; + return ( +
+
+

{label}

+
+
+
+ + +
+
+ + +
+
+
+ ); +} + +export function DiffSelectionPage({ + initialEngineId = '', + initialQueryAId = '', + initialQueryBId = '', +}: DiffSelectionPageProps) { + const navigate = useNavigate(); + const [engineId, setEngineId] = useState(initialEngineId); + const [queryA, setQueryA] = useState({ groupId: '', queryId: initialQueryAId }); + const [queryB, setQueryB] = useState({ groupId: '', queryId: initialQueryBId }); + const [selectionOpen, setSelectionOpen] = useState( + !(initialEngineId && initialQueryAId && initialQueryBId && initialQueryAId !== initialQueryBId) + ); + + useEffect(() => { + setEngineId(initialEngineId); + setQueryA({ groupId: '', queryId: initialQueryAId }); + setQueryB({ groupId: '', queryId: initialQueryBId }); + setSelectionOpen( + !( + initialEngineId && + initialQueryAId && + initialQueryBId && + initialQueryAId !== initialQueryBId + ) + ); + }, [initialEngineId, initialQueryAId, initialQueryBId]); + + const { data: engines = [] } = useQuery({ + queryKey: ['list_engines'], + queryFn: fetchListEngines, + }); + + const { data: queryGroups = [] } = useQuery({ + queryKey: ['list_coordinators', engineId], + queryFn: () => fetchListCoordinators(engineId), + enabled: Boolean(engineId), + }); + + const { data: queriesByGroup = {}, isLoading: queriesLoading } = useQuery({ + queryKey: ['diff_queries_by_group', engineId, queryGroups.map(group => group.id).join('\0')], + queryFn: async () => { + const entries = await Promise.all( + queryGroups.map( + async group => [group.id, await fetchListQueries(engineId, group.id)] as const + ) + ); + return Object.fromEntries(entries); + }, + enabled: Boolean(engineId && queryGroups.length > 0), + }); + + useEffect(() => { + setQueryA(prev => { + if (prev.groupId || !prev.queryId) return prev; + const groupId = findGroupForQuery(prev.queryId, queriesByGroup); + return groupId ? { ...prev, groupId } : prev; + }); + setQueryB(prev => { + if (prev.groupId || !prev.queryId) return prev; + const groupId = findGroupForQuery(prev.queryId, queriesByGroup); + return groupId ? { ...prev, groupId } : prev; + }); + }, [queriesByGroup]); + + const selectedEngine = useMemo( + () => engines.find(engine => engine.id === engineId), + [engineId, engines] + ); + const engineSummary = selectedEngine?.instance_name ?? selectedEngine?.id ?? engineId; + const queryASummary = useMemo( + () => queryDisplayLabel(queryA.queryId, queriesByGroup, 'Select Query A'), + [queryA.queryId, queriesByGroup] + ); + const queryBSummary = useMemo( + () => queryDisplayLabel(queryB.queryId, queriesByGroup, 'Select Query B'), + [queryB.queryId, queriesByGroup] + ); + const sameQuerySelected = Boolean(queryA.queryId && queryA.queryId === queryB.queryId); + const canDiff = Boolean(engineId && queryA.queryId && queryB.queryId && !sameQuerySelected); + + const diffQuery = useQueryProfileDiff({ + engineId, + request: { + query_a_id: queryA.queryId, + query_b_id: sameQuerySelected ? '' : queryB.queryId, + }, + }); + + const maybeNavigateToDiff = ( + nextEngineId: string, + nextA: QuerySideState, + nextB: QuerySideState + ) => { + if (!nextEngineId || !nextA.queryId || !nextB.queryId || nextA.queryId === nextB.queryId) { + return; + } + navigate({ + to: '/diff/engine/$engineId/query/$queryAId/compare/$queryBId', + params: { + engineId: nextEngineId, + queryAId: nextA.queryId, + queryBId: nextB.queryId, + }, + }); + }; + + const handleEngineChange = (nextEngineId: string) => { + setEngineId(nextEngineId); + setQueryA({ groupId: '', queryId: '' }); + setQueryB({ groupId: '', queryId: '' }); + setSelectionOpen(true); + navigate({ to: '/diff' }); + }; + + const handleGroupChange = (side: 'a' | 'b', groupId: string) => { + setSelectionOpen(true); + if (side === 'a') { + setQueryA({ groupId, queryId: '' }); + } else { + setQueryB({ groupId, queryId: '' }); + } + }; + + const handleQueryChange = (side: 'a' | 'b', queryId: string) => { + if (side === 'a') { + const nextA = { ...queryA, queryId }; + setQueryA(nextA); + maybeNavigateToDiff(engineId, nextA, queryB); + } else { + const nextB = { ...queryB, queryId }; + setQueryB(nextB); + maybeNavigateToDiff(engineId, queryA, nextB); + } + }; + + return ( +
+ +
+ + Query Diff + + + {queryASummary} + + vs + + {queryBSummary} + + {engineSummary && ( + <> + on + + {engineSummary} + + + )} + + + +
+ + +
+
+
+ + +
+
+ +
+ handleGroupChange('a', groupId)} + onQueryChange={queryId => handleQueryChange('a', queryId)} + /> + handleGroupChange('b', groupId)} + onQueryChange={queryId => handleQueryChange('b', queryId)} + /> +
+
+
+
+ +
+
+ {!engineId ? ( +
+ Select an engine to compare queries. +
+ ) : sameQuerySelected ? ( +
Choose two different queries.
+ ) : !canDiff ? ( +
Select Query A and Query B.
+ ) : diffQuery.isLoading ? ( +
+ Loading diff... +
+ ) : diffQuery.error ? ( +
+ Failed to load diff +
+ ) : diffQuery.data ? ( + + ) : null} +
+
+
+ ); +} diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index a107e8f0..27c31b94 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -57,6 +57,20 @@ function RootComponent() { + + + + + diff --git a/ui/src/routes/diff.engine.$engineId.query.$queryAId.compare.$queryBId.tsx b/ui/src/routes/diff.engine.$engineId.query.$queryAId.compare.$queryBId.tsx new file mode 100644 index 00000000..ef89a30f --- /dev/null +++ b/ui/src/routes/diff.engine.$engineId.query.$queryAId.compare.$queryBId.tsx @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DiffSelectionPage } from '@/pages/DiffSelectionPage'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/diff/engine/$engineId/query/$queryAId/compare/$queryBId')({ + component: DiffComparison, +}); + +function DiffComparison() { + const { engineId, queryAId, queryBId } = Route.useParams(); + return ( + + ); +} diff --git a/ui/src/routes/diff.index.tsx b/ui/src/routes/diff.index.tsx new file mode 100644 index 00000000..b449f7cb --- /dev/null +++ b/ui/src/routes/diff.index.tsx @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DiffSelectionPage } from '@/pages/DiffSelectionPage'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/diff/')({ + component: DiffIndex, +}); + +function DiffIndex() { + return ; +} diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx new file mode 100644 index 00000000..eab45837 --- /dev/null +++ b/ui/src/routes/diff.test.tsx @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { screen, renderWithRouter } from '@/test/test-utils'; +import { server } from '@/test/mocks/server'; +import { equalPlanQueryProfileDiffFixture } from '@/test/mocks/queryProfileDiffFixtures'; + +const API_BASE = 'http://localhost:8000/api'; + +describe('Diff routes', () => { + beforeEach(() => { + server.use( + http.get(`${API_BASE}/engines`, () => + HttpResponse.json([{ id: 'engine-1', instance_name: 'Engine 1' }]) + ), + http.get(`${API_BASE}/engines/:engineId/query-groups`, () => + HttpResponse.json([ + { id: 'group-a', instance_name: 'Group A', engine_id: 'engine-1' }, + { id: 'group-b', instance_name: 'Group B', engine_id: 'engine-1' }, + ]) + ), + http.get(`${API_BASE}/engines/:engineId/query_group/:queryGroupId/queries`, ({ params }) => { + const queryGroupId = String(params.queryGroupId); + return HttpResponse.json( + queryGroupId === 'group-a' + ? [ + { + id: 'query-a', + query_group_id: 'group-a', + instance_name: 'Query A', + start_unix_ns: null, + planning_s: null, + executing_s: null, + completed_s: null, + }, + ] + : [ + { + id: 'query-b', + query_group_id: 'group-b', + instance_name: 'Query B', + start_unix_ns: null, + planning_s: null, + executing_s: null, + completed_s: null, + }, + ] + ); + }), + http.post(`${API_BASE}/engines/:engineId/query-profile-diff`, () => + HttpResponse.json(equalPlanQueryProfileDiffFixture) + ) + ); + }); + + it('renders the top-level diff selection route', async () => { + renderWithRouter({ initialPath: '/diff' }); + + expect(await screen.findByText('Query A')).toBeInTheDocument(); + expect(screen.getByText('Query B')).toBeInTheDocument(); + expect(screen.getByText('Select an engine to compare queries.')).toBeInTheDocument(); + }); + + it('renders a selected comparison route', async () => { + renderWithRouter({ + initialPath: '/diff/engine/engine-1/query/query-a/compare/query-b', + }); + + expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); + expect(screen.getAllByText(/Query A/).length).toBeGreaterThan(0); + }); + + it('does not render a diff for the same query on both sides', async () => { + renderWithRouter({ + initialPath: '/diff/engine/engine-1/query/query-a/compare/query-a', + }); + + expect(await screen.findByText('Choose two different queries.')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/routes/diff.tsx b/ui/src/routes/diff.tsx new file mode 100644 index 00000000..00850c6e --- /dev/null +++ b/ui/src/routes/diff.tsx @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createFileRoute, Outlet } from '@tanstack/react-router'; + +export const Route = createFileRoute('/diff')({ + component: DiffLayout, +}); + +function DiffLayout() { + return ; +} diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index b7d3b127..3feaff15 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { http, HttpResponse } from 'msw'; +import { + differentPlanQueryProfileDiffFixture, + equalPlanQueryProfileDiffFixture, +} from './queryProfileDiffFixtures'; /** * Default MSW handlers for mocking API responses @@ -48,4 +52,16 @@ export const handlers = [ }, }); }), + + http.post('/api/engines/:engineId/query-profile-diff', async ({ request }) => { + const body = (await request.json()) as { query_a_id?: string; query_b_id?: string }; + if (body.query_a_id?.includes('different') || body.query_b_id?.includes('different')) { + return HttpResponse.json(differentPlanQueryProfileDiffFixture); + } + return HttpResponse.json({ + ...equalPlanQueryProfileDiffFixture, + query_a: { ...equalPlanQueryProfileDiffFixture.query_a, id: body.query_a_id ?? 'query-a' }, + query_b: { ...equalPlanQueryProfileDiffFixture.query_b, id: body.query_b_id ?? 'query-b' }, + }); + }), ]; diff --git a/ui/src/test/mocks/queryProfileDiffFixtures.ts b/ui/src/test/mocks/queryProfileDiffFixtures.ts new file mode 100644 index 00000000..c4c45876 --- /dev/null +++ b/ui/src/test/mocks/queryProfileDiffFixtures.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { QueryProfileDiffResponse } from '@quent/client'; + +export const equalPlanQueryProfileDiffFixture: QueryProfileDiffResponse = { + scenario: 'plans_equal', + query_a: { + id: 'query-a', + instance_name: 'Query A', + query_group_id: 'group-1', + query_group_name: 'Group 1', + }, + query_b: { + id: 'query-b', + instance_name: 'Query B', + query_group_id: 'group-2', + query_group_name: 'Group 2', + }, + plan_comparison: { + match_kind: 'structural', + matched_operator_count: 3, + unmatched_operator_a_count: 0, + unmatched_operator_b_count: 0, + }, + operator_diffs: [ + { + operator_a: { + id: 'scan-a', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-a', + }, + operator_b: { + id: 'scan-b', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 12, b: 10, delta: 2, percent_delta: 0.2 }, + input_rows: { a: 1000, b: 1200, delta: -200, percent_delta: -0.1666666667 }, + output_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, + }, + }, + { + operator_a: { + id: 'join-a', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-a', + }, + operator_b: { + id: 'join-b', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 24, b: 30, delta: -6, percent_delta: -0.2 }, + input_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, + output_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, + }, + }, + { + operator_a: { + id: 'agg-a', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-a', + }, + operator_b: { + id: 'agg-b', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 4, b: 4, delta: 0, percent_delta: 0 }, + input_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, + output_rows: { a: 20, b: 20, delta: 0, percent_delta: 0 }, + }, + }, + ], +}; + +export const differentPlanQueryProfileDiffFixture: QueryProfileDiffResponse = { + ...equalPlanQueryProfileDiffFixture, + scenario: 'plans_different', + plan_comparison: { + match_kind: 'different', + matched_operator_count: 0, + unmatched_operator_a_count: 3, + unmatched_operator_b_count: 4, + }, + operator_diffs: [], + warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], +}; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 99e9e171..df977b94 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,12 +3,15 @@ import path from 'path'; import { defineConfig } from 'vite'; +import type { Plugin } from 'vite'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; import { visualizer } from 'rollup-plugin-visualizer'; import tailwindcss from '@tailwindcss/vite'; const API_TARGET = process.env.VITE_API_TARGET || 'http://localhost:8080'; +const ENABLE_DIFF_MOCK_API = process.env.VITE_DIFF_MOCK_API !== 'false'; /** Ensures JS chunks get high fetch priority so they load before competing API requests. */ function vitePluginScriptPriority() { @@ -30,11 +33,182 @@ function vitePluginScriptPriority() { }; } +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + }); +} + +function createMockQueryProfileDiffResponse(queryAId: string, queryBId: string) { + const different = queryAId.includes('different') || queryBId.includes('different'); + const base = { + query_a: { + id: queryAId, + instance_name: queryAId, + query_group_id: null, + query_group_name: null, + }, + query_b: { + id: queryBId, + instance_name: queryBId, + query_group_id: null, + query_group_name: null, + }, + }; + + if (different) { + return { + scenario: 'plans_different', + ...base, + plan_comparison: { + match_kind: 'different', + matched_operator_count: 0, + unmatched_operator_a_count: 3, + unmatched_operator_b_count: 4, + }, + operator_diffs: [], + warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], + }; + } + + return { + scenario: 'plans_equal', + ...base, + plan_comparison: { + match_kind: 'structural', + matched_operator_count: 3, + unmatched_operator_a_count: 0, + unmatched_operator_b_count: 0, + }, + operator_diffs: [ + { + operator_a: { + id: 'scan-a', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-a', + }, + operator_b: { + id: 'scan-b', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 12, b: 10, delta: 2, percent_delta: 0.2 }, + input_rows: { a: 1000, b: 1200, delta: -200, percent_delta: -0.1666666667 }, + output_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, + }, + }, + { + operator_a: { + id: 'join-a', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-a', + }, + operator_b: { + id: 'join-b', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 24, b: 30, delta: -6, percent_delta: -0.2 }, + input_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, + output_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, + }, + }, + { + operator_a: { + id: 'agg-a', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-a', + }, + operator_b: { + id: 'agg-b', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 4, b: 4, delta: 0, percent_delta: 0 }, + input_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, + output_rows: { a: 20, b: 20, delta: 0, percent_delta: 0 }, + }, + }, + ], + }; +} + +function vitePluginQueryProfileDiffMock(): Plugin { + const diffPath = /^\/api\/engines\/[^/]+\/query-profile-diff(?:\?.*)?$/; + return { + name: 'vite-plugin-query-profile-diff-mock', + configureServer(server) { + if (!ENABLE_DIFF_MOCK_API) return; + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => { + if (req.method !== 'POST' || !req.url || !diffPath.test(req.url)) { + next(); + return; + } + void readRequestBody(req) + .then(bodyText => { + const body = JSON.parse(bodyText || '{}') as { + query_a_id?: string; + query_b_id?: string; + }; + const response = createMockQueryProfileDiffResponse( + body.query_a_id ?? 'query-a', + body.query_b_id ?? 'query-b' + ); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(response)); + }) + .catch(next); + }); + }, + configurePreviewServer(server) { + if (!ENABLE_DIFF_MOCK_API) return; + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => { + if (req.method !== 'POST' || !req.url || !diffPath.test(req.url)) { + next(); + return; + } + void readRequestBody(req) + .then(bodyText => { + const body = JSON.parse(bodyText || '{}') as { + query_a_id?: string; + query_b_id?: string; + }; + const response = createMockQueryProfileDiffResponse( + body.query_a_id ?? 'query-a', + body.query_b_id ?? 'query-b' + ); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(response)); + }) + .catch(next); + }); + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), vitePluginScriptPriority(), + vitePluginQueryProfileDiffMock(), TanStackRouterVite({ routeFileIgnorePattern: '.test.|.spec.', }), From 49e7e980ebb8cea60def995ff9115153a9cf79e4 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 09:48:47 -0600 Subject: [PATCH 02/33] POC timelines and real table comparisons --- .../query_engine/query_profile_diff.md | 7 +- .../src/pivot-table/PivotedStatTable.tsx | 3 +- .../components/src/pivot-table/types.ts | 4 + .../query-diff/QueryDiffTable.test.tsx | 19 +- .../components/query-diff/QueryDiffTable.tsx | 35 +- .../query-diff/QueryDiffTable.utils.ts | 33 +- .../query-diff/QueryDiffTimeline.tsx | 384 ++++++++++++++++++ .../QueryDiffTimeline.utils.test.ts | 43 ++ .../query-diff/QueryDiffTimeline.utils.ts | 147 +++++++ .../queryProfileDiffFromBundles.test.ts | 99 +++++ .../query-diff/queryProfileDiffFromBundles.ts | 169 ++++++++ ui/src/index.css | 28 ++ ui/src/pages/DiffSelectionPage.tsx | 50 ++- ui/src/routes/diff.test.tsx | 112 ++++- ui/src/test/mocks/handlers.ts | 16 - ui/vite.config.ts | 174 -------- 16 files changed, 1096 insertions(+), 227 deletions(-) create mode 100644 ui/src/components/query-diff/QueryDiffTimeline.tsx create mode 100644 ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts create mode 100644 ui/src/components/query-diff/QueryDiffTimeline.utils.ts create mode 100644 ui/src/components/query-diff/queryProfileDiffFromBundles.test.ts create mode 100644 ui/src/components/query-diff/queryProfileDiffFromBundles.ts diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md index f299e657..42edc407 100644 --- a/docs/domains/query_engine/query_profile_diff.md +++ b/docs/domains/query_engine/query_profile_diff.md @@ -1,9 +1,8 @@ # Query Profile Diff API The query profile diff API compares two query profiles from the same engine. -The UI uses this contract through a mock endpoint first; generated TypeScript -bindings should replace the temporary client-side types once the Rust endpoint -exports these shapes. +The UI also uses this contract as its internal diff view model when it builds +query diffs client-side from real `QueryBundle` API responses. ## Endpoint @@ -81,4 +80,4 @@ export interface QueryProfileDiffResponse { - `operator_diffs` contains matched operator pairs for equal plans. - Numeric stats include `delta` and optional `percent_delta`; non-numeric or missing values use `delta: null`. -- Different-plan aggregate rows and timeline deltas are planned follow-ups. +- Different-plan aggregate rows are a planned follow-up. diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index 8fe46c97..929f8ce7 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -103,6 +103,7 @@ function GroupCell({ }: GroupCellProps) { const { interaction, renderConfig } = usePivotTableRenderContext(); const typeColor = renderConfig.getGroupTypeColor?.(gk.key, gk.id); + const customContent = renderConfig.formatGroupCellValue?.({ groupKey: gk, row }); const handlers = interaction.groupCellHandlers?.(gk, row); const isRowHighlightedFromDag = interaction.hoveredItemId !== null && @@ -152,7 +153,7 @@ function GroupCell({ handlers?.onMouseLeave?.(); }} > - {gk.label} + {customContent ?? gk.label} ); } diff --git a/ui/packages/@quent/components/src/pivot-table/types.ts b/ui/packages/@quent/components/src/pivot-table/types.ts index 4b119491..b13ce39f 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -69,6 +69,10 @@ export interface PivotTableInteractionConfig string | undefined; + formatGroupCellValue?: (args: { + groupKey: GroupedDataTableGroupKeyEntry; + row: PivotedRow; + }) => React.ReactNode; getDataCellStyle?: (args: { row: PivotedRow; stat: string; diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index e25bf222..f84208db 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -16,7 +16,11 @@ describe('QueryDiffTable helpers', () => { expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ operatorType: 'Scan', - operatorLabel: 'Scan orders (scan-a) / Scan orders (scan-b)', + operatorLabel: 'Scan orders <-> Scan orders\nscan-a <-> scan-b', + operatorAId: 'scan-a', + operatorALabel: 'Scan orders', + operatorBId: 'scan-b', + operatorBLabel: 'Scan orders', stats: { duration_s: 2, input_rows: -200, @@ -25,10 +29,15 @@ describe('QueryDiffTable helpers', () => { }); it('formats numeric deltas with signs', () => { - expect(formatSignedDiffValue(12)).toBe('+12'); - expect(formatSignedDiffValue(-12)).toBe('-12'); - expect(formatSignedDiffValue(0)).toBe('0'); - expect(formatSignedDiffValue(null)).toBeNull(); + expect(formatSignedDiffValue(12, 'input_rows')).toBe('+12'); + expect(formatSignedDiffValue(-12, 'input_rows')).toBe('-12'); + expect(formatSignedDiffValue(0, 'input_rows')).toBe('0'); + expect(formatSignedDiffValue(null, 'input_rows')).toBe('-'); + }); + + it('uses the operator table stat formatter for delta values', () => { + expect(formatSignedDiffValue(1536, 'buffer_bytes')).toBe('+1.5 KiB'); + expect(formatSignedDiffValue(-0.125, 'probe_selectivity')).toBe('-12.5%'); }); it('returns diverging styles for positive and negative deltas only', () => { diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index 790016d7..e7407f6a 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -47,7 +47,7 @@ const INDEX_ORDER: IndexKey[] = ['operator_type', 'operator']; const DEFAULT_ENABLED: Record = { operator_type: true, - operator: true, + operator: false, }; const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; @@ -55,8 +55,32 @@ const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; const getOperatorTypeColor = (key: string, id: string): string | undefined => key === 'operator_type' ? getOperationTypeColor(id.toLowerCase()) : undefined; +function OperatorPairCell({ row }: { row: QueryDiffTableRow }) { + return ( +
+
+ {row.operatorALabel} + + {row.operatorAId} + +
+ {'<->'} +
+ {row.operatorBLabel} + + {row.operatorBId} + +
+
+ ); +} + export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { const rows = useMemo(() => buildQueryDiffRows(diff), [diff]); + const rowsByOperatorPairId = useMemo( + () => new Map(rows.map(row => [row.operatorPairId, row])), + [rows] + ); const allStatNames = useMemo(() => getSchemaStatNames(rows, DIFF_TABLE_SCHEMA), [rows]); const maxAbsByStat = useMemo(() => buildMaxAbsByStat(rows), [rows]); const [hoveredStat, setHoveredStat] = useState(null); @@ -120,10 +144,15 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { const renderConfig = useMemo( (): PivotTableRenderConfig => ({ getGroupTypeColor: getOperatorTypeColor, + formatGroupCellValue: ({ groupKey }) => { + if (groupKey.key !== 'operator') return groupKey.label; + const row = rowsByOperatorPairId.get(groupKey.id); + return row ? : groupKey.label; + }, getDataCellStyle: ({ stat, value }) => getDeltaCellStyle(value, maxAbsByStat.get(stat)), - formatDataCellValue: ({ value }) => formatSignedDiffValue(value) ?? '-', + formatDataCellValue: ({ stat, value }) => formatSignedDiffValue(value, stat), }), - [maxAbsByStat] + [maxAbsByStat, rowsByOperatorPairId] ); if (diff.scenario !== 'plans_equal') { diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index e942d885..49e570f7 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -3,16 +3,26 @@ import type { QueryProfileDiffResponse } from '@quent/client'; import type { StatValue } from '@quent/utils'; +import { formatStatValue } from '@quent/components'; export interface QueryDiffTableRow { operatorType: string; operatorLabel: string; operatorPairId: string; + operatorAId: string; + operatorALabel: string; + operatorBId: string; + operatorBLabel: string; stats: Record; } -function formatOperatorRef(label: string, id: string): string { - return `${label} (${id})`; +function formatOperatorPairLabel( + operatorALabel: string, + operatorAId: string, + operatorBLabel: string, + operatorBId: string +): string { + return `${operatorALabel} <-> ${operatorBLabel}\n${operatorAId} <-> ${operatorBId}`; } export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTableRow[] { @@ -20,7 +30,12 @@ export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTab if (!entry.operator_a || !entry.operator_b) return []; const operatorType = entry.operator_a.operator_type_name ?? entry.operator_b.operator_type_name ?? '-'; - const operatorLabel = `${formatOperatorRef(entry.operator_a.label, entry.operator_a.id)} / ${formatOperatorRef(entry.operator_b.label, entry.operator_b.id)}`; + const operatorLabel = formatOperatorPairLabel( + entry.operator_a.label, + entry.operator_a.id, + entry.operator_b.label, + entry.operator_b.id + ); const stats = Object.fromEntries( Object.entries(entry.stats).map(([statName, stat]) => [statName, stat.delta]) ); @@ -29,16 +44,20 @@ export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTab operatorType, operatorLabel, operatorPairId: `${entry.operator_a.id}:${entry.operator_b.id}`, + operatorAId: entry.operator_a.id, + operatorALabel: entry.operator_a.label, + operatorBId: entry.operator_b.id, + operatorBLabel: entry.operator_b.label, stats, }, ]; }); } -export function formatSignedDiffValue(value: StatValue): string | null { - if (typeof value !== 'number') return null; - if (Object.is(value, -0) || value === 0) return '0'; - return value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString(); +export function formatSignedDiffValue(value: StatValue, statName: string): string { + const formattedValue = formatStatValue(value, statName); + if (typeof value !== 'number' || Object.is(value, -0) || value === 0) return formattedValue; + return value > 0 ? `+${formattedValue}` : formattedValue; } export function getDeltaCellStyle( diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx new file mode 100644 index 00000000..95aea291 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -0,0 +1,384 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + DEFAULT_STALE_TIME, + fetchSingleTimeline, + type QueryProfileDiffResponse, +} from '@quent/client'; +import { + DataText, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Timeline, + TimelineController, + TimelineRuler, + collectResourceTypesFromTree, + transformResourceTree, + getAdaptiveNumBins, +} from '@quent/components'; +import { useSetDebouncedZoomRange, useSetZoomRange } from '@quent/hooks'; +import { + cn, + formatDuration, + type EntityRef, + type EntityRefKey, + type QueryBundle, + type QueryFilter, + type SingleTimelineRequest, + type TaskFilter, +} from '@quent/utils'; +import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; +import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; + +interface QueryDiffTimelineProps { + engineId: string; + diff: QueryProfileDiffResponse; + queryABundle: QueryBundle; + queryBBundle: QueryBundle; +} + +interface TimelineTarget { + rootResourceGroupId: string; + resourceTypes: string[]; +} + +const TIMELINE_ROW_HEIGHT = 44; +const TIMELINE_START = 0n; +const COMPACT_SELECT_TRIGGER_CLASS = 'h-7 min-w-36 rounded px-2 py-1 text-xs'; +const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; + +function getTimelineTarget(bundle: QueryBundle): TimelineTarget | null { + if (!('ResourceGroup' in bundle.resource_tree)) return null; + + const [, rootResourceGroupId] = Object.entries(bundle.resource_tree.ResourceGroup.id)[0] as [ + EntityRefKey, + string, + ]; + const rootItem = transformResourceTree(bundle.entities, bundle.resource_tree); + + return { + rootResourceGroupId, + resourceTypes: collectResourceTypesFromTree([rootItem]), + }; +} + +function getSharedResourceTypes(a: string[], b: string[]): string[] { + const bSet = new Set(b); + return a.filter(type => bSet.has(type)); +} + +function buildRootTimelineRequest({ + queryId, + rootResourceGroupId, + resourceTypeName, + durationSeconds, +}: { + queryId: string; + rootResourceGroupId: string; + resourceTypeName: string; + durationSeconds: number; +}): SingleTimelineRequest { + return { + entry: { + ResourceGroup: { + resource_group_id: rootResourceGroupId, + resource_type_name: resourceTypeName, + long_entities_threshold_s: null, + entity_filter: { entity_type_name: null }, + app_params: { operator_id: null }, + config: { + num_bins: getAdaptiveNumBins(), + start: 0, + end: durationSeconds, + }, + }, + }, + app_params: { query_id: queryId }, + }; +} + +function TimelineLane({ + label, + detail, + children, + className, +}: { + label: string; + detail?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {label} + {detail && {detail}} +
+
{children}
+
+ ); +} + +export function QueryDiffTimeline({ + engineId, + diff, + queryABundle, + queryBBundle, +}: QueryDiffTimelineProps) { + const { theme } = useTheme(); + const isDark = theme === THEME_DARK; + const paletteTheme = isDark ? 'dark' : 'light'; + const setZoomRange = useSetZoomRange(); + const setDebouncedZoomRange = useSetDebouncedZoomRange(); + + const queryAId = diff.query_a.id; + const queryBId = diff.query_b.id; + + const targetA = useMemo(() => getTimelineTarget(queryABundle), [queryABundle]); + const targetB = useMemo(() => getTimelineTarget(queryBBundle), [queryBBundle]); + const sharedResourceTypes = useMemo( + () => getSharedResourceTypes(targetA?.resourceTypes ?? [], targetB?.resourceTypes ?? []), + [targetA?.resourceTypes, targetB?.resourceTypes] + ); + const [resourceType, setResourceType] = useState(''); + + useEffect(() => { + if (sharedResourceTypes.length === 0) { + setResourceType(''); + return; + } + setResourceType(prev => (sharedResourceTypes.includes(prev) ? prev : sharedResourceTypes[0]!)); + }, [sharedResourceTypes]); + + const durationSeconds = Math.max(queryABundle.duration_s, queryBBundle.duration_s); + + useEffect(() => { + if (durationSeconds <= 0) return; + const full = { start: 0, end: durationSeconds }; + setZoomRange(full); + setDebouncedZoomRange(full); + }, [durationSeconds, queryAId, queryBId, setZoomRange, setDebouncedZoomRange]); + + const requestA = useMemo(() => { + if (!targetA || !resourceType) return null; + return buildRootTimelineRequest({ + queryId: queryABundle.query_id, + rootResourceGroupId: targetA.rootResourceGroupId, + resourceTypeName: resourceType, + durationSeconds: queryABundle.duration_s, + }); + }, [queryABundle.duration_s, queryABundle.query_id, resourceType, targetA]); + + const requestB = useMemo(() => { + if (!targetB || !resourceType) return null; + return buildRootTimelineRequest({ + queryId: queryBBundle.query_id, + rootResourceGroupId: targetB.rootResourceGroupId, + resourceTypeName: resourceType, + durationSeconds: queryBBundle.duration_s, + }); + }, [queryBBundle.duration_s, queryBBundle.query_id, resourceType, targetB]); + + const timelineA = useQuery({ + queryKey: [ + 'queryDiffTimeline', + 'a', + engineId, + queryAId, + targetA?.rootResourceGroupId, + resourceType, + queryABundle.duration_s, + ], + queryFn: () => fetchSingleTimeline(engineId, requestA!, queryABundle.duration_s), + enabled: Boolean(requestA && engineId), + staleTime: DEFAULT_STALE_TIME, + }); + + const timelineB = useQuery({ + queryKey: [ + 'queryDiffTimeline', + 'b', + engineId, + queryBId, + targetB?.rootResourceGroupId, + resourceType, + queryBBundle.duration_s, + ], + queryFn: () => fetchSingleTimeline(engineId, requestB!, queryBBundle.duration_s), + enabled: Boolean(requestB && engineId), + staleTime: DEFAULT_STALE_TIME, + }); + + const comparison = useMemo(() => { + if (!timelineA.data || !timelineB.data || durationSeconds <= 0) return null; + const resourceTypeDecl = + queryABundle.entities.resource_types[resourceType] ?? + queryBBundle.entities.resource_types[resourceType]; + return buildDiffTimelineData({ + queryATimeline: timelineA.data, + queryBTimeline: timelineB.data, + durationSeconds, + theme: paletteTheme, + capacities: resourceTypeDecl?.capacities, + quantitySpecs: queryABundle.quantity_specs ?? queryBBundle.quantity_specs, + fsmTypes: queryABundle.entities.fsm_types ?? queryBBundle.entities.fsm_types, + }); + }, [ + durationSeconds, + paletteTheme, + queryABundle.entities.fsm_types, + queryABundle.entities.resource_types, + queryABundle.quantity_specs, + queryBBundle.entities.fsm_types, + queryBBundle.entities.resource_types, + queryBBundle.quantity_specs, + resourceType, + timelineA.data, + timelineB.data, + ]); + + const isLoading = Boolean(resourceType) && (timelineA.isLoading || timelineB.isLoading); + const hasError = timelineA.isError || timelineB.isError; + + return ( +
+
+
+
+ Timeline Delta +
+
+ + {diff.query_a.instance_name ?? queryAId} + + minus + + {diff.query_b.instance_name ?? queryBId} + + {durationSeconds > 0 && {formatDuration(durationSeconds * 1_000)}} +
+
+ +
+ {comparison && ( +
+ + A higher + + + B higher + +
+ )} + +
+
+ + {isLoading ? ( +
+ Loading timeline... +
+ ) : hasError ? ( +
+ Failed to load timeline delta +
+ ) : !resourceType || !targetA || !targetB ? ( +
+ No shared resource type available for timeline delta. +
+ ) : comparison ? ( +
+
+ { + setZoomRange(range); + setDebouncedZoomRange(range); + }} + isDark={isDark} + /> +
+
+
+ +
+ {diff.query_a.instance_name ?? queryAId}} + > + + + {diff.query_b.instance_name ?? queryBId}} + > + + + + + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts new file mode 100644 index 00000000..e4c017f8 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import type { SingleTimelineResponse } from '@quent/utils'; +import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; + +function makeTimeline(values: number[]): SingleTimelineResponse { + const config = { + span: { start: 0, end: values.length }, + bin_duration: 1, + num_bins: BigInt(values.length), + }; + + return { + config, + data: { + Binned: { + config, + capacities_values: { slots: values }, + long_fsms: [], + }, + }, + }; +} + +describe('buildDiffTimelineData', () => { + it('splits positive and negative aggregate deltas into direction series', () => { + const data = buildDiffTimelineData({ + queryATimeline: makeTimeline([3, 1]), + queryBTimeline: makeTimeline([1, 4]), + durationSeconds: 2, + theme: 'light', + }); + + expect(data.queryA.series.slots?.values).toEqual([3, 1]); + expect(data.queryB.series.slots?.values).toEqual([1, 4]); + expect(data.delta.series['Query A higher']?.values[0]).toBe(2); + expect(data.delta.series['Query B higher']?.values[0]).toBe(0); + expect(data.delta.series['Query A higher']?.values[150]).toBe(0); + expect(data.delta.series['Query B higher']?.values[150]).toBe(3); + }); +}); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts new file mode 100644 index 00000000..b3b5c7a4 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + buildBinnedTimelineSeries, + getAdaptiveNumBins, + type TimelineSeries, +} from '@quent/components'; +import type { + CapacityDecl, + FsmTypeDecl, + PaletteTheme, + QuantitySpec, + SingleTimelineResponse, +} from '@quent/utils'; + +const QUERY_A_HIGHER_COLOR = '#CC6677'; +const QUERY_B_HIGHER_COLOR = '#44AA99'; + +interface TimelineRowData { + timestamps: number[]; + series: TimelineSeries; +} + +export interface DiffTimelineData { + queryA: TimelineRowData; + queryB: TimelineRowData; + delta: TimelineRowData; +} + +interface BuildDiffTimelineDataParams { + queryATimeline: SingleTimelineResponse; + queryBTimeline: SingleTimelineResponse; + durationSeconds: number; + theme: PaletteTheme; + capacities?: CapacityDecl[]; + quantitySpecs?: { [key in string]?: QuantitySpec }; + fsmTypes?: { [key in string]?: FsmTypeDecl }; +} + +function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { + return ( + Object.values(seriesA).find(entry => entry.values.length > 0)?.formatter ?? + Object.values(seriesB).find(entry => entry.values.length > 0)?.formatter ?? + ((value: number) => String(value)) + ); +} + +function buildElapsedTimestamps(durationSeconds: number, numBins: number): number[] { + if (numBins <= 0) return []; + const binDurationMs = (durationSeconds * 1_000) / numBins; + return Array.from({ length: numBins }, (_, index) => index * binDurationMs); +} + +function sampleAggregateAt(series: TimelineSeries, timestamps: number[], targetTimestamp: number) { + const entries = Object.values(series); + if (entries.length === 0 || timestamps.length === 0) return 0; + + const firstTimestamp = timestamps[0] ?? 0; + const secondTimestamp = timestamps[1]; + const firstEntry = entries[0]; + const binDurationMs = + secondTimestamp != null + ? secondTimestamp - firstTimestamp + : Math.max(firstEntry?.binDuration ?? 0, 0) * 1_000; + + if (binDurationMs <= 0 || targetTimestamp < firstTimestamp) return 0; + + const index = Math.floor((targetTimestamp - firstTimestamp) / binDurationMs); + if (index < 0) return 0; + + return entries.reduce((sum, entry) => sum + (entry.values[index] ?? 0), 0); +} + +function buildDiffSeries({ + queryA, + queryB, + timestamps, + durationSeconds, +}: { + queryA: TimelineRowData; + queryB: TimelineRowData; + timestamps: number[]; + durationSeconds: number; +}): TimelineSeries { + const formatter = getFirstFormatter(queryA.series, queryB.series); + const deltas = timestamps.map(timestamp => { + const a = sampleAggregateAt(queryA.series, queryA.timestamps, timestamp); + const b = sampleAggregateAt(queryB.series, queryB.timestamps, timestamp); + return a - b; + }); + const binDuration = timestamps.length > 0 ? durationSeconds / timestamps.length : 0; + + return { + 'Query A higher': { + color: QUERY_A_HIGHER_COLOR, + binDuration, + formatter, + values: deltas.map(delta => Math.max(delta, 0)), + }, + 'Query B higher': { + color: QUERY_B_HIGHER_COLOR, + binDuration, + formatter, + values: deltas.map(delta => Math.max(-delta, 0)), + }, + }; +} + +export function buildDiffTimelineData({ + queryATimeline, + queryBTimeline, + durationSeconds, + theme, + capacities, + quantitySpecs, + fsmTypes, +}: BuildDiffTimelineDataParams): DiffTimelineData { + const queryA = buildBinnedTimelineSeries( + queryATimeline.data, + queryATimeline.config, + 0n, + theme, + capacities, + quantitySpecs, + fsmTypes + ); + const queryB = buildBinnedTimelineSeries( + queryBTimeline.data, + queryBTimeline.config, + 0n, + theme, + capacities, + quantitySpecs, + fsmTypes + ); + const timestamps = buildElapsedTimestamps(durationSeconds, getAdaptiveNumBins()); + + return { + queryA, + queryB, + delta: { + timestamps, + series: buildDiffSeries({ queryA, queryB, timestamps, durationSeconds }), + }, + }; +} diff --git a/ui/src/components/query-diff/queryProfileDiffFromBundles.test.ts b/ui/src/components/query-diff/queryProfileDiffFromBundles.test.ts new file mode 100644 index 00000000..f4be12f8 --- /dev/null +++ b/ui/src/components/query-diff/queryProfileDiffFromBundles.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import type { EntityRef, QueryBundle } from '@quent/utils'; +import { buildQueryProfileDiffFromBundles } from './queryProfileDiffFromBundles'; + +function makeBundle({ + queryId, + operatorId, + operatorType = 'Scan', + rows, + duration, +}: { + queryId: string; + operatorId: string; + operatorType?: string; + rows: number; + duration: number; +}): QueryBundle { + const planId = `plan-${queryId}`; + return { + query_id: queryId, + entities: { + engine: { id: 'engine-1', instance_name: 'Engine 1' }, + query_group: { id: `group-${queryId}`, instance_name: `Group ${queryId}` }, + query: { id: queryId, instance_name: queryId }, + workers: {}, + plans: { + [planId]: { + id: planId, + instance_name: 'Root plan', + parent: null, + worker_id: null, + edges: [], + }, + }, + operators: { + [operatorId]: { + id: operatorId, + plan_id: planId, + parent_operator_ids: [], + instance_name: 'Scan orders', + operator_type_name: operatorType, + custom_attributes: {}, + statistics: { custom_statistics: { input_rows: { Number: rows } } }, + active_span: { start: 0, end: duration }, + }, + }, + ports: {}, + resource_types: {}, + resource_group_types: {}, + resources: {}, + resource_groups: {}, + fsm_types: {}, + }, + resource_tree: { + ResourceGroup: { + id: { QueryGroup: `group-${queryId}` }, + children: [], + }, + }, + plan_tree: { id: planId, worker: null, children: [] }, + unique_operator_names: [], + quantity_specs: {}, + start_time_unix_ns: 0n, + duration_s: duration, + } as unknown as QueryBundle; +} + +describe('buildQueryProfileDiffFromBundles', () => { + it('builds operator stat deltas from real query bundle data', () => { + const diff = buildQueryProfileDiffFromBundles( + makeBundle({ queryId: 'query-a', operatorId: 'scan-a', rows: 100, duration: 12 }), + makeBundle({ queryId: 'query-b', operatorId: 'scan-b', rows: 80, duration: 10 }) + ); + + expect(diff.scenario).toBe('plans_equal'); + expect(diff.operator_diffs[0]?.stats.duration_s.delta).toBe(2); + expect(diff.operator_diffs[0]?.stats.input_rows.delta).toBe(20); + expect(diff.operator_diffs[0]?.stats.input_rows.percent_delta).toBe(0.25); + }); + + it('marks structurally different operator signatures as different plans', () => { + const diff = buildQueryProfileDiffFromBundles( + makeBundle({ queryId: 'query-a', operatorId: 'scan-a', rows: 100, duration: 12 }), + makeBundle({ + queryId: 'query-b', + operatorId: 'join-b', + operatorType: 'Join', + rows: 80, + duration: 10, + }) + ); + + expect(diff.scenario).toBe('plans_different'); + expect(diff.operator_diffs).toEqual([]); + }); +}); diff --git a/ui/src/components/query-diff/queryProfileDiffFromBundles.ts b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts new file mode 100644 index 00000000..fd94af9d --- /dev/null +++ b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseCustomStatistics } from '@quent/components'; +import type { + QueryProfileDiffOperatorDelta, + QueryProfileDiffOperatorRef, + QueryProfileDiffQuerySummary, + QueryProfileDiffResponse, + QueryProfileDiffStatDelta, +} from '@quent/client'; +import type { EntityRef, Operator, PlanTree, QueryBundle, StatValue } from '@quent/utils'; + +interface PlanSignature { + operators: string[]; + children: PlanSignature[]; +} + +function getOperatorsForPlan(bundle: QueryBundle, planId: string): Operator[] { + return Object.values(bundle.entities.operators) + .filter((operator): operator is Operator => operator != null && operator.plan_id === planId) + .sort((a, b) => { + const typeCompare = (a.operator_type_name ?? '').localeCompare(b.operator_type_name ?? ''); + if (typeCompare !== 0) return typeCompare; + const nameCompare = (a.instance_name ?? '').localeCompare(b.instance_name ?? ''); + if (nameCompare !== 0) return nameCompare; + return a.id.localeCompare(b.id); + }); +} + +function getOperatorSignature(operator: Operator): string { + return `${operator.operator_type_name ?? ''}:${operator.instance_name ?? ''}`; +} + +function getPlanSignature(bundle: QueryBundle, node: PlanTree): PlanSignature { + return { + operators: getOperatorsForPlan(bundle, node.id).map(getOperatorSignature), + children: node.children.map(child => getPlanSignature(bundle, child)), + }; +} + +function signaturesEqual(a: PlanSignature, b: PlanSignature): boolean { + if (a.operators.length !== b.operators.length || a.children.length !== b.children.length) { + return false; + } + if (a.operators.some((signature, index) => signature !== b.operators[index])) { + return false; + } + return a.children.every((child, index) => signaturesEqual(child, b.children[index]!)); +} + +function flattenOperatorsByPlanTree(bundle: QueryBundle, node: PlanTree): Operator[] { + return [ + ...getOperatorsForPlan(bundle, node.id), + ...node.children.flatMap(child => flattenOperatorsByPlanTree(bundle, child)), + ]; +} + +function countOperators(bundle: QueryBundle): number { + return Object.values(bundle.entities.operators).filter(Boolean).length; +} + +function getQuerySummary(bundle: QueryBundle): QueryProfileDiffQuerySummary { + return { + id: bundle.entities.query.id, + instance_name: bundle.entities.query.instance_name ?? null, + query_group_id: bundle.entities.query_group.id, + query_group_name: bundle.entities.query_group.instance_name ?? null, + }; +} + +function getOperatorRef(operator: Operator): QueryProfileDiffOperatorRef { + return { + id: operator.id, + label: operator.instance_name ?? operator.id, + operator_type_name: operator.operator_type_name ?? null, + plan_id: operator.plan_id ?? null, + }; +} + +function getOperatorStats(operator: Operator): Record { + const stats: Record = { + duration_s: operator.active_span + ? Number((operator.active_span.end - operator.active_span.start).toFixed(6)) + : null, + }; + + for (const stat of parseCustomStatistics(operator)) { + stats[stat.key] = stat.value; + } + + return stats; +} + +function buildStatDelta(a: StatValue, b: StatValue): QueryProfileDiffStatDelta { + const delta = typeof a === 'number' && typeof b === 'number' ? a - b : null; + return { + a, + b, + delta, + percent_delta: delta != null && typeof b === 'number' && b !== 0 ? delta / b : null, + }; +} + +function buildOperatorDelta( + operatorA: Operator, + operatorB: Operator +): QueryProfileDiffOperatorDelta { + const statsA = getOperatorStats(operatorA); + const statsB = getOperatorStats(operatorB); + const statNames = [...new Set([...Object.keys(statsA), ...Object.keys(statsB)])].sort(); + + return { + operator_a: getOperatorRef(operatorA), + operator_b: getOperatorRef(operatorB), + stats: Object.fromEntries( + statNames.map(statName => [ + statName, + buildStatDelta(statsA[statName] ?? null, statsB[statName] ?? null), + ]) + ), + }; +} + +export function buildQueryProfileDiffFromBundles( + queryA: QueryBundle, + queryB: QueryBundle +): QueryProfileDiffResponse { + const query_a = getQuerySummary(queryA); + const query_b = getQuerySummary(queryB); + + const signatureA = getPlanSignature(queryA, queryA.plan_tree); + const signatureB = getPlanSignature(queryB, queryB.plan_tree); + + if (!signaturesEqual(signatureA, signatureB)) { + return { + scenario: 'plans_different', + query_a, + query_b, + plan_comparison: { + match_kind: 'different', + matched_operator_count: 0, + unmatched_operator_a_count: countOperators(queryA), + unmatched_operator_b_count: countOperators(queryB), + }, + operator_diffs: [], + warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], + }; + } + + const operatorsA = flattenOperatorsByPlanTree(queryA, queryA.plan_tree); + const operatorsB = flattenOperatorsByPlanTree(queryB, queryB.plan_tree); + const matchedCount = Math.min(operatorsA.length, operatorsB.length); + + return { + scenario: 'plans_equal', + query_a, + query_b, + plan_comparison: { + match_kind: 'structural', + matched_operator_count: matchedCount, + unmatched_operator_a_count: operatorsA.length - matchedCount, + unmatched_operator_b_count: operatorsB.length - matchedCount, + }, + operator_diffs: operatorsA + .slice(0, matchedCount) + .map((operatorA, index) => buildOperatorDelta(operatorA, operatorsB[index]!)), + }; +} diff --git a/ui/src/index.css b/ui/src/index.css index 846a26a0..b017f98f 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -80,6 +80,8 @@ --radius-lg: var(--radius); --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-collapsible-down: collapsible-down 0.24s cubic-bezier(0.16, 1, 0.3, 1); + --animate-collapsible-up: collapsible-up 0.2s cubic-bezier(0.16, 1, 0.3, 1); } @keyframes accordion-down { @@ -100,6 +102,32 @@ } } +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + transform: translateY(-4px); + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + transform: translateY(0); + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + transform: translateY(0); + } + to { + height: 0; + opacity: 0; + transform: translateY(-4px); + } +} + @layer base { * { @apply border-border; diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 0c3b509b..7ec057f8 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -9,7 +9,7 @@ import { fetchListCoordinators, fetchListEngines, fetchListQueries, - useQueryProfileDiff, + queryBundleQueryOptions, } from '@quent/client'; import type { Query, QueryGroup } from '@quent/utils'; import { @@ -25,6 +25,8 @@ import { } from '@quent/components'; import { cn } from '@quent/utils'; import { QueryDiffTable } from '@/components/query-diff/QueryDiffTable'; +import { QueryDiffTimeline } from '@/components/query-diff/QueryDiffTimeline'; +import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryProfileDiffFromBundles'; interface DiffSelectionPageProps { initialEngineId?: string; @@ -250,13 +252,23 @@ export function DiffSelectionPage({ const sameQuerySelected = Boolean(queryA.queryId && queryA.queryId === queryB.queryId); const canDiff = Boolean(engineId && queryA.queryId && queryB.queryId && !sameQuerySelected); - const diffQuery = useQueryProfileDiff({ - engineId, - request: { - query_a_id: queryA.queryId, - query_b_id: sameQuerySelected ? '' : queryB.queryId, - }, + const queryABundle = useQuery({ + ...queryBundleQueryOptions({ engineId, queryId: queryA.queryId }), + enabled: canDiff, + }); + const queryBBundle = useQuery({ + ...queryBundleQueryOptions({ engineId, queryId: queryB.queryId }), + enabled: canDiff, }); + const diff = useMemo( + () => + queryABundle.data && queryBBundle.data + ? buildQueryProfileDiffFromBundles(queryABundle.data, queryBBundle.data) + : null, + [queryABundle.data, queryBBundle.data] + ); + const diffLoading = canDiff && (queryABundle.isLoading || queryBBundle.isLoading); + const diffError = queryABundle.error ?? queryBBundle.error; const maybeNavigateToDiff = ( nextEngineId: string, @@ -313,7 +325,7 @@ export function DiffSelectionPage({ className="shrink-0 border-b border-border bg-card" >
- + Query Diff @@ -332,11 +344,11 @@ export function DiffSelectionPage({ )} - +
- +
@@ -410,16 +422,26 @@ export function DiffSelectionPage({
Choose two different queries.
) : !canDiff ? (
Select Query A and Query B.
- ) : diffQuery.isLoading ? ( + ) : diffLoading ? (
Loading diff...
- ) : diffQuery.error ? ( + ) : diffError ? (
Failed to load diff
- ) : diffQuery.data ? ( - + ) : diff && queryABundle.data && queryBBundle.data ? ( +
+ +
+ +
+
) : null}
diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index eab45837..fb930221 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -5,10 +5,115 @@ import { describe, expect, it, beforeEach } from 'vitest'; import { http, HttpResponse } from 'msw'; import { screen, renderWithRouter } from '@/test/test-utils'; import { server } from '@/test/mocks/server'; -import { equalPlanQueryProfileDiffFixture } from '@/test/mocks/queryProfileDiffFixtures'; const API_BASE = 'http://localhost:8000/api'; +const QUERY_STATS = { + 'query-a': { + scan: { duration: 12, input_rows: 1000, output_rows: 900 }, + join: { duration: 24, input_rows: 900, output_rows: 400 }, + agg: { duration: 4, input_rows: 400, output_rows: 20 }, + }, + 'query-b': { + scan: { duration: 10, input_rows: 1200, output_rows: 950 }, + join: { duration: 30, input_rows: 950, output_rows: 380 }, + agg: { duration: 4, input_rows: 380, output_rows: 20 }, + }, +}; + +function taggedNumber(value: number) { + return { Number: value }; +} + +function createQueryBundle(queryId: string) { + const suffix = queryId.endsWith('b') ? 'b' : 'a'; + const stats = QUERY_STATS[queryId as keyof typeof QUERY_STATS] ?? QUERY_STATS['query-a']; + const planId = `plan-${suffix}`; + + return { + query_id: queryId, + entities: { + engine: { id: 'engine-1', instance_name: 'Engine 1' }, + query_group: { id: 'group-1', instance_name: 'Group 1' }, + query: { id: queryId, instance_name: suffix === 'a' ? 'Query A' : 'Query B' }, + workers: {}, + plans: { + [planId]: { + id: planId, + instance_name: 'Root plan', + parent: null, + worker_id: null, + edges: [], + }, + }, + operators: { + [`scan-${suffix}`]: { + id: `scan-${suffix}`, + plan_id: planId, + parent_operator_ids: [], + instance_name: 'Scan orders', + operator_type_name: 'Scan', + custom_attributes: {}, + statistics: { + custom_statistics: { + input_rows: taggedNumber(stats.scan.input_rows), + output_rows: taggedNumber(stats.scan.output_rows), + }, + }, + active_span: { start: 0, end: stats.scan.duration }, + }, + [`join-${suffix}`]: { + id: `join-${suffix}`, + plan_id: planId, + parent_operator_ids: [], + instance_name: 'Join lineitem', + operator_type_name: 'Join', + custom_attributes: {}, + statistics: { + custom_statistics: { + input_rows: taggedNumber(stats.join.input_rows), + output_rows: taggedNumber(stats.join.output_rows), + }, + }, + active_span: { start: 0, end: stats.join.duration }, + }, + [`agg-${suffix}`]: { + id: `agg-${suffix}`, + plan_id: planId, + parent_operator_ids: [], + instance_name: 'Aggregate', + operator_type_name: 'Aggregate', + custom_attributes: {}, + statistics: { + custom_statistics: { + input_rows: taggedNumber(stats.agg.input_rows), + output_rows: taggedNumber(stats.agg.output_rows), + }, + }, + active_span: { start: 0, end: stats.agg.duration }, + }, + }, + ports: {}, + resource_types: {}, + resource_group_types: {}, + resources: {}, + resource_groups: {}, + fsm_types: {}, + }, + resource_tree: { + ResourceGroup: { + id: { QueryGroup: 'group-1' }, + children: [], + }, + }, + plan_tree: { id: planId, worker: null, children: [] }, + unique_operator_names: [], + quantity_specs: {}, + start_time_unix_ns: 0, + duration_s: 1, + }; +} + describe('Diff routes', () => { beforeEach(() => { server.use( @@ -49,8 +154,8 @@ describe('Diff routes', () => { ] ); }), - http.post(`${API_BASE}/engines/:engineId/query-profile-diff`, () => - HttpResponse.json(equalPlanQueryProfileDiffFixture) + http.get(`${API_BASE}/engines/:engineId/query/:queryId`, ({ params }) => + HttpResponse.json(createQueryBundle(String(params.queryId))) ) ); }); @@ -69,6 +174,7 @@ describe('Diff routes', () => { }); expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); + expect(screen.getByText('Timeline Delta')).toBeInTheDocument(); expect(screen.getAllByText(/Query A/).length).toBeGreaterThan(0); }); diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index 3feaff15..b7d3b127 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -2,10 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { http, HttpResponse } from 'msw'; -import { - differentPlanQueryProfileDiffFixture, - equalPlanQueryProfileDiffFixture, -} from './queryProfileDiffFixtures'; /** * Default MSW handlers for mocking API responses @@ -52,16 +48,4 @@ export const handlers = [ }, }); }), - - http.post('/api/engines/:engineId/query-profile-diff', async ({ request }) => { - const body = (await request.json()) as { query_a_id?: string; query_b_id?: string }; - if (body.query_a_id?.includes('different') || body.query_b_id?.includes('different')) { - return HttpResponse.json(differentPlanQueryProfileDiffFixture); - } - return HttpResponse.json({ - ...equalPlanQueryProfileDiffFixture, - query_a: { ...equalPlanQueryProfileDiffFixture.query_a, id: body.query_a_id ?? 'query-a' }, - query_b: { ...equalPlanQueryProfileDiffFixture.query_b, id: body.query_b_id ?? 'query-b' }, - }); - }), ]; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index df977b94..99e9e171 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,15 +3,12 @@ import path from 'path'; import { defineConfig } from 'vite'; -import type { Plugin } from 'vite'; -import type { IncomingMessage, ServerResponse } from 'node:http'; import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; import { visualizer } from 'rollup-plugin-visualizer'; import tailwindcss from '@tailwindcss/vite'; const API_TARGET = process.env.VITE_API_TARGET || 'http://localhost:8080'; -const ENABLE_DIFF_MOCK_API = process.env.VITE_DIFF_MOCK_API !== 'false'; /** Ensures JS chunks get high fetch priority so they load before competing API requests. */ function vitePluginScriptPriority() { @@ -33,182 +30,11 @@ function vitePluginScriptPriority() { }; } -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ''; - req.setEncoding('utf8'); - req.on('data', chunk => { - body += chunk; - }); - req.on('end', () => resolve(body)); - req.on('error', reject); - }); -} - -function createMockQueryProfileDiffResponse(queryAId: string, queryBId: string) { - const different = queryAId.includes('different') || queryBId.includes('different'); - const base = { - query_a: { - id: queryAId, - instance_name: queryAId, - query_group_id: null, - query_group_name: null, - }, - query_b: { - id: queryBId, - instance_name: queryBId, - query_group_id: null, - query_group_name: null, - }, - }; - - if (different) { - return { - scenario: 'plans_different', - ...base, - plan_comparison: { - match_kind: 'different', - matched_operator_count: 0, - unmatched_operator_a_count: 3, - unmatched_operator_b_count: 4, - }, - operator_diffs: [], - warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], - }; - } - - return { - scenario: 'plans_equal', - ...base, - plan_comparison: { - match_kind: 'structural', - matched_operator_count: 3, - unmatched_operator_a_count: 0, - unmatched_operator_b_count: 0, - }, - operator_diffs: [ - { - operator_a: { - id: 'scan-a', - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: 'plan-a', - }, - operator_b: { - id: 'scan-b', - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: 'plan-b', - }, - stats: { - duration_s: { a: 12, b: 10, delta: 2, percent_delta: 0.2 }, - input_rows: { a: 1000, b: 1200, delta: -200, percent_delta: -0.1666666667 }, - output_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, - }, - }, - { - operator_a: { - id: 'join-a', - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: 'plan-a', - }, - operator_b: { - id: 'join-b', - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: 'plan-b', - }, - stats: { - duration_s: { a: 24, b: 30, delta: -6, percent_delta: -0.2 }, - input_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, - output_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, - }, - }, - { - operator_a: { - id: 'agg-a', - label: 'Aggregate', - operator_type_name: 'Aggregate', - plan_id: 'plan-a', - }, - operator_b: { - id: 'agg-b', - label: 'Aggregate', - operator_type_name: 'Aggregate', - plan_id: 'plan-b', - }, - stats: { - duration_s: { a: 4, b: 4, delta: 0, percent_delta: 0 }, - input_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, - output_rows: { a: 20, b: 20, delta: 0, percent_delta: 0 }, - }, - }, - ], - }; -} - -function vitePluginQueryProfileDiffMock(): Plugin { - const diffPath = /^\/api\/engines\/[^/]+\/query-profile-diff(?:\?.*)?$/; - return { - name: 'vite-plugin-query-profile-diff-mock', - configureServer(server) { - if (!ENABLE_DIFF_MOCK_API) return; - server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => { - if (req.method !== 'POST' || !req.url || !diffPath.test(req.url)) { - next(); - return; - } - void readRequestBody(req) - .then(bodyText => { - const body = JSON.parse(bodyText || '{}') as { - query_a_id?: string; - query_b_id?: string; - }; - const response = createMockQueryProfileDiffResponse( - body.query_a_id ?? 'query-a', - body.query_b_id ?? 'query-b' - ); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(response)); - }) - .catch(next); - }); - }, - configurePreviewServer(server) { - if (!ENABLE_DIFF_MOCK_API) return; - server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => { - if (req.method !== 'POST' || !req.url || !diffPath.test(req.url)) { - next(); - return; - } - void readRequestBody(req) - .then(bodyText => { - const body = JSON.parse(bodyText || '{}') as { - query_a_id?: string; - query_b_id?: string; - }; - const response = createMockQueryProfileDiffResponse( - body.query_a_id ?? 'query-a', - body.query_b_id ?? 'query-b' - ); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(response)); - }) - .catch(next); - }); - }, - }; -} - // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), vitePluginScriptPriority(), - vitePluginQueryProfileDiffMock(), TanStackRouterVite({ routeFileIgnorePattern: '.test.|.spec.', }), From d8c8125b9dfde568885286f863e198a71a646ea6 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 09:57:05 -0600 Subject: [PATCH 03/33] Swappable, some UX improvements --- ui/src/pages/DiffSelectionPage.tsx | 46 ++++++++++++++------ ui/src/routes/diff.test.tsx | 68 +++++++++++++++++++++++++++++- ui/src/test/setup.ts | 7 +++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 7ec057f8..4344cc49 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; -import { ChevronDown } from 'lucide-react'; +import { ArrowLeftRight, ChevronDown } from 'lucide-react'; import { fetchListCoordinators, fetchListEngines, @@ -13,6 +13,7 @@ import { } from '@quent/client'; import type { Query, QueryGroup } from '@quent/utils'; import { + Button, Collapsible, CollapsibleContent, CollapsibleTrigger, @@ -187,15 +188,11 @@ export function DiffSelectionPage({ useEffect(() => { setEngineId(initialEngineId); - setQueryA({ groupId: '', queryId: initialQueryAId }); - setQueryB({ groupId: '', queryId: initialQueryBId }); - setSelectionOpen( - !( - initialEngineId && - initialQueryAId && - initialQueryBId && - initialQueryAId !== initialQueryBId - ) + setQueryA(prev => + prev.queryId === initialQueryAId ? prev : { groupId: '', queryId: initialQueryAId } + ); + setQueryB(prev => + prev.queryId === initialQueryBId ? prev : { groupId: '', queryId: initialQueryBId } ); }, [initialEngineId, initialQueryAId, initialQueryBId]); @@ -234,7 +231,7 @@ export function DiffSelectionPage({ const groupId = findGroupForQuery(prev.queryId, queriesByGroup); return groupId ? { ...prev, groupId } : prev; }); - }, [queriesByGroup]); + }, [queriesByGroup, queryA.groupId, queryA.queryId, queryB.groupId, queryB.queryId]); const selectedEngine = useMemo( () => engines.find(engine => engine.id === engineId), @@ -249,6 +246,9 @@ export function DiffSelectionPage({ () => queryDisplayLabel(queryB.queryId, queriesByGroup, 'Select Query B'), [queryB.queryId, queriesByGroup] ); + const canSwapQueries = Boolean( + engineId && (queryA.groupId || queryA.queryId || queryB.groupId || queryB.queryId) + ); const sameQuerySelected = Boolean(queryA.queryId && queryA.queryId === queryB.queryId); const canDiff = Boolean(engineId && queryA.queryId && queryB.queryId && !sameQuerySelected); @@ -317,6 +317,14 @@ export function DiffSelectionPage({ } }; + const handleSwapQueries = () => { + const nextA = queryB; + const nextB = queryA; + setQueryA(nextA); + setQueryB(nextB); + maybeNavigateToDiff(engineId, nextA, nextB); + }; + return (
-
+
handleGroupChange('a', groupId)} onQueryChange={queryId => handleQueryChange('a', queryId)} /> +
+ +
{ expect(screen.getAllByText(/Query A/).length).toBeGreaterThan(0); }); + it('preserves query group and query selections after the selector collapses', async () => { + const user = userEvent.setup(); + renderWithRouter({ initialPath: '/diff' }); + + await user.click(await screen.findByRole('combobox', { name: 'Engine' })); + await user.click(await screen.findByRole('option', { name: 'Engine 1' })); + + await waitFor(() => expect(screen.getAllByRole('combobox')).toHaveLength(5)); + let selectors = screen.getAllByRole('combobox'); + await user.click(selectors[1]); + await user.click(await screen.findByRole('option', { name: 'Group A' })); + + selectors = screen.getAllByRole('combobox'); + await user.click(selectors[2]); + await user.click(await screen.findByRole('option', { name: 'Query A' })); + + selectors = screen.getAllByRole('combobox'); + await user.click(selectors[3]); + await user.click(await screen.findByRole('option', { name: 'Group B' })); + + selectors = screen.getAllByRole('combobox'); + await user.click(selectors[4]); + await user.click(await screen.findByRole('option', { name: 'Query B' })); + + expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); + + const trigger = screen.getByText('Query Diff').closest('button'); + expect(trigger).not.toBeNull(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await user.click(trigger!); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await user.click(trigger!); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await waitFor(() => { + const reopenedSelectors = screen.getAllByRole('combobox'); + expect(reopenedSelectors[1]).toHaveTextContent('Group A'); + expect(reopenedSelectors[2]).toHaveTextContent('Query A'); + expect(reopenedSelectors[3]).toHaveTextContent('Group B'); + expect(reopenedSelectors[4]).toHaveTextContent('Query B'); + }); + }); + + it('swaps Query A and Query B from the selector', async () => { + const user = userEvent.setup(); + const { router } = renderWithRouter({ + initialPath: '/diff/engine/engine-1/query/query-a/compare/query-b', + }); + + expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); + + const trigger = screen.getByText('Query Diff').closest('button'); + expect(trigger).not.toBeNull(); + await user.click(trigger!); + + await user.click(await screen.findByRole('button', { name: 'Swap Query A and Query B' })); + + await waitFor(() => { + expect(router.state.location.pathname).toBe( + '/diff/engine/engine-1/query/query-b/compare/query-a' + ); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + it('does not render a diff for the same query on both sides', async () => { renderWithRouter({ initialPath: '/diff/engine/engine-1/query/query-a/compare/query-a', diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index 235d542d..32ae53f4 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -33,6 +33,13 @@ class ResizeObserverMock { // Mock scrollIntoView for Radix UI Select components Element.prototype.scrollIntoView = vi.fn(); +// Mock pointer capture for Radix UI Select components in jsdom +Object.defineProperties(Element.prototype, { + hasPointerCapture: { value: vi.fn(() => false), configurable: true }, + setPointerCapture: { value: vi.fn(), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, +}); + // Start MSW server before all tests beforeAll(() => { server.listen({ onUnhandledRequest: 'warn' }); From 9fcc6eb37758681696a3a01364a4d6ea3ce57d6a Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 10:05:20 -0600 Subject: [PATCH 04/33] Don't close automatically annoyingly --- ui/src/pages/DiffSelectionPage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 4344cc49..1d52d92a 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -54,6 +54,17 @@ const COMPACT_SELECT_TRIGGER_CLASS = 'h-7 min-w-0 rounded px-2 py-1 text-xs [&_svg]:h-3 [&_svg]:w-3'; const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; +let pendingSelectionOpenAfterNavigation: boolean | null = null; + +function getInitialSelectionOpen(engineId: string, queryAId: string, queryBId: string): boolean { + if (pendingSelectionOpenAfterNavigation !== null) { + const selectionOpen = pendingSelectionOpenAfterNavigation; + pendingSelectionOpenAfterNavigation = null; + return selectionOpen; + } + return !(engineId && queryAId && queryBId && queryAId !== queryBId); +} + function findGroupForQuery( queryId: string, queriesByGroup: Record @@ -182,8 +193,8 @@ export function DiffSelectionPage({ const [engineId, setEngineId] = useState(initialEngineId); const [queryA, setQueryA] = useState({ groupId: '', queryId: initialQueryAId }); const [queryB, setQueryB] = useState({ groupId: '', queryId: initialQueryBId }); - const [selectionOpen, setSelectionOpen] = useState( - !(initialEngineId && initialQueryAId && initialQueryBId && initialQueryAId !== initialQueryBId) + const [selectionOpen, setSelectionOpen] = useState(() => + getInitialSelectionOpen(initialEngineId, initialQueryAId, initialQueryBId) ); useEffect(() => { @@ -278,6 +289,7 @@ export function DiffSelectionPage({ if (!nextEngineId || !nextA.queryId || !nextB.queryId || nextA.queryId === nextB.queryId) { return; } + pendingSelectionOpenAfterNavigation = selectionOpen; navigate({ to: '/diff/engine/$engineId/query/$queryAId/compare/$queryBId', params: { @@ -293,6 +305,7 @@ export function DiffSelectionPage({ setQueryA({ groupId: '', queryId: '' }); setQueryB({ groupId: '', queryId: '' }); setSelectionOpen(true); + pendingSelectionOpenAfterNavigation = true; navigate({ to: '/diff' }); }; From 1abcb8f399aad83e0e5f16143f699062f055d7c7 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 11:15:12 -0600 Subject: [PATCH 05/33] Refining the timeline contract --- ui/packages/@quent/client/src/api.ts | 19 +- ui/packages/@quent/client/src/index.ts | 11 +- .../client/src/queryProfileDiff.test.ts | 44 ++++- .../@quent/client/src/queryProfileDiff.ts | 34 +++- .../client/src/queryProfileDiffTypes.ts | 23 ++- .../components/query-diff/QueryDiffTable.tsx | 9 +- .../query-diff/QueryDiffTimeline.tsx | 60 +++--- .../QueryDiffTimeline.utils.test.ts | 34 ++-- .../query-diff/QueryDiffTimeline.utils.ts | 107 ++++------ ui/src/test/mocks/handlers.ts | 156 +++++++++++++++ ui/vite.config.ts | 187 +++++++++++++++++- 11 files changed, 557 insertions(+), 127 deletions(-) diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index cc8a75ec..de2aa2dc 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -16,7 +16,12 @@ import type { EntityRef, Engine, } from '@quent/utils'; -import type { QueryProfileDiffRequest, QueryProfileDiffResponse } from './queryProfileDiffTypes'; +import type { + QueryProfileDiffRequest, + QueryProfileDiffResponse, + QueryProfileDiffTimelineRequest, + QueryProfileDiffTimelineResponse, +} from './queryProfileDiffTypes'; interface ApiFetchOptions { params?: Record; @@ -117,3 +122,15 @@ export async function fetchQueryProfileDiff( }, }); } + +export async function fetchQueryProfileDiffTimeline( + engineId: string, + request: QueryProfileDiffTimelineRequest +): Promise { + return apiFetch(`/engines/${engineId}/timeline/diff`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); +} diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index 90245e12..f558a26b 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -14,6 +14,7 @@ export { fetchSingleTimeline, fetchBulkTimelines, fetchQueryProfileDiff, + fetchQueryProfileDiffTimeline, } from './api'; // queryOptions factories @@ -23,7 +24,10 @@ export { queryGroupsQueryOptions } from './queryGroups'; export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; -export { queryProfileDiffQueryOptions } from './queryProfileDiff'; +export { + queryProfileDiffQueryOptions, + queryProfileDiffTimelineQueryOptions, +} from './queryProfileDiff'; // Hooks export { useQueryBundle } from './queryBundle'; @@ -31,7 +35,7 @@ export { useEngines } from './engines'; export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; -export { useQueryProfileDiff } from './queryProfileDiff'; +export { useQueryProfileDiff, useQueryProfileDiffTimeline } from './queryProfileDiff'; export type { QueryProfileDiffOperatorDelta, @@ -42,4 +46,7 @@ export type { QueryProfileDiffResponse, QueryProfileDiffScenario, QueryProfileDiffStatDelta, + QueryProfileDiffTimelineEntries, + QueryProfileDiffTimelineRequest, + QueryProfileDiffTimelineResponse, } from './queryProfileDiff'; diff --git a/ui/packages/@quent/client/src/queryProfileDiff.test.ts b/ui/packages/@quent/client/src/queryProfileDiff.test.ts index 5a89ef0d..41ebd0ca 100644 --- a/ui/packages/@quent/client/src/queryProfileDiff.test.ts +++ b/ui/packages/@quent/client/src/queryProfileDiff.test.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { queryProfileDiffQueryOptions } from './queryProfileDiff'; +import { + queryProfileDiffQueryOptions, + queryProfileDiffTimelineQueryOptions, +} from './queryProfileDiff'; +import type { QueryProfileDiffTimelineRequest } from './queryProfileDiffTypes'; describe('queryProfileDiffQueryOptions', () => { it('builds a stable key from engine and query ids', () => { @@ -13,4 +17,42 @@ describe('queryProfileDiffQueryOptions', () => { expect(options.queryKey).toEqual(['queryProfileDiff', 'engine-1', 'query-a', 'query-b']); }); + + it('builds diff timeline options around the full request', () => { + const request: QueryProfileDiffTimelineRequest = { + timelines: [ + { + entry: { + ResourceGroup: { + resource_group_id: 'root-a', + resource_type_name: 'GPU', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: null }, + app_params: { operator_id: null }, + config: { num_bins: 200, start: 0, end: 10 }, + }, + }, + app_params: { query_id: 'query-a' }, + }, + { + entry: { + ResourceGroup: { + resource_group_id: 'root-b', + resource_type_name: 'GPU', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: null }, + app_params: { operator_id: null }, + config: { num_bins: 200, start: 0, end: 12 }, + }, + }, + app_params: { query_id: 'query-b' }, + }, + ], + delta_config: { num_bins: 200, start: 0, end: 12 }, + }; + + const options = queryProfileDiffTimelineQueryOptions({ engineId: 'engine-1', request }); + + expect(options.queryKey).toEqual(['queryProfileDiffTimeline', 'engine-1', request]); + }); }); diff --git a/ui/packages/@quent/client/src/queryProfileDiff.ts b/ui/packages/@quent/client/src/queryProfileDiff.ts index fa0f806e..149d9c97 100644 --- a/ui/packages/@quent/client/src/queryProfileDiff.ts +++ b/ui/packages/@quent/client/src/queryProfileDiff.ts @@ -2,15 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { queryOptions, useQuery } from '@tanstack/react-query'; -import { fetchQueryProfileDiff } from './api'; +import { fetchQueryProfileDiff, fetchQueryProfileDiffTimeline } from './api'; import { DEFAULT_STALE_TIME } from './constants'; -import type { QueryProfileDiffRequest, QueryProfileDiffResponse } from './queryProfileDiffTypes'; +import type { + QueryProfileDiffRequest, + QueryProfileDiffResponse, + QueryProfileDiffTimelineRequest, + QueryProfileDiffTimelineResponse, +} from './queryProfileDiffTypes'; interface QueryProfileDiffParams { engineId: string; request: QueryProfileDiffRequest; } +interface QueryProfileDiffTimelineParams { + engineId: string; + request: QueryProfileDiffTimelineRequest; +} + export const queryProfileDiffQueryOptions = ( { engineId, request }: QueryProfileDiffParams, options?: { staleTime?: number } @@ -27,6 +37,23 @@ export const useQueryProfileDiff = ( options?: { staleTime?: number } ) => useQuery(queryProfileDiffQueryOptions(params, options)); +export const queryProfileDiffTimelineQueryOptions = ( + { engineId, request }: QueryProfileDiffTimelineParams, + options?: { staleTime?: number } +) => + queryOptions({ + queryKey: ['queryProfileDiffTimeline', engineId, request], + queryFn: (): Promise => + fetchQueryProfileDiffTimeline(engineId, request), + staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, + enabled: Boolean(engineId), + }); + +export const useQueryProfileDiffTimeline = ( + params: QueryProfileDiffTimelineParams, + options?: { staleTime?: number } +) => useQuery(queryProfileDiffTimelineQueryOptions(params, options)); + export type { QueryProfileDiffOperatorDelta, QueryProfileDiffOperatorRef, @@ -36,4 +63,7 @@ export type { QueryProfileDiffResponse, QueryProfileDiffScenario, QueryProfileDiffStatDelta, + QueryProfileDiffTimelineEntries, + QueryProfileDiffTimelineRequest, + QueryProfileDiffTimelineResponse, } from './queryProfileDiffTypes'; diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index b78f956f..07bb3c69 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -1,7 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { StatValue } from '@quent/utils'; +import type { + QueryFilter, + SingleTimelineRequest, + SingleTimelineResponse, + StatValue, + TaskFilter, + TimelineConfig, +} from '@quent/utils'; export interface QueryProfileDiffRequest { query_a_id: string; @@ -38,6 +45,7 @@ export interface QueryProfileDiffOperatorDelta { } export interface QueryProfileDiffPlanComparison { + /* Big question here, how do we represent query plan graph diffs */ match_kind: 'structural' | 'different' | 'incomparable'; matched_operator_count: number; unmatched_operator_a_count: number; @@ -52,3 +60,16 @@ export interface QueryProfileDiffResponse { operator_diffs: QueryProfileDiffOperatorDelta[]; warnings?: string[]; } + +export type QueryProfileDiffTimelineEntries = [T, T, ...T[]]; + +export interface QueryProfileDiffTimelineRequest { + timelines: QueryProfileDiffTimelineEntries>; + delta_config: TimelineConfig; +} + +export interface QueryProfileDiffTimelineResponse { + timelines: QueryProfileDiffTimelineEntries; + delta: SingleTimelineResponse; + warnings?: string[]; +} diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index e7407f6a..a0124a37 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo, useState } from 'react'; +import { Triangle } from 'lucide-react'; import { DataText, PivotedStatTable, @@ -177,9 +178,13 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) {
Operator Stat Deltas
-
+
{diff.query_a.instance_name ?? diff.query_a.id} - {' minus '} + {diff.query_b.instance_name ?? diff.query_b.id}
diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 95aea291..60bf1fc9 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -3,10 +3,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { Triangle } from 'lucide-react'; import { DEFAULT_STALE_TIME, - fetchSingleTimeline, + fetchQueryProfileDiffTimeline, type QueryProfileDiffResponse, + type QueryProfileDiffTimelineRequest, } from '@quent/client'; import { DataText, @@ -191,45 +193,40 @@ export function QueryDiffTimeline({ }); }, [queryBBundle.duration_s, queryBBundle.query_id, resourceType, targetB]); - const timelineA = useQuery({ - queryKey: [ - 'queryDiffTimeline', - 'a', - engineId, - queryAId, - targetA?.rootResourceGroupId, - resourceType, - queryABundle.duration_s, - ], - queryFn: () => fetchSingleTimeline(engineId, requestA!, queryABundle.duration_s), - enabled: Boolean(requestA && engineId), - staleTime: DEFAULT_STALE_TIME, - }); + const timelineDiffRequest = useMemo(() => { + if (!requestA || !requestB || durationSeconds <= 0) return null; + return { + timelines: [requestA, requestB], + delta_config: { + num_bins: getAdaptiveNumBins(), + start: 0, + end: durationSeconds, + }, + }; + }, [durationSeconds, requestA, requestB]); - const timelineB = useQuery({ + const timelineDiff = useQuery({ queryKey: [ 'queryDiffTimeline', - 'b', engineId, + queryAId, queryBId, + targetA?.rootResourceGroupId, targetB?.rootResourceGroupId, - resourceType, - queryBBundle.duration_s, + timelineDiffRequest, ], - queryFn: () => fetchSingleTimeline(engineId, requestB!, queryBBundle.duration_s), - enabled: Boolean(requestB && engineId), + queryFn: () => fetchQueryProfileDiffTimeline(engineId, timelineDiffRequest!), + enabled: Boolean(timelineDiffRequest && engineId), staleTime: DEFAULT_STALE_TIME, }); const comparison = useMemo(() => { - if (!timelineA.data || !timelineB.data || durationSeconds <= 0) return null; + if (!timelineDiff.data || durationSeconds <= 0) return null; const resourceTypeDecl = queryABundle.entities.resource_types[resourceType] ?? queryBBundle.entities.resource_types[resourceType]; return buildDiffTimelineData({ - queryATimeline: timelineA.data, - queryBTimeline: timelineB.data, - durationSeconds, + timelineDiff: timelineDiff.data, theme: paletteTheme, capacities: resourceTypeDecl?.capacities, quantitySpecs: queryABundle.quantity_specs ?? queryBBundle.quantity_specs, @@ -245,12 +242,11 @@ export function QueryDiffTimeline({ queryBBundle.entities.resource_types, queryBBundle.quantity_specs, resourceType, - timelineA.data, - timelineB.data, + timelineDiff.data, ]); - const isLoading = Boolean(resourceType) && (timelineA.isLoading || timelineB.isLoading); - const hasError = timelineA.isError || timelineB.isError; + const isLoading = Boolean(resourceType) && timelineDiff.isLoading; + const hasError = timelineDiff.isError; return (
@@ -263,7 +259,11 @@ export function QueryDiffTimeline({ {diff.query_a.instance_name ?? queryAId} - minus + {diff.query_b.instance_name ?? queryBId} diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts index e4c017f8..ba87324e 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -3,13 +3,15 @@ import { describe, expect, it } from 'vitest'; import type { SingleTimelineResponse } from '@quent/utils'; +import type { QueryProfileDiffTimelineResponse } from '@quent/client'; import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; -function makeTimeline(values: number[]): SingleTimelineResponse { +function makeTimeline(values: Record): SingleTimelineResponse { + const firstValues = Object.values(values)[0] ?? []; const config = { - span: { start: 0, end: values.length }, + span: { start: 0, end: firstValues.length }, bin_duration: 1, - num_bins: BigInt(values.length), + num_bins: BigInt(firstValues.length), }; return { @@ -17,7 +19,7 @@ function makeTimeline(values: number[]): SingleTimelineResponse { data: { Binned: { config, - capacities_values: { slots: values }, + capacities_values: values, long_fsms: [], }, }, @@ -25,19 +27,23 @@ function makeTimeline(values: number[]): SingleTimelineResponse { } describe('buildDiffTimelineData', () => { - it('splits positive and negative aggregate deltas into direction series', () => { + it('uses backend-provided delta series for the diff lane', () => { + const response: QueryProfileDiffTimelineResponse = { + timelines: [makeTimeline({ slots: [100, 100] }), makeTimeline({ slots: [0, 0] })], + delta: makeTimeline({ + 'Query A higher': [2, 0], + 'Query B higher': [0, 3], + }), + }; + const data = buildDiffTimelineData({ - queryATimeline: makeTimeline([3, 1]), - queryBTimeline: makeTimeline([1, 4]), - durationSeconds: 2, + timelineDiff: response, theme: 'light', }); - expect(data.queryA.series.slots?.values).toEqual([3, 1]); - expect(data.queryB.series.slots?.values).toEqual([1, 4]); - expect(data.delta.series['Query A higher']?.values[0]).toBe(2); - expect(data.delta.series['Query B higher']?.values[0]).toBe(0); - expect(data.delta.series['Query A higher']?.values[150]).toBe(0); - expect(data.delta.series['Query B higher']?.values[150]).toBe(3); + expect(data.queryA.series.slots?.values).toEqual([100, 100]); + expect(data.queryB.series.slots?.values).toEqual([0, 0]); + expect(data.delta.series['Query A higher']?.values).toEqual([2, 0]); + expect(data.delta.series['Query B higher']?.values).toEqual([0, 3]); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index b3b5c7a4..a4fa06f6 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -1,21 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - buildBinnedTimelineSeries, - getAdaptiveNumBins, - type TimelineSeries, -} from '@quent/components'; -import type { - CapacityDecl, - FsmTypeDecl, - PaletteTheme, - QuantitySpec, - SingleTimelineResponse, -} from '@quent/utils'; +import { buildBinnedTimelineSeries, type TimelineSeries } from '@quent/components'; +import type { QueryProfileDiffTimelineResponse } from '@quent/client'; +import type { CapacityDecl, FsmTypeDecl, PaletteTheme, QuantitySpec } from '@quent/utils'; const QUERY_A_HIGHER_COLOR = '#CC6677'; const QUERY_B_HIGHER_COLOR = '#44AA99'; +const QUERY_A_HIGHER_LABEL = 'Query A higher'; +const QUERY_B_HIGHER_LABEL = 'Query B higher'; interface TimelineRowData { timestamps: number[]; @@ -29,9 +22,7 @@ export interface DiffTimelineData { } interface BuildDiffTimelineDataParams { - queryATimeline: SingleTimelineResponse; - queryBTimeline: SingleTimelineResponse; - durationSeconds: number; + timelineDiff: QueryProfileDiffTimelineResponse; theme: PaletteTheme; capacities?: CapacityDecl[]; quantitySpecs?: { [key in string]?: QuantitySpec }; @@ -46,76 +37,41 @@ function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { ); } -function buildElapsedTimestamps(durationSeconds: number, numBins: number): number[] { - if (numBins <= 0) return []; - const binDurationMs = (durationSeconds * 1_000) / numBins; - return Array.from({ length: numBins }, (_, index) => index * binDurationMs); -} - -function sampleAggregateAt(series: TimelineSeries, timestamps: number[], targetTimestamp: number) { - const entries = Object.values(series); - if (entries.length === 0 || timestamps.length === 0) return 0; - - const firstTimestamp = timestamps[0] ?? 0; - const secondTimestamp = timestamps[1]; - const firstEntry = entries[0]; - const binDurationMs = - secondTimestamp != null - ? secondTimestamp - firstTimestamp - : Math.max(firstEntry?.binDuration ?? 0, 0) * 1_000; - - if (binDurationMs <= 0 || targetTimestamp < firstTimestamp) return 0; - - const index = Math.floor((targetTimestamp - firstTimestamp) / binDurationMs); - if (index < 0) return 0; - - return entries.reduce((sum, entry) => sum + (entry.values[index] ?? 0), 0); -} - -function buildDiffSeries({ +function formatDeltaSeries({ + delta, queryA, queryB, - timestamps, - durationSeconds, }: { + delta: TimelineRowData; queryA: TimelineRowData; queryB: TimelineRowData; - timestamps: number[]; - durationSeconds: number; }): TimelineSeries { const formatter = getFirstFormatter(queryA.series, queryB.series); - const deltas = timestamps.map(timestamp => { - const a = sampleAggregateAt(queryA.series, queryA.timestamps, timestamp); - const b = sampleAggregateAt(queryB.series, queryB.timestamps, timestamp); - return a - b; - }); - const binDuration = timestamps.length > 0 ? durationSeconds / timestamps.length : 0; - - return { - 'Query A higher': { - color: QUERY_A_HIGHER_COLOR, - binDuration, - formatter, - values: deltas.map(delta => Math.max(delta, 0)), - }, - 'Query B higher': { - color: QUERY_B_HIGHER_COLOR, - binDuration, - formatter, - values: deltas.map(delta => Math.max(-delta, 0)), - }, - }; + return Object.fromEntries( + Object.entries(delta.series).map(([name, entry]) => [ + name, + { + ...entry, + color: + name === QUERY_A_HIGHER_LABEL + ? QUERY_A_HIGHER_COLOR + : name === QUERY_B_HIGHER_LABEL + ? QUERY_B_HIGHER_COLOR + : entry.color, + formatter, + }, + ]) + ); } export function buildDiffTimelineData({ - queryATimeline, - queryBTimeline, - durationSeconds, + timelineDiff, theme, capacities, quantitySpecs, fsmTypes, }: BuildDiffTimelineDataParams): DiffTimelineData { + const [queryATimeline, queryBTimeline] = timelineDiff.timelines; const queryA = buildBinnedTimelineSeries( queryATimeline.data, queryATimeline.config, @@ -134,14 +90,19 @@ export function buildDiffTimelineData({ quantitySpecs, fsmTypes ); - const timestamps = buildElapsedTimestamps(durationSeconds, getAdaptiveNumBins()); + const delta = buildBinnedTimelineSeries( + timelineDiff.delta.data, + timelineDiff.delta.config, + 0n, + theme + ); return { queryA, queryB, delta: { - timestamps, - series: buildDiffSeries({ queryA, queryB, timestamps, durationSeconds }), + timestamps: delta.timestamps, + series: formatDeltaSeries({ delta, queryA, queryB }), }, }; } diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index b7d3b127..1292ab75 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -2,6 +2,136 @@ // SPDX-License-Identifier: Apache-2.0 import { http, HttpResponse } from 'msw'; +import { MAX_TIMELINE_BINS } from '@quent/utils'; +import type { + BinnedSpanSec, + BulkTimelineRequest, + BulkTimelinesResponse, + QueryFilter, + SingleTimelineRequest, + SingleTimelineResponse, + TaskFilter, + TimelineConfig, + TimelineRequest, +} from '@quent/utils'; +import type { + QueryProfileDiffTimelineRequest, + QueryProfileDiffTimelineResponse, +} from '@quent/client'; + +const QUERY_A_HIGHER_SERIES = 'Query A higher'; +const QUERY_B_HIGHER_SERIES = 'Query B higher'; + +function hashString(value: string): number { + return [...value].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 0); +} + +function entryConfig(entry: TimelineRequest): TimelineConfig { + return 'ResourceGroup' in entry ? entry.ResourceGroup.config : entry.Resource.config; +} + +function toBinnedSpanSec(config: TimelineConfig): BinnedSpanSec { + const numBins = Math.max(1, Math.trunc(Number(config.num_bins || MAX_TIMELINE_BINS))); + const start = Number(config.start); + const end = Number(config.end); + return { + span: { start, end }, + bin_duration: end > start ? (end - start) / numBins : 0, + num_bins: numBins as unknown as bigint, + }; +} + +function roundTimelineValue(value: number): number { + return Number(value.toFixed(3)); +} + +function makeMockTimelineResponse( + request: SingleTimelineRequest +): SingleTimelineResponse { + const config = toBinnedSpanSec(entryConfig(request.entry)); + const numBins = Number(config.num_bins); + const seed = hashString(request.app_params.query_id); + const baseline = 1 + (seed % 7); + const amplitude = 1 + (seed % 5) / 2; + const values = Array.from({ length: numBins }, (_, index) => { + const wave = Math.sin((index + seed) / 13); + return roundTimelineValue(Math.max(0, baseline + wave * amplitude)); + }); + + return { + config, + data: { + Binned: { + config, + capacities_values: { usage: values }, + long_fsms: [], + }, + }, + }; +} + +function timelineValueArrays(response: SingleTimelineResponse): number[][] { + if ('Binned' in response.data) { + return Object.values(response.data.Binned.capacities_values).filter( + (values): values is number[] => Array.isArray(values) + ); + } + + return Object.values(response.data.BinnedByState.capacities_states_values).flatMap(states => + Object.values(states ?? {}).filter((values): values is number[] => Array.isArray(values)) + ); +} + +function sampleAggregateAt(response: SingleTimelineResponse, targetSeconds: number): number { + const binDuration = response.config.bin_duration; + if (binDuration <= 0 || targetSeconds < response.config.span.start) return 0; + + const index = Math.floor((targetSeconds - response.config.span.start) / binDuration); + if (index < 0 || index >= Number(response.config.num_bins)) return 0; + + return timelineValueArrays(response).reduce((sum, values) => sum + (values[index] ?? 0), 0); +} + +function makeMockTimelineDiffResponse( + request: QueryProfileDiffTimelineRequest +): QueryProfileDiffTimelineResponse { + const [queryARequest, queryBRequest, ...restRequests] = request.timelines; + const queryA = makeMockTimelineResponse(queryARequest); + const queryB = makeMockTimelineResponse(queryBRequest); + const timelines: QueryProfileDiffTimelineResponse['timelines'] = [ + queryA, + queryB, + ...restRequests.map(makeMockTimelineResponse), + ]; + const config = toBinnedSpanSec(request.delta_config); + const queryAHigher: number[] = []; + const queryBHigher: number[] = []; + + for (let index = 0; index < Number(config.num_bins); index += 1) { + const targetSeconds = config.span.start + index * config.bin_duration; + const delta = + sampleAggregateAt(queryA, targetSeconds) - sampleAggregateAt(queryB, targetSeconds); + queryAHigher.push(roundTimelineValue(Math.max(delta, 0))); + queryBHigher.push(roundTimelineValue(Math.max(-delta, 0))); + } + + return { + timelines, + delta: { + config, + data: { + Binned: { + config, + capacities_values: { + [QUERY_A_HIGHER_SERIES]: queryAHigher, + [QUERY_B_HIGHER_SERIES]: queryBHigher, + }, + long_fsms: [], + }, + }, + }, + }; +} /** * Default MSW handlers for mocking API responses @@ -48,4 +178,30 @@ export const handlers = [ }, }); }), + + http.post('*/api/engines/:engineId/timeline/single', async ({ request }) => { + const body = (await request.json()) as SingleTimelineRequest; + return HttpResponse.json(makeMockTimelineResponse(body)); + }), + + http.post('*/api/engines/:engineId/timeline/bulk', async ({ request }) => { + const body = (await request.json()) as BulkTimelineRequest; + const entries: BulkTimelinesResponse['entries'] = {}; + for (const [id, entry] of Object.entries(body.entries)) { + if (!entry) continue; + const response = makeMockTimelineResponse({ entry, app_params: body.app_params }); + entries[id] = { + status: 'ok', + message: '', + config: response.config, + data: response.data, + }; + } + return HttpResponse.json({ entries } satisfies BulkTimelinesResponse); + }), + + http.post('*/api/engines/:engineId/timeline/diff', async ({ request }) => { + const body = (await request.json()) as QueryProfileDiffTimelineRequest; + return HttpResponse.json(makeMockTimelineDiffResponse(body)); + }), ]; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 99e9e171..771cd124 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import path from 'path'; -import { defineConfig } from 'vite'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { defineConfig, type PreviewServer, type ViteDevServer } from 'vite'; import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -30,11 +31,195 @@ function vitePluginScriptPriority() { }; } +interface TimelineConfig { + num_bins: number; + start: number; + end: number; +} + +interface BinnedSpanSec { + span: { + start: number; + end: number; + }; + bin_duration: number; + num_bins: number; +} + +interface SingleTimelineResponse { + config: BinnedSpanSec; + data: + | { + Binned: { + config: BinnedSpanSec; + capacities_values: Record; + long_fsms: unknown[]; + }; + } + | { + BinnedByState: { + config: BinnedSpanSec; + capacities_states_values: Record< + string, + Record | undefined + >; + long_fsms: unknown[]; + }; + }; +} + +interface QueryProfileDiffTimelineRequest { + timelines: unknown[]; + delta_config: TimelineConfig; +} + +const QUERY_A_HIGHER_SERIES = 'Query A higher'; +const QUERY_B_HIGHER_SERIES = 'Query B higher'; + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + }); +} + +function toBinnedSpanSec(config: TimelineConfig): BinnedSpanSec { + const numBins = Math.max(1, Math.trunc(Number(config.num_bins))); + const start = Number(config.start); + const end = Number(config.end); + + return { + span: { start, end }, + bin_duration: end > start ? (end - start) / numBins : 0, + num_bins: numBins, + }; +} + +function timelineValueArrays(response: SingleTimelineResponse): number[][] { + if ('Binned' in response.data) { + return Object.values(response.data.Binned.capacities_values).filter( + (values): values is number[] => Array.isArray(values) + ); + } + + return Object.values(response.data.BinnedByState.capacities_states_values).flatMap(states => + Object.values(states ?? {}).filter((values): values is number[] => Array.isArray(values)) + ); +} + +function sampleAggregateAt(response: SingleTimelineResponse, targetSeconds: number): number { + const { bin_duration: binDuration, span, num_bins: numBins } = response.config; + if (binDuration <= 0 || targetSeconds < span.start) return 0; + + const index = Math.floor((targetSeconds - span.start) / binDuration); + if (index < 0 || index >= numBins) return 0; + + return timelineValueArrays(response).reduce((sum, values) => sum + (values[index] ?? 0), 0); +} + +async function fetchSingleTimelineFromTarget( + engineId: string, + request: unknown +): Promise { + const response = await fetch(`${API_TARGET}/api/engines/${engineId}/timeline/single`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`single timeline fetch failed: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as SingleTimelineResponse; +} + +function writeJson(res: ServerResponse, statusCode: number, body: unknown) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +} + +function installTimelineDiffMock(server: ViteDevServer | PreviewServer) { + server.middlewares.use(async (req, res, next) => { + if (req.method !== 'POST' || !req.url) { + next(); + return; + } + + const match = req.url.match(/^\/api\/engines\/([^/]+)\/timeline\/diff(?:\?|$)/); + if (!match) { + next(); + return; + } + + try { + const engineId = decodeURIComponent(match[1]!); + const body = JSON.parse(await readRequestBody(req)) as QueryProfileDiffTimelineRequest; + if (body.timelines.length < 2) { + throw new Error('timeline diff requires at least two timeline requests'); + } + + const timelines = await Promise.all( + body.timelines.map(timelineRequest => + fetchSingleTimelineFromTarget(engineId, timelineRequest) + ) + ); + const [queryA, queryB] = timelines; + const deltaConfig = toBinnedSpanSec(body.delta_config); + const queryAHigher: number[] = []; + const queryBHigher: number[] = []; + + for (let index = 0; index < deltaConfig.num_bins; index += 1) { + const timestamp = deltaConfig.span.start + index * deltaConfig.bin_duration; + const delta = sampleAggregateAt(queryA, timestamp) - sampleAggregateAt(queryB, timestamp); + queryAHigher.push(Math.max(delta, 0)); + queryBHigher.push(Math.max(-delta, 0)); + } + + writeJson(res, 200, { + timelines, + delta: { + config: deltaConfig, + data: { + Binned: { + config: deltaConfig, + capacities_values: { + [QUERY_A_HIGHER_SERIES]: queryAHigher, + [QUERY_B_HIGHER_SERIES]: queryBHigher, + }, + long_fsms: [], + }, + }, + }, + }); + } catch (error) { + writeJson(res, 500, { + error: error instanceof Error ? error.message : 'Failed to mock timeline diff', + }); + } + }); +} + +function vitePluginTimelineDiffMock() { + return { + name: 'vite-plugin-timeline-diff-mock', + configureServer: installTimelineDiffMock, + configurePreviewServer: installTimelineDiffMock, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), vitePluginScriptPriority(), + vitePluginTimelineDiffMock(), TanStackRouterVite({ routeFileIgnorePattern: '.test.|.spec.', }), From 81e7cf180faf110f5dbd22112350d9474b95a9b5 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 13:00:05 -0600 Subject: [PATCH 06/33] Adding stat cards at the top --- ui/packages/@quent/components/src/index.ts | 11 + .../src/stat-card/StatisticCard.tsx | 203 ++++++++++++++++++ .../query-diff/QueryDiffColors.test.ts | 39 ++++ .../components/query-diff/QueryDiffColors.ts | 45 ++++ .../components/query-diff/QueryDiffStats.tsx | 173 +++++++++++++++ .../query-diff/QueryDiffStats.utils.test.ts | 69 ++++++ .../query-diff/QueryDiffStats.utils.ts | 78 +++++++ .../query-diff/QueryDiffTable.test.tsx | 5 +- .../components/query-diff/QueryDiffTable.tsx | 10 +- .../query-diff/QueryDiffTable.utils.ts | 8 +- .../query-diff/QueryDiffTimeline.tsx | 36 +++- .../QueryDiffTimeline.utils.test.ts | 6 + .../query-diff/QueryDiffTimeline.utils.ts | 41 +++- ui/src/pages/DiffSelectionPage.tsx | 6 + 14 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 ui/packages/@quent/components/src/stat-card/StatisticCard.tsx create mode 100644 ui/src/components/query-diff/QueryDiffColors.test.ts create mode 100644 ui/src/components/query-diff/QueryDiffColors.ts create mode 100644 ui/src/components/query-diff/QueryDiffStats.tsx create mode 100644 ui/src/components/query-diff/QueryDiffStats.utils.test.ts create mode 100644 ui/src/components/query-diff/QueryDiffStats.utils.ts diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 45a2a059..6b474aaa 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -83,6 +83,17 @@ export { TableCaption, } from './ui/table'; +// ─── Statistic card components ─────────────────────────────────────────────── +export { StatisticCard, StatisticMiniBarChart } from './stat-card/StatisticCard'; +export type { + StatisticCardComparison, + StatisticCardProps, + StatisticCardValueTone, + StatisticMiniBarChartBar, + StatisticMiniBarChartProps, + StatisticMiniBarChartRow, +} from './stat-card/StatisticCard'; + // ─── ECharts ────────────────────────────────────────────────────────────────── export { echarts } from './lib/echarts'; export type { EChartsOption } from './lib/echarts'; diff --git a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx new file mode 100644 index 00000000..4a1ae6fd --- /dev/null +++ b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CSSProperties, ReactNode } from 'react'; +import { cn } from '@quent/utils'; +import { DataText } from '../ui/data-text'; + +export type StatisticCardValueTone = 'positive' | 'negative' | 'neutral'; + +export interface StatisticCardComparison { + id: string; + label: ReactNode; + value: ReactNode; + color?: string; +} + +export interface StatisticCardProps { + title: ReactNode; + value: ReactNode; + valueTone?: StatisticCardValueTone; + valueStyle?: CSSProperties; + secondaryValue?: ReactNode; + comparisons?: StatisticCardComparison[]; + comparisonSeparator?: ReactNode; + chart?: ReactNode; + chartLabel?: ReactNode; + valueClassName?: string; + className?: string; +} + +export interface StatisticMiniBarChartBar { + id: string; + value: number; + color: string; + label?: string; +} + +export interface StatisticMiniBarChartRow { + id: string; + label: ReactNode; + bars: StatisticMiniBarChartBar[]; + title?: string; + labelColor?: string; +} + +export interface StatisticMiniBarChartProps { + rows: StatisticMiniBarChartRow[]; + maxRows?: number; + className?: string; +} + +function valueToneClassName(tone: StatisticCardValueTone): string { + switch (tone) { + case 'positive': + return 'text-emerald-600 dark:text-emerald-400'; + case 'negative': + return 'text-destructive'; + case 'neutral': + return 'text-muted-foreground'; + } +} + +function valueBarWidth(value: number, maxValue: number): string { + if (maxValue <= 0) return '0%'; + return `${Math.max(2, (Math.max(0, value) / maxValue) * 100)}%`; +} + +export function StatisticMiniBarChart({ + rows, + maxRows = 5, + className, +}: StatisticMiniBarChartProps) { + const visibleRows = rows.slice(0, maxRows); + const maxValue = Math.max( + ...visibleRows.flatMap(row => row.bars.map(bar => Math.max(0, bar.value))), + 0 + ); + + return ( +
+ {visibleRows.map(row => ( +
+ + {row.label} + +
+ {row.bars.map(bar => ( +
+
+
+ ))} +
+
+ ))} +
+ ); +} + +export function StatisticCard({ + title, + value, + valueTone = 'neutral', + valueStyle, + secondaryValue, + comparisons = [], + comparisonSeparator, + chart, + chartLabel, + valueClassName, + className, +}: StatisticCardProps) { + const hasSupportingContent = comparisons.length > 0 || chart != null; + + return ( +
+
+
+

+ {title} +

+
+ {secondaryValue != null && ( +
{secondaryValue}
+ )} +
+ +
+
+ {value} +
+
+ + {hasSupportingContent && ( +
+ {comparisons.length > 0 && ( +
+ {comparisons.map((comparison, index) => ( +
+ {index > 0 && comparisonSeparator && ( +
{comparisonSeparator}
+ )} +
+
+ {comparison.color && ( + + )} + {comparison.label} +
+
+ {comparison.value} +
+
+
+ ))} +
+ )} + + {chart && ( +
0 && 'mt-1.5')} + > + {chartLabel && ( +
+ {chartLabel} +
+ )} + {chart} +
+ )} +
+ )} +
+ ); +} diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts new file mode 100644 index 00000000..e7139730 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from 'vitest'; +import { resetColorAssignments } from '@quent/utils'; +import { + DIFF_NEGATIVE_COLOR, + DIFF_POSITIVE_COLOR, + getQueryDiffQueryColors, +} from './QueryDiffColors'; + +describe('QueryDiffColors', () => { + afterEach(() => resetColorAssignments()); + + it('uses the Tol palette green and red for diff values', () => { + expect(DIFF_POSITIVE_COLOR).toBe('#44AA99'); + expect(DIFF_NEGATIVE_COLOR).toBe('#CC6677'); + }); + + it('assigns distinct palette colors to the compared queries', () => { + const colors = getQueryDiffQueryColors({ + queryAId: 'query-a', + queryBId: 'query-b', + theme: 'light', + }); + + expect(colors.queryA).not.toBe(colors.queryB); + }); + + it('keeps colors distinct when the same query id is compared', () => { + const colors = getQueryDiffQueryColors({ + queryAId: 'query-a', + queryBId: 'query-a', + theme: 'light', + }); + + expect(colors.queryA).not.toBe(colors.queryB); + }); +}); diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts new file mode 100644 index 00000000..98fd7095 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getColorByIndex, + getOperationTypeColor, + getPalette, + type PaletteTheme, +} from '@quent/utils'; + +const TOL_GREEN_INDEX = 0; +const TOL_RED_INDEX = 1; + +export function getDiffPositiveColor(theme: PaletteTheme): string { + return getPalette('extended', theme)[TOL_GREEN_INDEX]!; +} + +export function getDiffNegativeColor(theme: PaletteTheme): string { + return getPalette('extended', theme)[TOL_RED_INDEX]!; +} + +export const DIFF_POSITIVE_COLOR = getDiffPositiveColor('light'); +export const DIFF_NEGATIVE_COLOR = getDiffNegativeColor('light'); + +export interface QueryDiffQueryColors { + queryA: string; + queryB: string; +} + +export function getQueryDiffQueryColors({ + theme, +}: { + queryAId: string; + queryBId: string; + theme: PaletteTheme; +}): QueryDiffQueryColors { + return { + queryA: getColorByIndex(5, theme), + queryB: getColorByIndex(4, theme), + }; +} + +export function getQueryDiffOperatorTypeColor(operatorType: string): string { + return getOperationTypeColor(operatorType.toLowerCase()); +} diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx new file mode 100644 index 00000000..7b216526 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, type CSSProperties } from 'react'; +import { Triangle } from 'lucide-react'; +import type { QueryProfileDiffResponse } from '@quent/client'; +import { + StatisticCard, + StatisticMiniBarChart, + type StatisticCardComparison, + type StatisticMiniBarChartRow, +} from '@quent/components'; +import type { EntityRef, PaletteTheme, QueryBundle } from '@quent/utils'; +import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; +import { + getDiffNegativeColor, + getDiffPositiveColor, + getQueryDiffOperatorTypeColor, + getQueryDiffQueryColors, + type QueryDiffQueryColors, +} from './QueryDiffColors'; +import { + buildOperatorTypeRuntimeComparisons, + buildRuntimeComparison, + formatDurationSeconds, + formatPercentDelta, + formatSignedDurationSeconds, + sumRuntimeComparisons, + type OperatorTypeRuntimeComparison, + type RuntimeComparison, +} from './QueryDiffStats.utils'; + +interface QueryDiffStatsProps { + diff: QueryProfileDiffResponse; + queryABundle: QueryBundle; + queryBBundle: QueryBundle; +} + +function runtimeValueStyle(delta: number, paletteTheme: PaletteTheme): CSSProperties | undefined { + if (delta > 0) return { color: getDiffPositiveColor(paletteTheme) }; + if (delta < 0) return { color: getDiffNegativeColor(paletteTheme) }; + return undefined; +} + +function runtimeComparisons({ + comparison, + queryAName, + queryBName, + queryColors, +}: { + comparison: RuntimeComparison; + queryAName: string; + queryBName: string; + queryColors: QueryDiffQueryColors; +}): StatisticCardComparison[] { + return [ + { + id: 'a', + label: queryAName, + value: formatDurationSeconds(comparison.a), + color: queryColors.queryA, + }, + { + id: 'b', + label: queryBName, + value: formatDurationSeconds(comparison.b), + color: queryColors.queryB, + }, + ]; +} + +function operatorRuntimeChartRows( + comparisons: OperatorTypeRuntimeComparison[], + queryColors: QueryDiffQueryColors +): StatisticMiniBarChartRow[] { + return comparisons.map(comparison => ({ + id: comparison.id, + label: comparison.label, + labelColor: getQueryDiffOperatorTypeColor(comparison.id), + title: formatDurationSeconds(Math.max(comparison.a, comparison.b)), + bars: [ + { id: 'a', value: comparison.a, color: queryColors.queryA, label: 'First comparison value' }, + { + id: 'b', + value: comparison.b, + color: queryColors.queryB, + label: 'Second comparison value', + }, + ], + })); +} + +export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffStatsProps) { + const { theme } = useTheme(); + const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; + const queryAName = diff.query_a.instance_name ?? diff.query_a.id; + const queryBName = diff.query_b.instance_name ?? diff.query_b.id; + const queryColors = useMemo( + () => + getQueryDiffQueryColors({ + queryAId: diff.query_a.id, + queryBId: diff.query_b.id, + theme: paletteTheme, + }), + [diff.query_a.id, diff.query_b.id, paletteTheme] + ); + const operatorRuntimeComparisons = useMemo( + () => buildOperatorTypeRuntimeComparisons(diff), + [diff] + ); + const totalRuntimeComparison = useMemo( + () => buildRuntimeComparison(queryABundle.duration_s, queryBBundle.duration_s), + [queryABundle.duration_s, queryBBundle.duration_s] + ); + const operatorRuntimeComparison = useMemo( + () => sumRuntimeComparisons(operatorRuntimeComparisons), + [operatorRuntimeComparisons] + ); + + return ( +
+
+ + } + /> + {operatorRuntimeComparisons.length > 0 && ( + + } + chartLabel="By operator type" + chart={ + + } + /> + )} +
+
+ ); +} diff --git a/ui/src/components/query-diff/QueryDiffStats.utils.test.ts b/ui/src/components/query-diff/QueryDiffStats.utils.test.ts new file mode 100644 index 00000000..ef69ff48 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffStats.utils.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { equalPlanQueryProfileDiffFixture } from '@/test/mocks/queryProfileDiffFixtures'; +import { + buildOperatorTypeRuntimeComparisons, + buildRuntimeComparison, + formatPercentDelta, + formatSignedDurationSeconds, + sumRuntimeComparisons, +} from './QueryDiffStats.utils'; + +describe('QueryDiffStats helpers', () => { + it('builds runtime comparisons with signed deltas', () => { + expect(buildRuntimeComparison(12, 10)).toEqual({ + a: 12, + b: 10, + delta: 2, + percentDelta: 0.2, + }); + }); + + it('extracts sorted per-operator-type runtime comparisons', () => { + const comparisons = buildOperatorTypeRuntimeComparisons(equalPlanQueryProfileDiffFixture); + + expect(comparisons.map(comparison => comparison.label)).toEqual(['Join', 'Scan', 'Aggregate']); + expect(sumRuntimeComparisons(comparisons)).toMatchObject({ a: 40, b: 44, delta: -4 }); + }); + + it('groups runtime comparisons by operator type', () => { + const comparisons = buildOperatorTypeRuntimeComparisons({ + ...equalPlanQueryProfileDiffFixture, + operator_diffs: [ + ...equalPlanQueryProfileDiffFixture.operator_diffs, + { + operator_a: { + id: 'scan-extra-a', + label: 'Scan customers', + operator_type_name: 'Scan', + plan_id: 'plan-a', + }, + operator_b: { + id: 'scan-extra-b', + label: 'Scan customers', + operator_type_name: 'Scan', + plan_id: 'plan-b', + }, + stats: { + duration_s: { a: 3, b: 2, delta: 1, percent_delta: 0.5 }, + }, + }, + ], + }); + + expect(comparisons.find(comparison => comparison.id === 'Scan')).toMatchObject({ + a: 15, + b: 12, + delta: 3, + }); + }); + + it('formats duration and percent deltas for cards', () => { + expect(formatSignedDurationSeconds(2)).toBe('+2.00s'); + expect(formatSignedDurationSeconds(-0.5)).toBe('-500.00ms'); + expect(formatPercentDelta(0.2)).toBe('+20.0%'); + expect(formatPercentDelta(null)).toBe('-'); + }); +}); diff --git a/ui/src/components/query-diff/QueryDiffStats.utils.ts b/ui/src/components/query-diff/QueryDiffStats.utils.ts new file mode 100644 index 00000000..49fc2174 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffStats.utils.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { QueryProfileDiffResponse } from '@quent/client'; +import { formatDuration } from '@quent/utils'; + +export interface RuntimeComparison { + a: number; + b: number; + delta: number; + percentDelta: number | null; +} + +export interface OperatorTypeRuntimeComparison extends RuntimeComparison { + id: string; + label: string; +} + +export function buildRuntimeComparison(a: number, b: number): RuntimeComparison { + const delta = a - b; + return { + a, + b, + delta, + percentDelta: b === 0 ? null : delta / Math.abs(b), + }; +} + +export function buildOperatorTypeRuntimeComparisons( + diff: QueryProfileDiffResponse +): OperatorTypeRuntimeComparison[] { + const totalsByOperatorType = new Map(); + + for (const entry of diff.operator_diffs) { + if (!entry.operator_a || !entry.operator_b) continue; + const duration = entry.stats.duration_s; + if (typeof duration?.a !== 'number' || typeof duration.b !== 'number') continue; + + const operatorType = + entry.operator_a.operator_type_name ?? entry.operator_b.operator_type_name ?? 'Unknown'; + const totals = totalsByOperatorType.get(operatorType) ?? { a: 0, b: 0 }; + totals.a += duration.a; + totals.b += duration.b; + totalsByOperatorType.set(operatorType, totals); + } + + return [...totalsByOperatorType.entries()] + .map(([operatorType, totals]) => ({ + ...buildRuntimeComparison(totals.a, totals.b), + id: operatorType, + label: operatorType, + })) + .sort((left, right) => Math.max(right.a, right.b) - Math.max(left.a, left.b)); +} + +export function sumRuntimeComparisons(entries: RuntimeComparison[]): RuntimeComparison { + return buildRuntimeComparison( + entries.reduce((sum, entry) => sum + entry.a, 0), + entries.reduce((sum, entry) => sum + entry.b, 0) + ); +} + +export function formatDurationSeconds(seconds: number): string { + return formatDuration(seconds * 1_000); +} + +export function formatSignedDurationSeconds(seconds: number): string { + if (seconds === 0 || Object.is(seconds, -0)) return formatDurationSeconds(0); + const formatted = formatDurationSeconds(seconds); + return seconds > 0 ? `+${formatted}` : formatted; +} + +export function formatPercentDelta(percentDelta: number | null): string { + if (percentDelta === null) return '-'; + if (percentDelta === 0 || Object.is(percentDelta, -0)) return '0.0%'; + const formatted = `${(percentDelta * 100).toFixed(1)}%`; + return percentDelta > 0 ? `+${formatted}` : formatted; +} diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index f84208db..dfad87e5 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -8,6 +8,7 @@ import { getDeltaCellStyle, } from './QueryDiffTable.utils'; import { equalPlanQueryProfileDiffFixture } from '@/test/mocks/queryProfileDiffFixtures'; +import { DIFF_NEGATIVE_COLOR, DIFF_POSITIVE_COLOR } from './QueryDiffColors'; describe('QueryDiffTable helpers', () => { it('converts matched operator diffs into pivot rows', () => { @@ -41,8 +42,8 @@ describe('QueryDiffTable helpers', () => { }); it('returns diverging styles for positive and negative deltas only', () => { - expect(getDeltaCellStyle(5, 10)?.backgroundColor).toContain('#14b8a6'); - expect(getDeltaCellStyle(-5, 10)?.backgroundColor).toContain('#ef4444'); + expect(getDeltaCellStyle(5, 10)?.backgroundColor).toContain(DIFF_POSITIVE_COLOR); + expect(getDeltaCellStyle(-5, 10)?.backgroundColor).toContain(DIFF_NEGATIVE_COLOR); expect(getDeltaCellStyle(0, 10)).toBeUndefined(); expect(getDeltaCellStyle(null, 10)).toBeUndefined(); }); diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index a0124a37..7dbbaf28 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -16,8 +16,8 @@ import { } from '@quent/components'; import { useStatGroupTableControls } from '@quent/hooks'; import type { QueryProfileDiffResponse } from '@quent/client'; -import { getOperationTypeColor } from '@quent/utils'; import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; +import { getQueryDiffOperatorTypeColor } from './QueryDiffColors'; import { buildMaxAbsByStat, buildQueryDiffRows, @@ -54,7 +54,7 @@ const DEFAULT_ENABLED: Record = { const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; const getOperatorTypeColor = (key: string, id: string): string | undefined => - key === 'operator_type' ? getOperationTypeColor(id.toLowerCase()) : undefined; + key === 'operator_type' ? getQueryDiffOperatorTypeColor(id) : undefined; function OperatorPairCell({ row }: { row: QueryDiffTableRow }) { return ( @@ -87,6 +87,7 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { const [hoveredStat, setHoveredStat] = useState(null); const { theme } = useTheme(); const isDark = theme === THEME_DARK; + const paletteTheme = isDark ? 'dark' : 'light'; const { aggMode, @@ -150,10 +151,11 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { const row = rowsByOperatorPairId.get(groupKey.id); return row ? : groupKey.label; }, - getDataCellStyle: ({ stat, value }) => getDeltaCellStyle(value, maxAbsByStat.get(stat)), + getDataCellStyle: ({ stat, value }) => + getDeltaCellStyle(value, maxAbsByStat.get(stat), paletteTheme), formatDataCellValue: ({ stat, value }) => formatSignedDiffValue(value, stat), }), - [maxAbsByStat, rowsByOperatorPairId] + [maxAbsByStat, paletteTheme, rowsByOperatorPairId] ); if (diff.scenario !== 'plans_equal') { diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index 49e570f7..3331df21 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { QueryProfileDiffResponse } from '@quent/client'; -import type { StatValue } from '@quent/utils'; +import type { PaletteTheme, StatValue } from '@quent/utils'; import { formatStatValue } from '@quent/components'; +import { getDiffNegativeColor, getDiffPositiveColor } from './QueryDiffColors'; export interface QueryDiffTableRow { operatorType: string; @@ -62,12 +63,13 @@ export function formatSignedDiffValue(value: StatValue, statName: string): strin export function getDeltaCellStyle( value: StatValue, - maxAbs: number | undefined + maxAbs: number | undefined, + theme: PaletteTheme = 'light' ): React.CSSProperties | undefined { if (typeof value !== 'number' || value === 0 || !maxAbs) return undefined; const intensity = Math.min(1, Math.abs(value) / maxAbs); const mix = Math.round(14 + intensity * 42); - const color = value > 0 ? '#14b8a6' : '#ef4444'; + const color = value > 0 ? getDiffPositiveColor(theme) : getDiffNegativeColor(theme); return { backgroundColor: `color-mix(in srgb, ${color} ${mix}%, hsl(var(--card)))`, }; diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 60bf1fc9..fd509db5 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -36,6 +36,11 @@ import { type TaskFilter, } from '@quent/utils'; import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; +import { + getDiffNegativeColor, + getDiffPositiveColor, + getQueryDiffQueryColors, +} from './QueryDiffColors'; import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { @@ -108,11 +113,13 @@ function buildRootTimelineRequest({ function TimelineLane({ label, detail, + color, children, className, }: { label: string; detail?: React.ReactNode; + color?: string; children: React.ReactNode; className?: string; }) { @@ -125,7 +132,12 @@ function TimelineLane({ style={{ height: TIMELINE_ROW_HEIGHT }} >
- {label} + + {color && ( + + )} + {label} + {detail && {detail}}
{children}
@@ -147,6 +159,12 @@ export function QueryDiffTimeline({ const queryAId = diff.query_a.id; const queryBId = diff.query_b.id; + const queryColors = useMemo( + () => getQueryDiffQueryColors({ queryAId, queryBId, theme: paletteTheme }), + [paletteTheme, queryAId, queryBId] + ); + const diffPositiveColor = getDiffPositiveColor(paletteTheme); + const diffNegativeColor = getDiffNegativeColor(paletteTheme); const targetA = useMemo(() => getTimelineTarget(queryABundle), [queryABundle]); const targetB = useMemo(() => getTimelineTarget(queryBBundle), [queryBBundle]); @@ -231,6 +249,7 @@ export function QueryDiffTimeline({ capacities: resourceTypeDecl?.capacities, quantitySpecs: queryABundle.quantity_specs ?? queryBBundle.quantity_specs, fsmTypes: queryABundle.entities.fsm_types ?? queryBBundle.entities.fsm_types, + queryColors, }); }, [ durationSeconds, @@ -241,6 +260,7 @@ export function QueryDiffTimeline({ queryBBundle.entities.fsm_types, queryBBundle.entities.resource_types, queryBBundle.quantity_specs, + queryColors, resourceType, timelineDiff.data, ]); @@ -275,10 +295,18 @@ export function QueryDiffTimeline({ {comparison && (
- A higher + + A higher - B higher + + B higher
)} @@ -343,6 +371,7 @@ export function QueryDiffTimeline({
{diff.query_a.instance_name ?? queryAId}} > {diff.query_b.instance_name ?? queryBId}} > ): SingleTimelineResponse { @@ -39,11 +40,16 @@ describe('buildDiffTimelineData', () => { const data = buildDiffTimelineData({ timelineDiff: response, theme: 'light', + queryColors: { queryA: '#0072B2', queryB: '#E69F00' }, }); expect(data.queryA.series.slots?.values).toEqual([100, 100]); expect(data.queryB.series.slots?.values).toEqual([0, 0]); expect(data.delta.series['Query A higher']?.values).toEqual([2, 0]); expect(data.delta.series['Query B higher']?.values).toEqual([0, 3]); + expect(data.queryA.series.slots?.color).toBe('#0072B2'); + expect(data.queryB.series.slots?.color).toBe('#E69F00'); + expect(data.delta.series['Query A higher']?.color).toBe(DIFF_POSITIVE_COLOR); + expect(data.delta.series['Query B higher']?.color).toBe(DIFF_NEGATIVE_COLOR); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index a4fa06f6..eda61845 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -4,9 +4,12 @@ import { buildBinnedTimelineSeries, type TimelineSeries } from '@quent/components'; import type { QueryProfileDiffTimelineResponse } from '@quent/client'; import type { CapacityDecl, FsmTypeDecl, PaletteTheme, QuantitySpec } from '@quent/utils'; +import { + getDiffNegativeColor, + getDiffPositiveColor, + type QueryDiffQueryColors, +} from './QueryDiffColors'; -const QUERY_A_HIGHER_COLOR = '#CC6677'; -const QUERY_B_HIGHER_COLOR = '#44AA99'; const QUERY_A_HIGHER_LABEL = 'Query A higher'; const QUERY_B_HIGHER_LABEL = 'Query B higher'; @@ -27,6 +30,7 @@ interface BuildDiffTimelineDataParams { capacities?: CapacityDecl[]; quantitySpecs?: { [key in string]?: QuantitySpec }; fsmTypes?: { [key in string]?: FsmTypeDecl }; + queryColors: QueryDiffQueryColors; } function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { @@ -41,12 +45,16 @@ function formatDeltaSeries({ delta, queryA, queryB, + theme, }: { delta: TimelineRowData; queryA: TimelineRowData; queryB: TimelineRowData; + theme: PaletteTheme; }): TimelineSeries { const formatter = getFirstFormatter(queryA.series, queryB.series); + const positiveColor = getDiffPositiveColor(theme); + const negativeColor = getDiffNegativeColor(theme); return Object.fromEntries( Object.entries(delta.series).map(([name, entry]) => [ name, @@ -54,9 +62,9 @@ function formatDeltaSeries({ ...entry, color: name === QUERY_A_HIGHER_LABEL - ? QUERY_A_HIGHER_COLOR + ? positiveColor : name === QUERY_B_HIGHER_LABEL - ? QUERY_B_HIGHER_COLOR + ? negativeColor : entry.color, formatter, }, @@ -64,12 +72,25 @@ function formatDeltaSeries({ ); } +function recolorTimelineSeries(series: TimelineSeries, color: string): TimelineSeries { + return Object.fromEntries( + Object.entries(series).map(([name, entry]) => [ + name, + { + ...entry, + color, + }, + ]) + ); +} + export function buildDiffTimelineData({ timelineDiff, theme, capacities, quantitySpecs, fsmTypes, + queryColors, }: BuildDiffTimelineDataParams): DiffTimelineData { const [queryATimeline, queryBTimeline] = timelineDiff.timelines; const queryA = buildBinnedTimelineSeries( @@ -98,11 +119,17 @@ export function buildDiffTimelineData({ ); return { - queryA, - queryB, + queryA: { + ...queryA, + series: recolorTimelineSeries(queryA.series, queryColors.queryA), + }, + queryB: { + ...queryB, + series: recolorTimelineSeries(queryB.series, queryColors.queryB), + }, delta: { timestamps: delta.timestamps, - series: formatDeltaSeries({ delta, queryA, queryB }), + series: formatDeltaSeries({ delta, queryA, queryB, theme }), }, }; } diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 1d52d92a..b6aa4d84 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -26,6 +26,7 @@ import { } from '@quent/components'; import { cn } from '@quent/utils'; import { QueryDiffTable } from '@/components/query-diff/QueryDiffTable'; +import { QueryDiffStats } from '@/components/query-diff/QueryDiffStats'; import { QueryDiffTimeline } from '@/components/query-diff/QueryDiffTimeline'; import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryProfileDiffFromBundles'; @@ -467,6 +468,11 @@ export function DiffSelectionPage({
) : diff && queryABundle.data && queryBBundle.data ? (
+ Date: Tue, 19 May 2026 13:17:34 -0600 Subject: [PATCH 07/33] UI Fixups --- ui/src/components/query-diff/QueryDiffStats.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index 7b216526..38d831dc 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -162,7 +162,8 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt chart={ } /> From 8f986c3536068a47fde938fa841282edefdbc14f Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 13:25:14 -0600 Subject: [PATCH 08/33] Color query names --- .../src/stat-card/StatisticCard.tsx | 40 +++++++++++-------- .../components/query-diff/QueryDiffStats.tsx | 24 +---------- ui/src/pages/DiffSelectionPage.tsx | 23 ++++++++++- 3 files changed, 46 insertions(+), 41 deletions(-) diff --git a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx index 4a1ae6fd..28d9e35d 100644 --- a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx +++ b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx @@ -16,7 +16,7 @@ export interface StatisticCardComparison { export interface StatisticCardProps { title: ReactNode; - value: ReactNode; + value?: ReactNode; valueTone?: StatisticCardValueTone; valueStyle?: CSSProperties; secondaryValue?: ReactNode; @@ -118,12 +118,14 @@ export function StatisticCard({ valueClassName, className, }: StatisticCardProps) { + const hasValue = value != null; const hasSupportingContent = comparisons.length > 0 || chart != null; return (
@@ -138,26 +140,28 @@ export function StatisticCard({ )}
-
+ {hasValue && (
- {value} +
+ {value} +
-
+ )} {hasSupportingContent && ( -
+
{comparisons.length > 0 && (
{comparisons.map((comparison, index) => ( @@ -186,7 +190,11 @@ export function StatisticCard({ {chart && (
0 && 'mt-1.5')} + className={cn( + !hasValue && comparisons.length === 0 && 'h-full min-h-0', + (hasValue || comparisons.length > 0) && 'border-t border-border pt-1.5', + comparisons.length > 0 && 'mt-1.5' + )} > {chartLabel && (
diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index 38d831dc..da7772b7 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -25,7 +25,6 @@ import { formatDurationSeconds, formatPercentDelta, formatSignedDurationSeconds, - sumRuntimeComparisons, type OperatorTypeRuntimeComparison, type RuntimeComparison, } from './QueryDiffStats.utils'; @@ -112,10 +111,6 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt () => buildRuntimeComparison(queryABundle.duration_s, queryBBundle.duration_s), [queryABundle.duration_s, queryBBundle.duration_s] ); - const operatorRuntimeComparison = useMemo( - () => sumRuntimeComparisons(operatorRuntimeComparisons), - [operatorRuntimeComparisons] - ); return (
@@ -142,28 +137,11 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt {operatorRuntimeComparisons.length > 0 && ( - } - chartLabel="By operator type" chart={ } /> diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index b6aa4d84..e217d2fe 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -28,7 +28,9 @@ import { cn } from '@quent/utils'; import { QueryDiffTable } from '@/components/query-diff/QueryDiffTable'; import { QueryDiffStats } from '@/components/query-diff/QueryDiffStats'; import { QueryDiffTimeline } from '@/components/query-diff/QueryDiffTimeline'; +import { getQueryDiffQueryColors } from '@/components/query-diff/QueryDiffColors'; import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryProfileDiffFromBundles'; +import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; interface DiffSelectionPageProps { initialEngineId?: string; @@ -191,6 +193,8 @@ export function DiffSelectionPage({ initialQueryBId = '', }: DiffSelectionPageProps) { const navigate = useNavigate(); + const { theme } = useTheme(); + const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; const [engineId, setEngineId] = useState(initialEngineId); const [queryA, setQueryA] = useState({ groupId: '', queryId: initialQueryAId }); const [queryB, setQueryB] = useState({ groupId: '', queryId: initialQueryBId }); @@ -258,6 +262,15 @@ export function DiffSelectionPage({ () => queryDisplayLabel(queryB.queryId, queriesByGroup, 'Select Query B'), [queryB.queryId, queriesByGroup] ); + const queryColors = useMemo( + () => + getQueryDiffQueryColors({ + queryAId: queryA.queryId, + queryBId: queryB.queryId, + theme: paletteTheme, + }), + [paletteTheme, queryA.queryId, queryB.queryId] + ); const canSwapQueries = Boolean( engineId && (queryA.groupId || queryA.queryId || queryB.groupId || queryB.queryId) ); @@ -350,11 +363,17 @@ export function DiffSelectionPage({ Query Diff - + {queryASummary} vs - + {queryBSummary} {engineSummary && ( From 43622ea01ce822db4dc32f631b7ff6e7962072ee Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 19 May 2026 13:58:19 -0600 Subject: [PATCH 09/33] Negative is good --- .../@quent/client/src/queryProfileDiffTypes.ts | 1 + .../query-diff/QueryDiffColors.test.ts | 6 +++--- ui/src/components/query-diff/QueryDiffColors.ts | 4 ++-- ui/src/components/query-diff/QueryDiffStats.tsx | 17 ++++++++++++++--- .../query-diff/QueryDiffTable.test.tsx | 4 ++-- .../query-diff/QueryDiffTable.utils.ts | 10 +++++++++- .../components/query-diff/QueryDiffTimeline.tsx | 6 +++--- .../query-diff/QueryDiffTimeline.utils.test.ts | 4 ++-- .../query-diff/QueryDiffTimeline.utils.ts | 4 ++-- 9 files changed, 38 insertions(+), 18 deletions(-) diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index 07bb3c69..600b9616 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -53,6 +53,7 @@ export interface QueryProfileDiffPlanComparison { } export interface QueryProfileDiffResponse { + // This almost becomes a query diff bundle scenario: QueryProfileDiffScenario; query_a: QueryProfileDiffQuerySummary; query_b: QueryProfileDiffQuerySummary; diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts index e7139730..186216a0 100644 --- a/ui/src/components/query-diff/QueryDiffColors.test.ts +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -12,9 +12,9 @@ import { describe('QueryDiffColors', () => { afterEach(() => resetColorAssignments()); - it('uses the Tol palette green and red for diff values', () => { - expect(DIFF_POSITIVE_COLOR).toBe('#44AA99'); - expect(DIFF_NEGATIVE_COLOR).toBe('#CC6677'); + it('uses Tol red for positive values and Tol green for negative values', () => { + expect(DIFF_POSITIVE_COLOR).toBe('#CC6677'); + expect(DIFF_NEGATIVE_COLOR).toBe('#44AA99'); }); it('assigns distinct palette colors to the compared queries', () => { diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index 98fd7095..5461f644 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -12,11 +12,11 @@ const TOL_GREEN_INDEX = 0; const TOL_RED_INDEX = 1; export function getDiffPositiveColor(theme: PaletteTheme): string { - return getPalette('extended', theme)[TOL_GREEN_INDEX]!; + return getPalette('extended', theme)[TOL_RED_INDEX]!; } export function getDiffNegativeColor(theme: PaletteTheme): string { - return getPalette('extended', theme)[TOL_RED_INDEX]!; + return getPalette('extended', theme)[TOL_GREEN_INDEX]!; } export const DIFF_POSITIVE_COLOR = getDiffPositiveColor('light'); diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index da7772b7..b7e512ee 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -41,6 +41,15 @@ function runtimeValueStyle(delta: number, paletteTheme: PaletteTheme): CSSProper return undefined; } +function displayDelta(delta: number): number { + return delta === 0 || Object.is(delta, -0) ? 0 : -delta; +} + +function displayPercentDelta(percentDelta: number | null): number | null { + if (percentDelta === null) return null; + return percentDelta === 0 || Object.is(percentDelta, -0) ? 0 : -percentDelta; +} + function runtimeComparisons({ comparison, queryAName, @@ -117,9 +126,11 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt
{ operatorBId: 'scan-b', operatorBLabel: 'Scan orders', stats: { - duration_s: 2, - input_rows: -200, + duration_s: -2, + input_rows: 200, }, }); }); diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index 3331df21..a182b86a 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -17,6 +17,11 @@ export interface QueryDiffTableRow { stats: Record; } +function displayDeltaValue(value: StatValue): StatValue { + if (typeof value !== 'number') return value; + return value === 0 || Object.is(value, -0) ? 0 : -value; +} + function formatOperatorPairLabel( operatorALabel: string, operatorAId: string, @@ -38,7 +43,10 @@ export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTab entry.operator_b.id ); const stats = Object.fromEntries( - Object.entries(entry.stats).map(([statName, stat]) => [statName, stat.delta]) + Object.entries(entry.stats).map(([statName, stat]) => [ + statName, + displayDeltaValue(stat.delta), + ]) ); return [ { diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index fd509db5..933bdaca 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -297,14 +297,14 @@ export function QueryDiffTimeline({ - A higher + B lower B higher diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts index a30dff0f..810279eb 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -49,7 +49,7 @@ describe('buildDiffTimelineData', () => { expect(data.delta.series['Query B higher']?.values).toEqual([0, 3]); expect(data.queryA.series.slots?.color).toBe('#0072B2'); expect(data.queryB.series.slots?.color).toBe('#E69F00'); - expect(data.delta.series['Query A higher']?.color).toBe(DIFF_POSITIVE_COLOR); - expect(data.delta.series['Query B higher']?.color).toBe(DIFF_NEGATIVE_COLOR); + expect(data.delta.series['Query A higher']?.color).toBe(DIFF_NEGATIVE_COLOR); + expect(data.delta.series['Query B higher']?.color).toBe(DIFF_POSITIVE_COLOR); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index eda61845..6feaf244 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -62,9 +62,9 @@ function formatDeltaSeries({ ...entry, color: name === QUERY_A_HIGHER_LABEL - ? positiveColor + ? negativeColor : name === QUERY_B_HIGHER_LABEL - ? negativeColor + ? positiveColor : entry.color, formatter, }, From 47f38ec82c0b4265a1c0842b34dce9d92070eaba Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 10:31:55 -0600 Subject: [PATCH 10/33] Remove match_kind, redundant --- docs/domains/query_engine/query_profile_diff.md | 1 - ui/packages/@quent/client/src/queryProfileDiffTypes.ts | 2 +- ui/src/components/query-diff/QueryDiffTimeline.tsx | 2 +- ui/src/components/query-diff/queryProfileDiffFromBundles.ts | 2 -- ui/src/test/mocks/queryProfileDiffFixtures.ts | 2 -- 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md index 42edc407..7afb03a6 100644 --- a/docs/domains/query_engine/query_profile_diff.md +++ b/docs/domains/query_engine/query_profile_diff.md @@ -57,7 +57,6 @@ export interface QueryProfileDiffOperatorDelta { } export interface QueryProfileDiffPlanComparison { - match_kind: "structural" | "different" | "incomparable"; matched_operator_count: number; unmatched_operator_a_count: number; unmatched_operator_b_count: number; diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index 600b9616..91683640 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -41,12 +41,12 @@ export interface QueryProfileDiffStatDelta { export interface QueryProfileDiffOperatorDelta { operator_a: QueryProfileDiffOperatorRef | null; operator_b: QueryProfileDiffOperatorRef | null; + /* stat name -> delta values */ stats: Record; } export interface QueryProfileDiffPlanComparison { /* Big question here, how do we represent query plan graph diffs */ - match_kind: 'structural' | 'different' | 'incomparable'; matched_operator_count: number; unmatched_operator_a_count: number; unmatched_operator_b_count: number; diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 933bdaca..9ef7651a 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -293,7 +293,7 @@ export function QueryDiffTimeline({
{comparison && ( -
+
Date: Wed, 20 May 2026 11:21:57 -0600 Subject: [PATCH 11/33] Upate md --- .../query_engine/query_profile_diff.md | 80 +++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md index 7afb03a6..32e34c8d 100644 --- a/docs/domains/query_engine/query_profile_diff.md +++ b/docs/domains/query_engine/query_profile_diff.md @@ -1,16 +1,16 @@ # Query Profile Diff API -The query profile diff API compares two query profiles from the same engine. -The UI also uses this contract as its internal diff view model when it builds -query diffs client-side from real `QueryBundle` API responses. +The query profile diff APIs compare two query profiles from the same engine. +The UI also uses the profile diff contract as its internal diff view model when +it builds query diffs client-side from real `QueryBundle` API responses. -## Endpoint +## Profile Diff Endpoint ```http POST /api/engines/{engine_id}/query-profile-diff ``` -## Request +### Request ```ts export interface QueryProfileDiffRequest { @@ -21,7 +21,7 @@ export interface QueryProfileDiffRequest { Query A is the baseline. Numeric deltas are always `A - B`. -## Response +### Response ```ts export type QueryProfileDiffScenario = @@ -72,6 +72,54 @@ export interface QueryProfileDiffResponse { } ``` +`StatValue` comes from the UI utility types and may be a string, number, +boolean, null, or string array. Numeric stats include `delta` and +`percent_delta`; non-numeric or missing values use `delta: null`. +`percent_delta` is `delta / b` when B is numeric and nonzero, otherwise null. + +## Timeline Diff Endpoint + +```http +POST /api/engines/{engine_id}/timeline/diff +``` + +The timeline diff endpoint accepts two or more single-timeline requests, +returns each requested timeline, and adds a derived delta timeline. + +### Request + +```ts +export type QueryProfileDiffTimelineEntries = [T, T, ...T[]]; + +export interface QueryProfileDiffTimelineRequest { + timelines: QueryProfileDiffTimelineEntries< + SingleTimelineRequest + >; + delta_config: TimelineConfig; +} +``` + +`timelines` must contain at least Query A and Query B entries. Additional +entries may be included when the caller needs the same request/response bundle +for overlays or comparison context. `delta_config` controls the output window +and binning for the derived delta timeline. + +### Response + +```ts +export interface QueryProfileDiffTimelineResponse { + timelines: QueryProfileDiffTimelineEntries; + delta: SingleTimelineResponse; + warnings?: string[]; +} +``` + +The first response in `timelines` corresponds to Query A and the second +corresponds to Query B. The `delta` timeline is sampled into `delta_config`. +The current implementation represents the delta as binned positive magnitudes: +one series for where Query A is higher and one series for where Query B is +higher. + ## V1 Semantics - `plans_equal` means a structural match: topology plus ordered operator @@ -79,4 +127,24 @@ export interface QueryProfileDiffResponse { - `operator_diffs` contains matched operator pairs for equal plans. - Numeric stats include `delta` and optional `percent_delta`; non-numeric or missing values use `delta: null`. +- `plans_different` means operator-to-operator diffs are unavailable. The + response reports matched and unmatched counts and may include a warning. +- `plans_incomparable` is reserved for profiles that cannot be compared, such + as unsupported or missing plan data. - Different-plan aggregate rows are a planned follow-up. + +## Client Surface + +The TypeScript client exposes: + +```ts +fetchQueryProfileDiff(engineId, request) +fetchQueryProfileDiffTimeline(engineId, request) +useQueryProfileDiff(params, options?) +useQueryProfileDiffTimeline(params, options?) +``` + +The current UI can also build a `QueryProfileDiffResponse` locally from two +`QueryBundle` responses. That local path uses the same response contract so the +table, stats, and timeline views can consume either API-backed or client-built +diffs. From be0e378ca995e926f69af3d8d7054ad80b6e3f55 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 11:43:46 -0600 Subject: [PATCH 12/33] Diff engines per query --- .../query_engine/query_profile_diff.md | 36 +- ui/packages/@quent/client/src/api.ts | 6 +- ui/packages/@quent/client/src/index.ts | 2 + .../client/src/queryProfileDiff.test.ts | 62 ++-- .../@quent/client/src/queryProfileDiff.ts | 31 +- .../client/src/queryProfileDiffTypes.ts | 20 +- .../src/pivot-table/PivotedStatTable.tsx | 1 + .../query-diff/QueryDiffTable.test.tsx | 6 + .../components/query-diff/QueryDiffTable.tsx | 34 +- .../query-diff/QueryDiffTable.utils.ts | 31 ++ .../query-diff/QueryDiffTimeline.tsx | 20 +- .../query-diff/queryProfileDiffFromBundles.ts | 2 + ui/src/pages/DiffSelectionPage.tsx | 327 +++++++++++------- ...ngine.$queryBEngineId.query.$queryBId.tsx} | 9 +- ui/src/routes/diff.test.tsx | 58 ++-- ui/src/test/mocks/handlers.ts | 8 +- ui/src/test/mocks/queryProfileDiffFixtures.ts | 4 + ui/vite.config.ts | 12 +- 18 files changed, 451 insertions(+), 218 deletions(-) rename ui/src/routes/{diff.engine.$engineId.query.$queryAId.compare.$queryBId.tsx => diff.engine.$queryAEngineId.query.$queryAId.compare.engine.$queryBEngineId.query.$queryBId.tsx} (58%) diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md index 32e34c8d..19476b58 100644 --- a/docs/domains/query_engine/query_profile_diff.md +++ b/docs/domains/query_engine/query_profile_diff.md @@ -1,21 +1,28 @@ # Query Profile Diff API -The query profile diff APIs compare two query profiles from the same engine. +The query profile diff APIs compare two query profiles. The profiles may come +from different engines, so each side of the comparison carries its own engine +ID. The UI also uses the profile diff contract as its internal diff view model when it builds query diffs client-side from real `QueryBundle` API responses. ## Profile Diff Endpoint ```http -POST /api/engines/{engine_id}/query-profile-diff +POST /api/query-profile-diff ``` ### Request ```ts +export interface QueryProfileDiffQueryRef { + engine_id: string; + query_id: string; +} + export interface QueryProfileDiffRequest { - query_a_id: string; - query_b_id: string; + query_a: QueryProfileDiffQueryRef; + query_b: QueryProfileDiffQueryRef; } ``` @@ -31,6 +38,8 @@ export type QueryProfileDiffScenario = export interface QueryProfileDiffQuerySummary { id: string; + engine_id: string; + engine_name: string | null; instance_name: string | null; query_group_id?: string | null; query_group_name?: string | null; @@ -80,20 +89,29 @@ boolean, null, or string array. Numeric stats include `delta` and ## Timeline Diff Endpoint ```http -POST /api/engines/{engine_id}/timeline/diff +POST /api/timeline/diff ``` The timeline diff endpoint accepts two or more single-timeline requests, -returns each requested timeline, and adds a derived delta timeline. +returns each requested timeline, and adds a derived delta timeline. Each +timeline entry names the engine that should execute that single-timeline +request. ### Request ```ts export type QueryProfileDiffTimelineEntries = [T, T, ...T[]]; +export interface QueryProfileDiffTimelineEntry { + engine_id: string; + timeline: T; +} + export interface QueryProfileDiffTimelineRequest { timelines: QueryProfileDiffTimelineEntries< - SingleTimelineRequest + QueryProfileDiffTimelineEntry< + SingleTimelineRequest + > >; delta_config: TimelineConfig; } @@ -138,8 +156,8 @@ higher. The TypeScript client exposes: ```ts -fetchQueryProfileDiff(engineId, request) -fetchQueryProfileDiffTimeline(engineId, request) +fetchQueryProfileDiff(request) +fetchQueryProfileDiffTimeline(request) useQueryProfileDiff(params, options?) useQueryProfileDiffTimeline(params, options?) ``` diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index de2aa2dc..9de951e8 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -112,10 +112,9 @@ export async function fetchBulkTimelines( } export async function fetchQueryProfileDiff( - engineId: string, request: QueryProfileDiffRequest ): Promise { - return apiFetch(`/engines/${engineId}/query-profile-diff`, { + return apiFetch('/query-profile-diff', { fetchOptions: { method: 'POST', body: JSON.stringify(request), @@ -124,10 +123,9 @@ export async function fetchQueryProfileDiff( } export async function fetchQueryProfileDiffTimeline( - engineId: string, request: QueryProfileDiffTimelineRequest ): Promise { - return apiFetch(`/engines/${engineId}/timeline/diff`, { + return apiFetch('/timeline/diff', { fetchOptions: { method: 'POST', body: JSON.stringify(request), diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index f558a26b..831c4b15 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -41,11 +41,13 @@ export type { QueryProfileDiffOperatorDelta, QueryProfileDiffOperatorRef, QueryProfileDiffPlanComparison, + QueryProfileDiffQueryRef, QueryProfileDiffQuerySummary, QueryProfileDiffRequest, QueryProfileDiffResponse, QueryProfileDiffScenario, QueryProfileDiffStatDelta, + QueryProfileDiffTimelineEntry, QueryProfileDiffTimelineEntries, QueryProfileDiffTimelineRequest, QueryProfileDiffTimelineResponse, diff --git a/ui/packages/@quent/client/src/queryProfileDiff.test.ts b/ui/packages/@quent/client/src/queryProfileDiff.test.ts index 41ebd0ca..c2b031c2 100644 --- a/ui/packages/@quent/client/src/queryProfileDiff.test.ts +++ b/ui/packages/@quent/client/src/queryProfileDiff.test.ts @@ -9,50 +9,64 @@ import { import type { QueryProfileDiffTimelineRequest } from './queryProfileDiffTypes'; describe('queryProfileDiffQueryOptions', () => { - it('builds a stable key from engine and query ids', () => { + it('builds a stable key from both engine and query ids', () => { const options = queryProfileDiffQueryOptions({ - engineId: 'engine-1', - request: { query_a_id: 'query-a', query_b_id: 'query-b' }, + request: { + query_a: { engine_id: 'engine-a', query_id: 'query-a' }, + query_b: { engine_id: 'engine-b', query_id: 'query-b' }, + }, }); - expect(options.queryKey).toEqual(['queryProfileDiff', 'engine-1', 'query-a', 'query-b']); + expect(options.queryKey).toEqual([ + 'queryProfileDiff', + 'engine-a', + 'query-a', + 'engine-b', + 'query-b', + ]); }); it('builds diff timeline options around the full request', () => { const request: QueryProfileDiffTimelineRequest = { timelines: [ { - entry: { - ResourceGroup: { - resource_group_id: 'root-a', - resource_type_name: 'GPU', - long_entities_threshold_s: null, - entity_filter: { entity_type_name: null }, - app_params: { operator_id: null }, - config: { num_bins: 200, start: 0, end: 10 }, + engine_id: 'engine-a', + timeline: { + entry: { + ResourceGroup: { + resource_group_id: 'root-a', + resource_type_name: 'GPU', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: null }, + app_params: { operator_id: null }, + config: { num_bins: 200, start: 0, end: 10 }, + }, }, + app_params: { query_id: 'query-a' }, }, - app_params: { query_id: 'query-a' }, }, { - entry: { - ResourceGroup: { - resource_group_id: 'root-b', - resource_type_name: 'GPU', - long_entities_threshold_s: null, - entity_filter: { entity_type_name: null }, - app_params: { operator_id: null }, - config: { num_bins: 200, start: 0, end: 12 }, + engine_id: 'engine-b', + timeline: { + entry: { + ResourceGroup: { + resource_group_id: 'root-b', + resource_type_name: 'GPU', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: null }, + app_params: { operator_id: null }, + config: { num_bins: 200, start: 0, end: 12 }, + }, }, + app_params: { query_id: 'query-b' }, }, - app_params: { query_id: 'query-b' }, }, ], delta_config: { num_bins: 200, start: 0, end: 12 }, }; - const options = queryProfileDiffTimelineQueryOptions({ engineId: 'engine-1', request }); + const options = queryProfileDiffTimelineQueryOptions({ request }); - expect(options.queryKey).toEqual(['queryProfileDiffTimeline', 'engine-1', request]); + expect(options.queryKey).toEqual(['queryProfileDiffTimeline', request]); }); }); diff --git a/ui/packages/@quent/client/src/queryProfileDiff.ts b/ui/packages/@quent/client/src/queryProfileDiff.ts index 149d9c97..af5702fa 100644 --- a/ui/packages/@quent/client/src/queryProfileDiff.ts +++ b/ui/packages/@quent/client/src/queryProfileDiff.ts @@ -12,24 +12,33 @@ import type { } from './queryProfileDiffTypes'; interface QueryProfileDiffParams { - engineId: string; request: QueryProfileDiffRequest; } interface QueryProfileDiffTimelineParams { - engineId: string; request: QueryProfileDiffTimelineRequest; } export const queryProfileDiffQueryOptions = ( - { engineId, request }: QueryProfileDiffParams, + { request }: QueryProfileDiffParams, options?: { staleTime?: number } ) => queryOptions({ - queryKey: ['queryProfileDiff', engineId, request.query_a_id, request.query_b_id], - queryFn: (): Promise => fetchQueryProfileDiff(engineId, request), + queryKey: [ + 'queryProfileDiff', + request.query_a.engine_id, + request.query_a.query_id, + request.query_b.engine_id, + request.query_b.query_id, + ], + queryFn: (): Promise => fetchQueryProfileDiff(request), staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, - enabled: Boolean(engineId && request.query_a_id && request.query_b_id), + enabled: Boolean( + request.query_a.engine_id && + request.query_a.query_id && + request.query_b.engine_id && + request.query_b.query_id + ), }); export const useQueryProfileDiff = ( @@ -38,15 +47,15 @@ export const useQueryProfileDiff = ( ) => useQuery(queryProfileDiffQueryOptions(params, options)); export const queryProfileDiffTimelineQueryOptions = ( - { engineId, request }: QueryProfileDiffTimelineParams, + { request }: QueryProfileDiffTimelineParams, options?: { staleTime?: number } ) => queryOptions({ - queryKey: ['queryProfileDiffTimeline', engineId, request], + queryKey: ['queryProfileDiffTimeline', request], queryFn: (): Promise => - fetchQueryProfileDiffTimeline(engineId, request), + fetchQueryProfileDiffTimeline(request), staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, - enabled: Boolean(engineId), + enabled: request.timelines.length >= 2, }); export const useQueryProfileDiffTimeline = ( @@ -58,11 +67,13 @@ export type { QueryProfileDiffOperatorDelta, QueryProfileDiffOperatorRef, QueryProfileDiffPlanComparison, + QueryProfileDiffQueryRef, QueryProfileDiffQuerySummary, QueryProfileDiffRequest, QueryProfileDiffResponse, QueryProfileDiffScenario, QueryProfileDiffStatDelta, + QueryProfileDiffTimelineEntry, QueryProfileDiffTimelineEntries, QueryProfileDiffTimelineRequest, QueryProfileDiffTimelineResponse, diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index 91683640..ab01dfe9 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -10,15 +10,22 @@ import type { TimelineConfig, } from '@quent/utils'; +export interface QueryProfileDiffQueryRef { + engine_id: string; + query_id: string; +} + export interface QueryProfileDiffRequest { - query_a_id: string; - query_b_id: string; + query_a: QueryProfileDiffQueryRef; + query_b: QueryProfileDiffQueryRef; } export type QueryProfileDiffScenario = 'plans_equal' | 'plans_different' | 'plans_incomparable'; export interface QueryProfileDiffQuerySummary { id: string; + engine_id: string; + engine_name: string | null; instance_name: string | null; query_group_id?: string | null; query_group_name?: string | null; @@ -64,8 +71,15 @@ export interface QueryProfileDiffResponse { export type QueryProfileDiffTimelineEntries = [T, T, ...T[]]; +export interface QueryProfileDiffTimelineEntry { + engine_id: string; + timeline: T; +} + export interface QueryProfileDiffTimelineRequest { - timelines: QueryProfileDiffTimelineEntries>; + timelines: QueryProfileDiffTimelineEntries< + QueryProfileDiffTimelineEntry> + >; delta_config: TimelineConfig; } diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index 929f8ce7..8914c86a 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -305,6 +305,7 @@ export function PivotedStatTable({ const effectiveRenderConfig = useMemo( (): PivotTableRenderConfig => ({ getGroupTypeColor: renderConfig?.getGroupTypeColor, + formatGroupCellValue: renderConfig?.formatGroupCellValue, getDataCellStyle: renderConfig?.getDataCellStyle, formatDataCellValue: renderConfig?.formatDataCellValue, }), diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index c10bf707..415043ef 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -16,6 +16,12 @@ describe('QueryDiffTable helpers', () => { expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ + engineGroupId: 'engine-a:engine-b', + engineGroupLabel: 'Engine A, Engine B', + engines: [ + { id: 'engine-a', label: 'Engine A' }, + { id: 'engine-b', label: 'Engine B' }, + ], operatorType: 'Scan', operatorLabel: 'Scan orders <-> Scan orders\nscan-a <-> scan-b', operatorAId: 'scan-a', diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index 7dbbaf28..aa276de3 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -26,10 +26,14 @@ import { type QueryDiffTableRow, } from './QueryDiffTable.utils'; -type IndexKey = 'operator_type' | 'operator'; +type IndexKey = 'engine' | 'operator_type' | 'operator'; const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { groups: { + engine: { + id: row => row.engineGroupId, + label: row => row.engineGroupLabel, + }, operator_type: { id: row => row.operatorType, }, @@ -44,9 +48,10 @@ const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { stats: row => row.stats, }; -const INDEX_ORDER: IndexKey[] = ['operator_type', 'operator']; +const INDEX_ORDER: IndexKey[] = ['engine', 'operator_type', 'operator']; const DEFAULT_ENABLED: Record = { + engine: true, operator_type: true, operator: false, }; @@ -56,6 +61,18 @@ const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; const getOperatorTypeColor = (key: string, id: string): string | undefined => key === 'operator_type' ? getQueryDiffOperatorTypeColor(id) : undefined; +function EngineGroupCell({ engines }: { engines: QueryDiffTableRow['engines'] }) { + return ( +
+ {engines.map(engine => ( + + {engine.label} + + ))} +
+ ); +} + function OperatorPairCell({ row }: { row: QueryDiffTableRow }) { return (
@@ -78,6 +95,10 @@ function OperatorPairCell({ row }: { row: QueryDiffTableRow }) { export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { const rows = useMemo(() => buildQueryDiffRows(diff), [diff]); + const rowsByEngineGroupId = useMemo( + () => new Map(rows.map(row => [row.engineGroupId, row])), + [rows] + ); const rowsByOperatorPairId = useMemo( () => new Map(rows.map(row => [row.operatorPairId, row])), [rows] @@ -111,13 +132,14 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { defaultEnabled: DEFAULT_ENABLED, allStatNames, defaultStatSelector: stats => stats, - persistKey: 'queryDiffTable', + persistKey: 'queryDiffTable:v2', rows, getRowIndexId: (row, key) => DIFF_TABLE_SCHEMA.groups[key].id(row), }); const indexLabels: Record = useMemo( () => ({ + engine: 'Engine', operator_type: 'Operator Type', operator: 'Operator Pair', }), @@ -147,6 +169,10 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { (): PivotTableRenderConfig => ({ getGroupTypeColor: getOperatorTypeColor, formatGroupCellValue: ({ groupKey }) => { + if (groupKey.key === 'engine') { + const row = rowsByEngineGroupId.get(groupKey.id); + return row ? : groupKey.label; + } if (groupKey.key !== 'operator') return groupKey.label; const row = rowsByOperatorPairId.get(groupKey.id); return row ? : groupKey.label; @@ -155,7 +181,7 @@ export function QueryDiffTable({ diff }: { diff: QueryProfileDiffResponse }) { getDeltaCellStyle(value, maxAbsByStat.get(stat), paletteTheme), formatDataCellValue: ({ stat, value }) => formatSignedDiffValue(value, stat), }), - [maxAbsByStat, paletteTheme, rowsByOperatorPairId] + [maxAbsByStat, paletteTheme, rowsByEngineGroupId, rowsByOperatorPairId] ); if (diff.scenario !== 'plans_equal') { diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index a182b86a..b910d85e 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -6,7 +6,15 @@ import type { PaletteTheme, StatValue } from '@quent/utils'; import { formatStatValue } from '@quent/components'; import { getDiffNegativeColor, getDiffPositiveColor } from './QueryDiffColors'; +export interface QueryDiffTableEngine { + id: string; + label: string; +} + export interface QueryDiffTableRow { + engineGroupId: string; + engineGroupLabel: string; + engines: QueryDiffTableEngine[]; operatorType: string; operatorLabel: string; operatorPairId: string; @@ -17,6 +25,22 @@ export interface QueryDiffTableRow { stats: Record; } +function getQueryEngine(query: QueryProfileDiffResponse['query_a']): QueryDiffTableEngine { + return { + id: query.engine_id, + label: query.engine_name ?? query.engine_id, + }; +} + +function uniqueEngines(engines: QueryDiffTableEngine[]): QueryDiffTableEngine[] { + const seen = new Set(); + return engines.filter(engine => { + if (seen.has(engine.id)) return false; + seen.add(engine.id); + return true; + }); +} + function displayDeltaValue(value: StatValue): StatValue { if (typeof value !== 'number') return value; return value === 0 || Object.is(value, -0) ? 0 : -value; @@ -32,6 +56,10 @@ function formatOperatorPairLabel( } export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTableRow[] { + const engines = uniqueEngines([getQueryEngine(diff.query_a), getQueryEngine(diff.query_b)]); + const engineGroupId = engines.map(engine => engine.id).join(':'); + const engineGroupLabel = engines.map(engine => engine.label).join(', '); + return diff.operator_diffs.flatMap(entry => { if (!entry.operator_a || !entry.operator_b) return []; const operatorType = @@ -50,6 +78,9 @@ export function buildQueryDiffRows(diff: QueryProfileDiffResponse): QueryDiffTab ); return [ { + engineGroupId, + engineGroupLabel, + engines, operatorType, operatorLabel, operatorPairId: `${entry.operator_a.id}:${entry.operator_b.id}`, diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 9ef7651a..370ee264 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -44,7 +44,8 @@ import { import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { - engineId: string; + queryAEngineId: string; + queryBEngineId: string; diff: QueryProfileDiffResponse; queryABundle: QueryBundle; queryBBundle: QueryBundle; @@ -146,7 +147,8 @@ function TimelineLane({ } export function QueryDiffTimeline({ - engineId, + queryAEngineId, + queryBEngineId, diff, queryABundle, queryBBundle, @@ -214,27 +216,31 @@ export function QueryDiffTimeline({ const timelineDiffRequest = useMemo(() => { if (!requestA || !requestB || durationSeconds <= 0) return null; return { - timelines: [requestA, requestB], + timelines: [ + { engine_id: queryAEngineId, timeline: requestA }, + { engine_id: queryBEngineId, timeline: requestB }, + ], delta_config: { num_bins: getAdaptiveNumBins(), start: 0, end: durationSeconds, }, }; - }, [durationSeconds, requestA, requestB]); + }, [durationSeconds, queryAEngineId, queryBEngineId, requestA, requestB]); const timelineDiff = useQuery({ queryKey: [ 'queryDiffTimeline', - engineId, + queryAEngineId, queryAId, + queryBEngineId, queryBId, targetA?.rootResourceGroupId, targetB?.rootResourceGroupId, timelineDiffRequest, ], - queryFn: () => fetchQueryProfileDiffTimeline(engineId, timelineDiffRequest!), - enabled: Boolean(timelineDiffRequest && engineId), + queryFn: () => fetchQueryProfileDiffTimeline(timelineDiffRequest!), + enabled: Boolean(timelineDiffRequest), staleTime: DEFAULT_STALE_TIME, }); diff --git a/ui/src/components/query-diff/queryProfileDiffFromBundles.ts b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts index 3122eb1f..6a99750f 100644 --- a/ui/src/components/query-diff/queryProfileDiffFromBundles.ts +++ b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts @@ -63,6 +63,8 @@ function countOperators(bundle: QueryBundle): number { function getQuerySummary(bundle: QueryBundle): QueryProfileDiffQuerySummary { return { id: bundle.entities.query.id, + engine_id: bundle.entities.engine.id, + engine_name: bundle.entities.engine.instance_name ?? null, instance_name: bundle.entities.query.instance_name ?? null, query_group_id: bundle.entities.query_group.id, query_group_name: bundle.entities.query_group.instance_name ?? null, diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index e217d2fe..2b41bf39 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -11,7 +11,7 @@ import { fetchListQueries, queryBundleQueryOptions, } from '@quent/client'; -import type { Query, QueryGroup } from '@quent/utils'; +import type { Engine, Query, QueryGroup } from '@quent/utils'; import { Button, Collapsible, @@ -33,12 +33,14 @@ import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryP import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; interface DiffSelectionPageProps { - initialEngineId?: string; + initialQueryAEngineId?: string; + initialQueryBEngineId?: string; initialQueryAId?: string; initialQueryBId?: string; } interface QuerySideState { + engineId: string; groupId: string; queryId: string; } @@ -46,9 +48,11 @@ interface QuerySideState { interface QuerySelectorColumnProps { label: string; side: QuerySideState; + engines: Engine[]; queryGroups: QueryGroup[]; queriesByGroup: Record; - disabled: boolean; + queriesLoading: boolean; + onEngineChange: (engineId: string) => void; onGroupChange: (groupId: string) => void; onQueryChange: (queryId: string) => void; } @@ -59,13 +63,24 @@ const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; let pendingSelectionOpenAfterNavigation: boolean | null = null; -function getInitialSelectionOpen(engineId: string, queryAId: string, queryBId: string): boolean { +function getInitialSelectionOpen( + queryAEngineId: string, + queryBEngineId: string, + queryAId: string, + queryBId: string +): boolean { if (pendingSelectionOpenAfterNavigation !== null) { const selectionOpen = pendingSelectionOpenAfterNavigation; pendingSelectionOpenAfterNavigation = null; return selectionOpen; } - return !(engineId && queryAId && queryBId && queryAId !== queryBId); + return !( + queryAEngineId && + queryBEngineId && + queryAId && + queryBId && + !(queryAEngineId === queryBEngineId && queryAId === queryBId) + ); } function findGroupForQuery( @@ -82,6 +97,16 @@ function queryLabel(query: Query): string { return query.instance_name ?? query.id; } +function engineLabel(engine: Engine): string { + return engine.instance_name ?? engine.id; +} + +function engineDisplayLabel(engineId: string, engines: Engine[], emptyLabel: string): string { + if (!engineId) return emptyLabel; + const engine = engines.find(item => item.id === engineId); + return engine ? engineLabel(engine) : engineId; +} + function findQueryById( queryId: string, queriesByGroup: Record @@ -102,12 +127,41 @@ function queryDisplayLabel( return query ? queryLabel(query) : queryId; } +function useQueryCatalog(engineId: string) { + const { data: queryGroups = [], isLoading: queryGroupsLoading } = useQuery({ + queryKey: ['list_coordinators', engineId], + queryFn: () => fetchListCoordinators(engineId), + enabled: Boolean(engineId), + }); + + const { data: queriesByGroup = {}, isLoading: queriesLoading } = useQuery({ + queryKey: ['diff_queries_by_group', engineId, queryGroups.map(group => group.id).join('\0')], + queryFn: async () => { + const entries = await Promise.all( + queryGroups.map( + async group => [group.id, await fetchListQueries(engineId, group.id)] as const + ) + ); + return Object.fromEntries(entries); + }, + enabled: Boolean(engineId && queryGroups.length > 0), + }); + + return { + queryGroups, + queriesByGroup, + queriesLoading: queryGroupsLoading || queriesLoading, + }; +} + function QuerySelectorColumn({ label, side, + engines, queryGroups, queriesByGroup, - disabled, + queriesLoading, + onEngineChange, onGroupChange, onQueryChange, }: QuerySelectorColumnProps) { @@ -117,7 +171,41 @@ function QuerySelectorColumn({

{label}

-
+
+
+ + +
- @@ -158,7 +250,7 @@ function QuerySelectorColumn({ - - - - - {engines.length === 0 ? ( - - No engines - - ) : ( - engines.map(engine => ( - - {engine.instance_name ?? engine.id} - - )) - )} - - -
-
-
handleEngineChange('a', engineId)} onGroupChange={groupId => handleGroupChange('a', groupId)} onQueryChange={queryId => handleQueryChange('a', queryId)} /> @@ -451,9 +525,11 @@ export function DiffSelectionPage({ handleEngineChange('b', engineId)} onGroupChange={groupId => handleGroupChange('b', groupId)} onQueryChange={queryId => handleQueryChange('b', queryId)} /> @@ -469,9 +545,9 @@ export function DiffSelectionPage({ !canDiff && 'flex items-center justify-center' )} > - {!engineId ? ( + {!queryA.engineId || !queryB.engineId ? (
- Select an engine to compare queries. + Select engines for Query A and Query B.
) : sameQuerySelected ? (
Choose two different queries.
@@ -493,7 +569,8 @@ export function DiffSelectionPage({ queryBBundle={queryBBundle.data} /> ); diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index fc7cc163..a204d2ea 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -25,16 +25,20 @@ function taggedNumber(value: number) { return { Number: value }; } -function createQueryBundle(queryId: string) { +function createQueryBundle(engineId: string, queryId: string) { const suffix = queryId.endsWith('b') ? 'b' : 'a'; const stats = QUERY_STATS[queryId as keyof typeof QUERY_STATS] ?? QUERY_STATS['query-a']; const planId = `plan-${suffix}`; + const groupId = `group-${suffix}`; return { query_id: queryId, entities: { - engine: { id: 'engine-1', instance_name: 'Engine 1' }, - query_group: { id: 'group-1', instance_name: 'Group 1' }, + engine: { + id: engineId, + instance_name: engineId === 'engine-2' ? 'Engine 2' : 'Engine 1', + }, + query_group: { id: groupId, instance_name: `Group ${suffix.toUpperCase()}` }, query: { id: queryId, instance_name: suffix === 'a' ? 'Query A' : 'Query B' }, workers: {}, plans: { @@ -102,7 +106,7 @@ function createQueryBundle(queryId: string) { }, resource_tree: { ResourceGroup: { - id: { QueryGroup: 'group-1' }, + id: { QueryGroup: groupId }, children: [], }, }, @@ -118,12 +122,15 @@ describe('Diff routes', () => { beforeEach(() => { server.use( http.get(`${API_BASE}/engines`, () => - HttpResponse.json([{ id: 'engine-1', instance_name: 'Engine 1' }]) + HttpResponse.json([ + { id: 'engine-1', instance_name: 'Engine 1' }, + { id: 'engine-2', instance_name: 'Engine 2' }, + ]) ), - http.get(`${API_BASE}/engines/:engineId/query-groups`, () => + http.get(`${API_BASE}/engines/:engineId/query-groups`, ({ params }) => HttpResponse.json([ - { id: 'group-a', instance_name: 'Group A', engine_id: 'engine-1' }, - { id: 'group-b', instance_name: 'Group B', engine_id: 'engine-1' }, + { id: 'group-a', instance_name: 'Group A', engine_id: String(params.engineId) }, + { id: 'group-b', instance_name: 'Group B', engine_id: String(params.engineId) }, ]) ), http.get(`${API_BASE}/engines/:engineId/query_group/:queryGroupId/queries`, ({ params }) => { @@ -155,7 +162,7 @@ describe('Diff routes', () => { ); }), http.get(`${API_BASE}/engines/:engineId/query/:queryId`, ({ params }) => - HttpResponse.json(createQueryBundle(String(params.queryId))) + HttpResponse.json(createQueryBundle(String(params.engineId), String(params.queryId))) ) ); }); @@ -165,12 +172,12 @@ describe('Diff routes', () => { expect(await screen.findByText('Query A')).toBeInTheDocument(); expect(screen.getByText('Query B')).toBeInTheDocument(); - expect(screen.getByText('Select an engine to compare queries.')).toBeInTheDocument(); + expect(screen.getByText('Select engines for Query A and Query B.')).toBeInTheDocument(); }); it('renders a selected comparison route', async () => { renderWithRouter({ - initialPath: '/diff/engine/engine-1/query/query-a/compare/query-b', + initialPath: '/diff/engine/engine-1/query/query-a/compare/engine/engine-2/query/query-b', }); expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); @@ -182,24 +189,33 @@ describe('Diff routes', () => { const user = userEvent.setup(); renderWithRouter({ initialPath: '/diff' }); - await user.click(await screen.findByRole('combobox', { name: 'Engine' })); + await waitFor(() => expect(screen.getAllByRole('combobox')).toHaveLength(6)); + let selectors = screen.getAllByRole('combobox'); + await user.click(selectors[0]); await user.click(await screen.findByRole('option', { name: 'Engine 1' })); - await waitFor(() => expect(screen.getAllByRole('combobox')).toHaveLength(5)); - let selectors = screen.getAllByRole('combobox'); + await waitFor(() => expect(screen.getAllByRole('combobox')[1]).not.toBeDisabled()); + selectors = screen.getAllByRole('combobox'); await user.click(selectors[1]); await user.click(await screen.findByRole('option', { name: 'Group A' })); + await waitFor(() => expect(screen.getAllByRole('combobox')[2]).not.toBeDisabled()); selectors = screen.getAllByRole('combobox'); await user.click(selectors[2]); await user.click(await screen.findByRole('option', { name: 'Query A' })); selectors = screen.getAllByRole('combobox'); await user.click(selectors[3]); - await user.click(await screen.findByRole('option', { name: 'Group B' })); + await user.click(await screen.findByRole('option', { name: 'Engine 2' })); + await waitFor(() => expect(screen.getAllByRole('combobox')[4]).not.toBeDisabled()); selectors = screen.getAllByRole('combobox'); await user.click(selectors[4]); + await user.click(await screen.findByRole('option', { name: 'Group B' })); + + await waitFor(() => expect(screen.getAllByRole('combobox')[5]).not.toBeDisabled()); + selectors = screen.getAllByRole('combobox'); + await user.click(selectors[5]); await user.click(await screen.findByRole('option', { name: 'Query B' })); expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); @@ -215,17 +231,19 @@ describe('Diff routes', () => { await waitFor(() => { const reopenedSelectors = screen.getAllByRole('combobox'); + expect(reopenedSelectors[0]).toHaveTextContent('Engine 1'); expect(reopenedSelectors[1]).toHaveTextContent('Group A'); expect(reopenedSelectors[2]).toHaveTextContent('Query A'); - expect(reopenedSelectors[3]).toHaveTextContent('Group B'); - expect(reopenedSelectors[4]).toHaveTextContent('Query B'); + expect(reopenedSelectors[3]).toHaveTextContent('Engine 2'); + expect(reopenedSelectors[4]).toHaveTextContent('Group B'); + expect(reopenedSelectors[5]).toHaveTextContent('Query B'); }); }); it('swaps Query A and Query B from the selector', async () => { const user = userEvent.setup(); const { router } = renderWithRouter({ - initialPath: '/diff/engine/engine-1/query/query-a/compare/query-b', + initialPath: '/diff/engine/engine-1/query/query-a/compare/engine/engine-2/query/query-b', }); expect(await screen.findByText('Operator Stat Deltas')).toBeInTheDocument(); @@ -238,7 +256,7 @@ describe('Diff routes', () => { await waitFor(() => { expect(router.state.location.pathname).toBe( - '/diff/engine/engine-1/query/query-b/compare/query-a' + '/diff/engine/engine-2/query/query-b/compare/engine/engine-1/query/query-a' ); expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); @@ -246,7 +264,7 @@ describe('Diff routes', () => { it('does not render a diff for the same query on both sides', async () => { renderWithRouter({ - initialPath: '/diff/engine/engine-1/query/query-a/compare/query-a', + initialPath: '/diff/engine/engine-1/query/query-a/compare/engine/engine-1/query/query-a', }); expect(await screen.findByText('Choose two different queries.')).toBeInTheDocument(); diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index 1292ab75..2cd6a4bb 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -96,12 +96,12 @@ function makeMockTimelineDiffResponse( request: QueryProfileDiffTimelineRequest ): QueryProfileDiffTimelineResponse { const [queryARequest, queryBRequest, ...restRequests] = request.timelines; - const queryA = makeMockTimelineResponse(queryARequest); - const queryB = makeMockTimelineResponse(queryBRequest); + const queryA = makeMockTimelineResponse(queryARequest.timeline); + const queryB = makeMockTimelineResponse(queryBRequest.timeline); const timelines: QueryProfileDiffTimelineResponse['timelines'] = [ queryA, queryB, - ...restRequests.map(makeMockTimelineResponse), + ...restRequests.map(request => makeMockTimelineResponse(request.timeline)), ]; const config = toBinnedSpanSec(request.delta_config); const queryAHigher: number[] = []; @@ -200,7 +200,7 @@ export const handlers = [ return HttpResponse.json({ entries } satisfies BulkTimelinesResponse); }), - http.post('*/api/engines/:engineId/timeline/diff', async ({ request }) => { + http.post('*/api/timeline/diff', async ({ request }) => { const body = (await request.json()) as QueryProfileDiffTimelineRequest; return HttpResponse.json(makeMockTimelineDiffResponse(body)); }), diff --git a/ui/src/test/mocks/queryProfileDiffFixtures.ts b/ui/src/test/mocks/queryProfileDiffFixtures.ts index 903baae6..cd3632d8 100644 --- a/ui/src/test/mocks/queryProfileDiffFixtures.ts +++ b/ui/src/test/mocks/queryProfileDiffFixtures.ts @@ -7,12 +7,16 @@ export const equalPlanQueryProfileDiffFixture: QueryProfileDiffResponse = { scenario: 'plans_equal', query_a: { id: 'query-a', + engine_id: 'engine-a', + engine_name: 'Engine A', instance_name: 'Query A', query_group_id: 'group-1', query_group_name: 'Group 1', }, query_b: { id: 'query-b', + engine_id: 'engine-b', + engine_name: 'Engine B', instance_name: 'Query B', query_group_id: 'group-2', query_group_name: 'Group 2', diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 771cd124..c687bd51 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -69,7 +69,10 @@ interface SingleTimelineResponse { } interface QueryProfileDiffTimelineRequest { - timelines: unknown[]; + timelines: Array<{ + engine_id: string; + timeline: unknown; + }>; delta_config: TimelineConfig; } @@ -152,22 +155,21 @@ function installTimelineDiffMock(server: ViteDevServer | PreviewServer) { return; } - const match = req.url.match(/^\/api\/engines\/([^/]+)\/timeline\/diff(?:\?|$)/); + const match = req.url.match(/^\/api\/timeline\/diff(?:\?|$)/); if (!match) { next(); return; } try { - const engineId = decodeURIComponent(match[1]!); const body = JSON.parse(await readRequestBody(req)) as QueryProfileDiffTimelineRequest; if (body.timelines.length < 2) { throw new Error('timeline diff requires at least two timeline requests'); } const timelines = await Promise.all( - body.timelines.map(timelineRequest => - fetchSingleTimelineFromTarget(engineId, timelineRequest) + body.timelines.map(({ engine_id: engineId, timeline }) => + fetchSingleTimelineFromTarget(engineId, timeline) ) ); const [queryA, queryB] = timelines; From c9ccbab6f782772282cade1d420df6f557326962 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 12:15:01 -0600 Subject: [PATCH 13/33] Handle baseline + multiple competitor queries --- .../query_engine/query_profile_diff.md | 6 +- .../query-diff/QueryDiffColors.test.ts | 12 +- .../components/query-diff/QueryDiffColors.ts | 12 +- .../components/query-diff/QueryDiffStats.tsx | 55 +- .../query-diff/QueryDiffTimeline.tsx | 141 ++-- .../QueryDiffTimeline.utils.test.ts | 18 +- .../query-diff/QueryDiffTimeline.utils.ts | 78 +- ui/src/pages/DiffSelectionPage.tsx | 796 +++++++++++++----- ...engine.$queryBEngineId.query.$queryBId.tsx | 23 - ...ineQueryId.compare.$competitorQueryIds.tsx | 19 + ui/src/routes/diff.test.tsx | 141 +++- 11 files changed, 875 insertions(+), 426 deletions(-) delete mode 100644 ui/src/routes/diff.engine.$queryAEngineId.query.$queryAId.compare.engine.$queryBEngineId.query.$queryBId.tsx create mode 100644 ui/src/routes/diff.query.$baselineQueryId.compare.$competitorQueryIds.tsx diff --git a/docs/domains/query_engine/query_profile_diff.md b/docs/domains/query_engine/query_profile_diff.md index 19476b58..a2f8434f 100644 --- a/docs/domains/query_engine/query_profile_diff.md +++ b/docs/domains/query_engine/query_profile_diff.md @@ -5,6 +5,9 @@ from different engines, so each side of the comparison carries its own engine ID. The UI also uses the profile diff contract as its internal diff view model when it builds query diffs client-side from real `QueryBundle` API responses. +The diff UI presents that pairwise contract as one Baseline Query and one or +more Competitor Queries. Each competitor renders an independent pairwise diff +where `query_a` is the baseline and `query_b` is that competitor. ## Profile Diff Endpoint @@ -26,7 +29,8 @@ export interface QueryProfileDiffRequest { } ``` -Query A is the baseline. Numeric deltas are always `A - B`. +For a single pairwise comparison, Query A is the baseline and Query B is the +competitor. Numeric deltas are always `A - B`. ### Response diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts index 186216a0..6e0b4b89 100644 --- a/ui/src/components/query-diff/QueryDiffColors.test.ts +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -19,21 +19,21 @@ describe('QueryDiffColors', () => { it('assigns distinct palette colors to the compared queries', () => { const colors = getQueryDiffQueryColors({ - queryAId: 'query-a', - queryBId: 'query-b', + baselineQueryId: 'query-a', + competitorQueryId: 'query-b', theme: 'light', }); - expect(colors.queryA).not.toBe(colors.queryB); + expect(colors.baseline).not.toBe(colors.competitor); }); it('keeps colors distinct when the same query id is compared', () => { const colors = getQueryDiffQueryColors({ - queryAId: 'query-a', - queryBId: 'query-a', + baselineQueryId: 'query-a', + competitorQueryId: 'query-a', theme: 'light', }); - expect(colors.queryA).not.toBe(colors.queryB); + expect(colors.baseline).not.toBe(colors.competitor); }); }); diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index 5461f644..90660e07 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -23,20 +23,20 @@ export const DIFF_POSITIVE_COLOR = getDiffPositiveColor('light'); export const DIFF_NEGATIVE_COLOR = getDiffNegativeColor('light'); export interface QueryDiffQueryColors { - queryA: string; - queryB: string; + baseline: string; + competitor: string; } export function getQueryDiffQueryColors({ theme, }: { - queryAId: string; - queryBId: string; + baselineQueryId: string; + competitorQueryId: string; theme: PaletteTheme; }): QueryDiffQueryColors { return { - queryA: getColorByIndex(5, theme), - queryB: getColorByIndex(4, theme), + baseline: getColorByIndex(5, theme), + competitor: getColorByIndex(4, theme), }; } diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index b7e512ee..aa7d22be 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -31,8 +31,8 @@ import { interface QueryDiffStatsProps { diff: QueryProfileDiffResponse; - queryABundle: QueryBundle; - queryBBundle: QueryBundle; + baselineBundle: QueryBundle; + competitorBundle: QueryBundle; } function runtimeValueStyle(delta: number, paletteTheme: PaletteTheme): CSSProperties | undefined { @@ -52,27 +52,27 @@ function displayPercentDelta(percentDelta: number | null): number | null { function runtimeComparisons({ comparison, - queryAName, - queryBName, + baselineName, + competitorName, queryColors, }: { comparison: RuntimeComparison; - queryAName: string; - queryBName: string; + baselineName: string; + competitorName: string; queryColors: QueryDiffQueryColors; }): StatisticCardComparison[] { return [ { - id: 'a', - label: queryAName, + id: 'baseline', + label: baselineName, value: formatDurationSeconds(comparison.a), - color: queryColors.queryA, + color: queryColors.baseline, }, { - id: 'b', - label: queryBName, + id: 'competitor', + label: competitorName, value: formatDurationSeconds(comparison.b), - color: queryColors.queryB, + color: queryColors.competitor, }, ]; } @@ -87,27 +87,32 @@ function operatorRuntimeChartRows( labelColor: getQueryDiffOperatorTypeColor(comparison.id), title: formatDurationSeconds(Math.max(comparison.a, comparison.b)), bars: [ - { id: 'a', value: comparison.a, color: queryColors.queryA, label: 'First comparison value' }, { - id: 'b', + id: 'baseline', + value: comparison.a, + color: queryColors.baseline, + label: 'Baseline value', + }, + { + id: 'competitor', value: comparison.b, - color: queryColors.queryB, - label: 'Second comparison value', + color: queryColors.competitor, + label: 'Competitor value', }, ], })); } -export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffStatsProps) { +export function QueryDiffStats({ diff, baselineBundle, competitorBundle }: QueryDiffStatsProps) { const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; - const queryAName = diff.query_a.instance_name ?? diff.query_a.id; - const queryBName = diff.query_b.instance_name ?? diff.query_b.id; + const baselineName = diff.query_a.instance_name ?? diff.query_a.id; + const competitorName = diff.query_b.instance_name ?? diff.query_b.id; const queryColors = useMemo( () => getQueryDiffQueryColors({ - queryAId: diff.query_a.id, - queryBId: diff.query_b.id, + baselineQueryId: diff.query_a.id, + competitorQueryId: diff.query_b.id, theme: paletteTheme, }), [diff.query_a.id, diff.query_b.id, paletteTheme] @@ -117,8 +122,8 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt [diff] ); const totalRuntimeComparison = useMemo( - () => buildRuntimeComparison(queryABundle.duration_s, queryBBundle.duration_s), - [queryABundle.duration_s, queryBBundle.duration_s] + () => buildRuntimeComparison(baselineBundle.duration_s, competitorBundle.duration_s), + [baselineBundle.duration_s, competitorBundle.duration_s] ); return ( @@ -133,8 +138,8 @@ export function QueryDiffStats({ diff, queryABundle, queryBBundle }: QueryDiffSt )} comparisons={runtimeComparisons({ comparison: totalRuntimeComparison, - queryAName, - queryBName, + baselineName, + competitorName, queryColors, })} comparisonSeparator={ diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 370ee264..3402b888 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -44,11 +44,11 @@ import { import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { - queryAEngineId: string; - queryBEngineId: string; + baselineEngineId: string; + competitorEngineId: string; diff: QueryProfileDiffResponse; - queryABundle: QueryBundle; - queryBBundle: QueryBundle; + baselineBundle: QueryBundle; + competitorBundle: QueryBundle; } interface TimelineTarget { @@ -147,11 +147,11 @@ function TimelineLane({ } export function QueryDiffTimeline({ - queryAEngineId, - queryBEngineId, + baselineEngineId, + competitorEngineId, diff, - queryABundle, - queryBBundle, + baselineBundle, + competitorBundle, }: QueryDiffTimelineProps) { const { theme } = useTheme(); const isDark = theme === THEME_DARK; @@ -159,20 +159,29 @@ export function QueryDiffTimeline({ const setZoomRange = useSetZoomRange(); const setDebouncedZoomRange = useSetDebouncedZoomRange(); - const queryAId = diff.query_a.id; - const queryBId = diff.query_b.id; + const baselineQueryId = diff.query_a.id; + const competitorQueryId = diff.query_b.id; const queryColors = useMemo( - () => getQueryDiffQueryColors({ queryAId, queryBId, theme: paletteTheme }), - [paletteTheme, queryAId, queryBId] + () => + getQueryDiffQueryColors({ + baselineQueryId, + competitorQueryId, + theme: paletteTheme, + }), + [baselineQueryId, competitorQueryId, paletteTheme] ); const diffPositiveColor = getDiffPositiveColor(paletteTheme); const diffNegativeColor = getDiffNegativeColor(paletteTheme); - const targetA = useMemo(() => getTimelineTarget(queryABundle), [queryABundle]); - const targetB = useMemo(() => getTimelineTarget(queryBBundle), [queryBBundle]); + const baselineTarget = useMemo(() => getTimelineTarget(baselineBundle), [baselineBundle]); + const competitorTarget = useMemo(() => getTimelineTarget(competitorBundle), [competitorBundle]); const sharedResourceTypes = useMemo( - () => getSharedResourceTypes(targetA?.resourceTypes ?? [], targetB?.resourceTypes ?? []), - [targetA?.resourceTypes, targetB?.resourceTypes] + () => + getSharedResourceTypes( + baselineTarget?.resourceTypes ?? [], + competitorTarget?.resourceTypes ?? [] + ), + [baselineTarget?.resourceTypes, competitorTarget?.resourceTypes] ); const [resourceType, setResourceType] = useState(''); @@ -184,41 +193,41 @@ export function QueryDiffTimeline({ setResourceType(prev => (sharedResourceTypes.includes(prev) ? prev : sharedResourceTypes[0]!)); }, [sharedResourceTypes]); - const durationSeconds = Math.max(queryABundle.duration_s, queryBBundle.duration_s); + const durationSeconds = Math.max(baselineBundle.duration_s, competitorBundle.duration_s); useEffect(() => { if (durationSeconds <= 0) return; const full = { start: 0, end: durationSeconds }; setZoomRange(full); setDebouncedZoomRange(full); - }, [durationSeconds, queryAId, queryBId, setZoomRange, setDebouncedZoomRange]); + }, [durationSeconds, baselineQueryId, competitorQueryId, setZoomRange, setDebouncedZoomRange]); - const requestA = useMemo(() => { - if (!targetA || !resourceType) return null; + const baselineRequest = useMemo(() => { + if (!baselineTarget || !resourceType) return null; return buildRootTimelineRequest({ - queryId: queryABundle.query_id, - rootResourceGroupId: targetA.rootResourceGroupId, + queryId: baselineBundle.query_id, + rootResourceGroupId: baselineTarget.rootResourceGroupId, resourceTypeName: resourceType, - durationSeconds: queryABundle.duration_s, + durationSeconds: baselineBundle.duration_s, }); - }, [queryABundle.duration_s, queryABundle.query_id, resourceType, targetA]); + }, [baselineBundle.duration_s, baselineBundle.query_id, baselineTarget, resourceType]); - const requestB = useMemo(() => { - if (!targetB || !resourceType) return null; + const competitorRequest = useMemo(() => { + if (!competitorTarget || !resourceType) return null; return buildRootTimelineRequest({ - queryId: queryBBundle.query_id, - rootResourceGroupId: targetB.rootResourceGroupId, + queryId: competitorBundle.query_id, + rootResourceGroupId: competitorTarget.rootResourceGroupId, resourceTypeName: resourceType, - durationSeconds: queryBBundle.duration_s, + durationSeconds: competitorBundle.duration_s, }); - }, [queryBBundle.duration_s, queryBBundle.query_id, resourceType, targetB]); + }, [competitorBundle.duration_s, competitorBundle.query_id, competitorTarget, resourceType]); const timelineDiffRequest = useMemo(() => { - if (!requestA || !requestB || durationSeconds <= 0) return null; + if (!baselineRequest || !competitorRequest || durationSeconds <= 0) return null; return { timelines: [ - { engine_id: queryAEngineId, timeline: requestA }, - { engine_id: queryBEngineId, timeline: requestB }, + { engine_id: baselineEngineId, timeline: baselineRequest }, + { engine_id: competitorEngineId, timeline: competitorRequest }, ], delta_config: { num_bins: getAdaptiveNumBins(), @@ -226,17 +235,17 @@ export function QueryDiffTimeline({ end: durationSeconds, }, }; - }, [durationSeconds, queryAEngineId, queryBEngineId, requestA, requestB]); + }, [baselineEngineId, baselineRequest, competitorEngineId, competitorRequest, durationSeconds]); const timelineDiff = useQuery({ queryKey: [ 'queryDiffTimeline', - queryAEngineId, - queryAId, - queryBEngineId, - queryBId, - targetA?.rootResourceGroupId, - targetB?.rootResourceGroupId, + baselineEngineId, + baselineQueryId, + competitorEngineId, + competitorQueryId, + baselineTarget?.rootResourceGroupId, + competitorTarget?.rootResourceGroupId, timelineDiffRequest, ], queryFn: () => fetchQueryProfileDiffTimeline(timelineDiffRequest!), @@ -247,25 +256,25 @@ export function QueryDiffTimeline({ const comparison = useMemo(() => { if (!timelineDiff.data || durationSeconds <= 0) return null; const resourceTypeDecl = - queryABundle.entities.resource_types[resourceType] ?? - queryBBundle.entities.resource_types[resourceType]; + baselineBundle.entities.resource_types[resourceType] ?? + competitorBundle.entities.resource_types[resourceType]; return buildDiffTimelineData({ timelineDiff: timelineDiff.data, theme: paletteTheme, capacities: resourceTypeDecl?.capacities, - quantitySpecs: queryABundle.quantity_specs ?? queryBBundle.quantity_specs, - fsmTypes: queryABundle.entities.fsm_types ?? queryBBundle.entities.fsm_types, + quantitySpecs: baselineBundle.quantity_specs ?? competitorBundle.quantity_specs, + fsmTypes: baselineBundle.entities.fsm_types ?? competitorBundle.entities.fsm_types, queryColors, }); }, [ + baselineBundle.entities.fsm_types, + baselineBundle.entities.resource_types, + baselineBundle.quantity_specs, + competitorBundle.entities.fsm_types, + competitorBundle.entities.resource_types, + competitorBundle.quantity_specs, durationSeconds, paletteTheme, - queryABundle.entities.fsm_types, - queryABundle.entities.resource_types, - queryABundle.quantity_specs, - queryBBundle.entities.fsm_types, - queryBBundle.entities.resource_types, - queryBBundle.quantity_specs, queryColors, resourceType, timelineDiff.data, @@ -283,7 +292,7 @@ export function QueryDiffTimeline({
- {diff.query_a.instance_name ?? queryAId} + {diff.query_a.instance_name ?? baselineQueryId} - {diff.query_b.instance_name ?? queryBId} + {diff.query_b.instance_name ?? competitorQueryId} {durationSeconds > 0 && {formatDuration(durationSeconds * 1_000)}}
@@ -305,14 +314,14 @@ export function QueryDiffTimeline({ className="h-2 w-2 rounded-full" style={{ backgroundColor: diffNegativeColor }} /> - B lower + Competitor lower - B higher + Competitor higher
)} @@ -353,7 +362,7 @@ export function QueryDiffTimeline({
Failed to load timeline delta
- ) : !resourceType || !targetA || !targetB ? ( + ) : !resourceType || !baselineTarget || !competitorTarget ? (
No shared resource type available for timeline delta.
@@ -376,34 +385,34 @@ export function QueryDiffTimeline({
{diff.query_a.instance_name ?? queryAId}} + label="Baseline" + color={queryColors.baseline} + detail={{diff.query_a.instance_name ?? baselineQueryId}} > {diff.query_b.instance_name ?? queryBId}} + label="Competitor" + color={queryColors.competitor} + detail={{diff.query_b.instance_name ?? competitorQueryId}} > - + { const data = buildDiffTimelineData({ timelineDiff: response, theme: 'light', - queryColors: { queryA: '#0072B2', queryB: '#E69F00' }, + queryColors: { baseline: '#0072B2', competitor: '#E69F00' }, }); - expect(data.queryA.series.slots?.values).toEqual([100, 100]); - expect(data.queryB.series.slots?.values).toEqual([0, 0]); - expect(data.delta.series['Query A higher']?.values).toEqual([2, 0]); - expect(data.delta.series['Query B higher']?.values).toEqual([0, 3]); - expect(data.queryA.series.slots?.color).toBe('#0072B2'); - expect(data.queryB.series.slots?.color).toBe('#E69F00'); - expect(data.delta.series['Query A higher']?.color).toBe(DIFF_NEGATIVE_COLOR); - expect(data.delta.series['Query B higher']?.color).toBe(DIFF_POSITIVE_COLOR); + expect(data.baseline.series.slots?.values).toEqual([100, 100]); + expect(data.competitor.series.slots?.values).toEqual([0, 0]); + expect(data.delta.series['Baseline higher']?.values).toEqual([2, 0]); + expect(data.delta.series['Competitor higher']?.values).toEqual([0, 3]); + expect(data.baseline.series.slots?.color).toBe('#0072B2'); + expect(data.competitor.series.slots?.color).toBe('#E69F00'); + expect(data.delta.series['Baseline higher']?.color).toBe(DIFF_NEGATIVE_COLOR); + expect(data.delta.series['Competitor higher']?.color).toBe(DIFF_POSITIVE_COLOR); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index 6feaf244..db3becb6 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -12,6 +12,8 @@ import { const QUERY_A_HIGHER_LABEL = 'Query A higher'; const QUERY_B_HIGHER_LABEL = 'Query B higher'; +const BASELINE_HIGHER_LABEL = 'Baseline higher'; +const COMPETITOR_HIGHER_LABEL = 'Competitor higher'; interface TimelineRowData { timestamps: number[]; @@ -19,8 +21,8 @@ interface TimelineRowData { } export interface DiffTimelineData { - queryA: TimelineRowData; - queryB: TimelineRowData; + baseline: TimelineRowData; + competitor: TimelineRowData; delta: TimelineRowData; } @@ -43,32 +45,40 @@ function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { function formatDeltaSeries({ delta, - queryA, - queryB, + baseline, + competitor, theme, }: { delta: TimelineRowData; - queryA: TimelineRowData; - queryB: TimelineRowData; + baseline: TimelineRowData; + competitor: TimelineRowData; theme: PaletteTheme; }): TimelineSeries { - const formatter = getFirstFormatter(queryA.series, queryB.series); + const formatter = getFirstFormatter(baseline.series, competitor.series); const positiveColor = getDiffPositiveColor(theme); const negativeColor = getDiffNegativeColor(theme); return Object.fromEntries( - Object.entries(delta.series).map(([name, entry]) => [ - name, - { - ...entry, - color: - name === QUERY_A_HIGHER_LABEL - ? negativeColor - : name === QUERY_B_HIGHER_LABEL - ? positiveColor - : entry.color, - formatter, - }, - ]) + Object.entries(delta.series).map(([name, entry]) => { + const displayName = + name === QUERY_A_HIGHER_LABEL + ? BASELINE_HIGHER_LABEL + : name === QUERY_B_HIGHER_LABEL + ? COMPETITOR_HIGHER_LABEL + : name; + return [ + displayName, + { + ...entry, + color: + name === QUERY_A_HIGHER_LABEL + ? negativeColor + : name === QUERY_B_HIGHER_LABEL + ? positiveColor + : entry.color, + formatter, + }, + ]; + }) ); } @@ -92,19 +102,19 @@ export function buildDiffTimelineData({ fsmTypes, queryColors, }: BuildDiffTimelineDataParams): DiffTimelineData { - const [queryATimeline, queryBTimeline] = timelineDiff.timelines; - const queryA = buildBinnedTimelineSeries( - queryATimeline.data, - queryATimeline.config, + const [baselineTimeline, competitorTimeline] = timelineDiff.timelines; + const baseline = buildBinnedTimelineSeries( + baselineTimeline.data, + baselineTimeline.config, 0n, theme, capacities, quantitySpecs, fsmTypes ); - const queryB = buildBinnedTimelineSeries( - queryBTimeline.data, - queryBTimeline.config, + const competitor = buildBinnedTimelineSeries( + competitorTimeline.data, + competitorTimeline.config, 0n, theme, capacities, @@ -119,17 +129,17 @@ export function buildDiffTimelineData({ ); return { - queryA: { - ...queryA, - series: recolorTimelineSeries(queryA.series, queryColors.queryA), + baseline: { + ...baseline, + series: recolorTimelineSeries(baseline.series, queryColors.baseline), }, - queryB: { - ...queryB, - series: recolorTimelineSeries(queryB.series, queryColors.queryB), + competitor: { + ...competitor, + series: recolorTimelineSeries(competitor.series, queryColors.competitor), }, delta: { timestamps: delta.timestamps, - series: formatDeltaSeries({ delta, queryA, queryB, theme }), + series: formatDeltaSeries({ delta, baseline, competitor, theme }), }, }; } diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 2b41bf39..64c4081b 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; -import { ArrowLeftRight, ChevronDown } from 'lucide-react'; +import { ChevronDown, Plus } from 'lucide-react'; import { fetchListCoordinators, fetchListEngines, @@ -33,10 +33,8 @@ import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryP import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; interface DiffSelectionPageProps { - initialQueryAEngineId?: string; - initialQueryBEngineId?: string; - initialQueryAId?: string; - initialQueryBId?: string; + initialBaselineQueryId?: string; + initialCompetitorQueryIds?: readonly string[]; } interface QuerySideState { @@ -45,42 +43,91 @@ interface QuerySideState { queryId: string; } +interface CompetitorQueryState extends QuerySideState { + id: string; +} + interface QuerySelectorColumnProps { label: string; + idPrefix: string; side: QuerySideState; engines: Engine[]; queryGroups: QueryGroup[]; queriesByGroup: Record; queriesLoading: boolean; + action?: React.ReactNode; onEngineChange: (engineId: string) => void; onGroupChange: (groupId: string) => void; onQueryChange: (queryId: string) => void; } +interface QueryLocation { + engineId: string; + groupId: string; +} + const COMPACT_SELECT_TRIGGER_CLASS = 'h-7 min-w-0 rounded px-2 py-1 text-xs [&_svg]:h-3 [&_svg]:w-3'; const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; +const EMPTY_QUERY_GROUPS: QueryGroup[] = []; +const EMPTY_QUERIES_BY_GROUP: Record = {}; let pendingSelectionOpenAfterNavigation: boolean | null = null; +let nextCompetitorId = 1; + +function makeQuerySide(queryId = ''): QuerySideState { + return { engineId: '', groupId: '', queryId }; +} -function getInitialSelectionOpen( - queryAEngineId: string, - queryBEngineId: string, - queryAId: string, - queryBId: string -): boolean { +function makeCompetitorQuery(queryId = ''): CompetitorQueryState { + return { + id: `competitor-${nextCompetitorId++}`, + ...makeQuerySide(queryId), + }; +} + +function makeCompetitorQueries(queryIds: readonly string[] = []): CompetitorQueryState[] { + const initialQueryIds = queryIds.length > 0 ? queryIds : ['']; + return initialQueryIds.map(queryId => makeCompetitorQuery(queryId)); +} + +function toQuerySide(side: QuerySideState): QuerySideState { + return { + engineId: side.engineId, + groupId: side.groupId, + queryId: side.queryId, + }; +} + +function isQuerySideComplete(side: QuerySideState): boolean { + return Boolean(side.engineId && side.groupId && side.queryId); +} + +function queryIdsEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +export function parseCompetitorQueryIds(competitorQueryIds: string): string[] { + return competitorQueryIds + .split(',') + .map(queryId => queryId.trim()) + .filter(Boolean); +} + +function formatCompetitorQueryIds(competitors: readonly QuerySideState[]): string { + return competitors + .map(competitor => competitor.queryId) + .filter(Boolean) + .join(','); +} + +function getInitialSelectionOpen(baselineQueryId: string, competitorQueryIds: readonly string[]) { if (pendingSelectionOpenAfterNavigation !== null) { const selectionOpen = pendingSelectionOpenAfterNavigation; pendingSelectionOpenAfterNavigation = null; return selectionOpen; } - return !( - queryAEngineId && - queryBEngineId && - queryAId && - queryBId && - !(queryAEngineId === queryBEngineId && queryAId === queryBId) - ); + return !(baselineQueryId && competitorQueryIds.length > 0); } function findGroupForQuery( @@ -128,13 +175,14 @@ function queryDisplayLabel( } function useQueryCatalog(engineId: string) { - const { data: queryGroups = [], isLoading: queryGroupsLoading } = useQuery({ + const { data: queryGroupsData, isLoading: queryGroupsLoading } = useQuery({ queryKey: ['list_coordinators', engineId], queryFn: () => fetchListCoordinators(engineId), enabled: Boolean(engineId), }); + const queryGroups = queryGroupsData ?? EMPTY_QUERY_GROUPS; - const { data: queriesByGroup = {}, isLoading: queriesLoading } = useQuery({ + const { data: queriesByGroupData, isLoading: queriesLoading } = useQuery({ queryKey: ['diff_queries_by_group', engineId, queryGroups.map(group => group.id).join('\0')], queryFn: async () => { const entries = await Promise.all( @@ -146,6 +194,7 @@ function useQueryCatalog(engineId: string) { }, enabled: Boolean(engineId && queryGroups.length > 0), }); + const queriesByGroup = queriesByGroupData ?? EMPTY_QUERIES_BY_GROUP; return { queryGroups, @@ -154,13 +203,46 @@ function useQueryCatalog(engineId: string) { }; } +function useQueryLocations(queryIds: string[], engines: Engine[]) { + const uniqueQueryIds = useMemo(() => [...new Set(queryIds.filter(Boolean))], [queryIds]); + const engineIds = useMemo(() => engines.map(engine => engine.id), [engines]); + + return useQuery({ + queryKey: ['diff_query_locations', engineIds.join('\0'), uniqueQueryIds.join('\0')], + queryFn: async () => { + const wantedQueryIds = new Set(uniqueQueryIds); + const locations: Record = {}; + + await Promise.all( + engines.map(async engine => { + const queryGroups = await fetchListCoordinators(engine.id); + await Promise.all( + queryGroups.map(async group => { + const queries = await fetchListQueries(engine.id, group.id); + for (const query of queries) { + if (!wantedQueryIds.has(query.id) || locations[query.id]) continue; + locations[query.id] = { engineId: engine.id, groupId: group.id }; + } + }) + ); + }) + ); + + return locations; + }, + enabled: uniqueQueryIds.length > 0 && engines.length > 0, + }); +} + function QuerySelectorColumn({ label, + idPrefix, side, engines, queryGroups, queriesByGroup, queriesLoading, + action, onEngineChange, onGroupChange, onQueryChange, @@ -168,19 +250,20 @@ function QuerySelectorColumn({ const queries = side.groupId ? (queriesByGroup[side.groupId] ?? []) : []; return (
-
+

{label}

+ {action}
+ + + + + {sharedResourceTypes.length === 0 ? ( + + No shared resource types + + ) : ( + sharedResourceTypes.map(type => ( + + {type} + + )) + )} + + +
+
+ + {!resourceType || !baselineTarget ? ( +
+ No shared resource type available for timeline delta. +
+ ) : baselineTimeline.isLoading ? ( +
+ Loading timeline... +
+ ) : baselineTimeline.isError || !baselineTimelineData ? ( +
+ Failed to load timeline delta +
+ ) : ( +
+
+ { + setZoomRange(range); + setDebouncedZoomRange(range); + }} + isDark={isDark} + /> +
+
+
+ +
+ {baselineName}} + > + + + {comparisons.map(comparison => ( + + ))} +
+ )} +
+ ); +} + export function QueryDiffTimeline({ baselineEngineId, competitorEngineId, diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 64c4081b..a4afb419 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { ChevronDown, Plus } from 'lucide-react'; import { fetchListCoordinators, @@ -26,8 +26,8 @@ import { } from '@quent/components'; import { cn } from '@quent/utils'; import { QueryDiffTable } from '@/components/query-diff/QueryDiffTable'; -import { QueryDiffStats } from '@/components/query-diff/QueryDiffStats'; -import { QueryDiffTimeline } from '@/components/query-diff/QueryDiffTimeline'; +import { QueryDiffOverviewStats } from '@/components/query-diff/QueryDiffStats'; +import { QueryDiffTimelineList } from '@/components/query-diff/QueryDiffTimeline'; import { getQueryDiffQueryColors } from '@/components/query-diff/QueryDiffColors'; import { buildQueryProfileDiffFromBundles } from '@/components/query-diff/queryProfileDiffFromBundles'; import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; @@ -402,11 +402,9 @@ function CompetitorQuerySelectorColumn({ ); } -interface CompetitorDiffPanelProps { +interface DiffDashboardProps { baselineQuery: QuerySideState; - competitorQuery: CompetitorQueryState; - index: number; - isOnlyCompetitor: boolean; + competitorQueries: CompetitorQueryState[]; } type DiffDashboardTab = 'overview' | 'operator' | 'timelines'; @@ -417,52 +415,64 @@ const DIFF_DASHBOARD_TABS: Array<{ id: DiffDashboardTab; label: string }> = [ { id: 'timelines', label: 'Timelines' }, ]; -function CompetitorDiffPanel({ - baselineQuery, - competitorQuery, - index, - isOnlyCompetitor, -}: CompetitorDiffPanelProps) { +function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) { const [activeTab, setActiveTab] = useState('overview'); const baselineBundle = useQuery({ ...queryBundleQueryOptions({ engineId: baselineQuery.engineId, queryId: baselineQuery.queryId, }), - enabled: isQuerySideComplete(baselineQuery) && isQuerySideComplete(competitorQuery), + enabled: isQuerySideComplete(baselineQuery), }); - const competitorBundle = useQuery({ - ...queryBundleQueryOptions({ - engineId: competitorQuery.engineId, - queryId: competitorQuery.queryId, - }), - enabled: isQuerySideComplete(baselineQuery) && isQuerySideComplete(competitorQuery), + const competitorBundles = useQueries({ + queries: competitorQueries.map(competitorQuery => ({ + ...queryBundleQueryOptions({ + engineId: competitorQuery.engineId, + queryId: competitorQuery.queryId, + }), + enabled: isQuerySideComplete(baselineQuery) && isQuerySideComplete(competitorQuery), + })), }); - const diff = useMemo( + + const comparisons = useMemo( () => - baselineBundle.data && competitorBundle.data - ? buildQueryProfileDiffFromBundles(baselineBundle.data, competitorBundle.data) - : null, - [baselineBundle.data, competitorBundle.data] + baselineBundle.data + ? competitorQueries.flatMap((competitorQuery, index) => { + const competitorBundle = competitorBundles[index]?.data; + if (!competitorBundle) return []; + const diff = buildQueryProfileDiffFromBundles(baselineBundle.data, competitorBundle); + return [ + { + id: competitorQuery.id, + competitorIndex: index, + competitorQuery, + diff, + baselineBundle: baselineBundle.data, + competitorBundle, + }, + ]; + }) + : [], + [baselineBundle.data, competitorBundles, competitorQueries] ); - const diffLoading = baselineBundle.isLoading || competitorBundle.isLoading; - const diffError = baselineBundle.error ?? competitorBundle.error; + const diffLoading = baselineBundle.isLoading || competitorBundles.some(query => query.isLoading); + const diffError = baselineBundle.error ?? competitorBundles.find(query => query.error)?.error; const baselineLabel = baselineBundle.data?.entities.query.instance_name ?? baselineQuery.queryId; - const competitorLabel = - competitorBundle.data?.entities.query.instance_name ?? competitorQuery.queryId; + const competitorCountLabel = + comparisons.length === 1 ? '1 competitor query' : `${comparisons.length} competitor queries`; return (
- Competitor Query {index + 1} + Dashboard {baselineLabel} vs - {competitorLabel} + {competitorCountLabel}
{diffLoading ? (
@@ -472,11 +482,11 @@ function CompetitorDiffPanel({
Failed to load diff
- ) : diff && baselineBundle.data && competitorBundle.data ? ( + ) : baselineBundle.data && comparisons.length > 0 ? (
{DIFF_DASHBOARD_TABS.map(tab => { @@ -484,11 +494,11 @@ function CompetitorDiffPanel({ return (
))} @@ -615,6 +668,7 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) id: comparison.id, competitorIndex: comparison.competitorIndex, competitorEngineId: comparison.competitorQuery.engineId, + comparisonQuery: comparison.comparisonQuery, diff: comparison.diff, competitorBundle: comparison.competitorBundle, }))} diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index 2cd6a4bb..ab1774fe 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -15,8 +15,11 @@ import type { TimelineRequest, } from '@quent/utils'; import type { - QueryProfileDiffTimelineRequest, - QueryProfileDiffTimelineResponse, + DiffRequest, + DiffResponse, + DiffTimelineRequest, + DiffTimelineResponse, + QueryDiff, } from '@quent/client'; const QUERY_A_HIGHER_SERIES = 'Query A higher'; @@ -92,13 +95,11 @@ function sampleAggregateAt(response: SingleTimelineResponse, targetSeconds: numb return timelineValueArrays(response).reduce((sum, values) => sum + (values[index] ?? 0), 0); } -function makeMockTimelineDiffResponse( - request: QueryProfileDiffTimelineRequest -): QueryProfileDiffTimelineResponse { +function makeMockTimelineDiffResponse(request: DiffTimelineRequest): DiffTimelineResponse { const [queryARequest, queryBRequest, ...restRequests] = request.timelines; const queryA = makeMockTimelineResponse(queryARequest.timeline); const queryB = makeMockTimelineResponse(queryBRequest.timeline); - const timelines: QueryProfileDiffTimelineResponse['timelines'] = [ + const timelines: DiffTimelineResponse['timelines'] = [ queryA, queryB, ...restRequests.map(request => makeMockTimelineResponse(request.timeline)), @@ -133,6 +134,89 @@ function makeMockTimelineDiffResponse( }; } +function queryNameFromId(queryId: string): string { + const parts = queryId.split('-'); + const suffix = parts[parts.length - 1]; + return suffix ? `Query ${suffix.toUpperCase()}` : queryId; +} + +function makeMockQueryProfileDiffResponse(request: DiffRequest): DiffResponse { + return { + comparisonQueries: request.comparisonQueries.map((query, index): QueryDiff => { + const durationA = 40; + const durationB = 44 + index * 3; + return { + compatibility: 'compatible', + query: { + id: query.query_id, + engine_id: query.engine_id, + engine_name: query.engine_id, + instance_name: queryNameFromId(query.query_id), + query_group_id: null, + query_group_name: null, + }, + stat_diffs: { + duration: { + stats: [durationA, durationB], + delta: durationA - durationB, + percent_delta: durationB === 0 ? null : (durationA - durationB) / durationB, + }, + }, + operator_diffs: [ + { + operators: [ + { + id: `scan-${request.baselineQuery.query_id}`, + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: `plan-${request.baselineQuery.query_id}`, + }, + { + id: `scan-${query.query_id}`, + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: `plan-${query.query_id}`, + }, + ], + stats: { + duration_s: { stats: [12, 10 + index], delta: 2 - index, percent_delta: 0.2 }, + input_rows: { + stats: [1000, 1200 + index * 100], + delta: -200 - index * 100, + percent_delta: -0.1666666667, + }, + }, + }, + { + operators: [ + { + id: `join-${request.baselineQuery.query_id}`, + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: `plan-${request.baselineQuery.query_id}`, + }, + { + id: `join-${query.query_id}`, + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: `plan-${query.query_id}`, + }, + ], + stats: { + duration_s: { stats: [24, 30 + index], delta: -6 - index, percent_delta: -0.2 }, + output_rows: { + stats: [400, 380 - index * 20], + delta: 20 + index * 20, + percent_delta: 0.0526315789, + }, + }, + }, + ], + }; + }), + }; +} + /** * Default MSW handlers for mocking API responses * Add your API mocks here @@ -200,8 +284,13 @@ export const handlers = [ return HttpResponse.json({ entries } satisfies BulkTimelinesResponse); }), + http.post('*/api/query-profile-diff', async ({ request }) => { + const body = (await request.json()) as DiffRequest; + return HttpResponse.json(makeMockQueryProfileDiffResponse(body)); + }), + http.post('*/api/timeline/diff', async ({ request }) => { - const body = (await request.json()) as QueryProfileDiffTimelineRequest; + const body = (await request.json()) as DiffTimelineRequest; return HttpResponse.json(makeMockTimelineDiffResponse(body)); }), ]; diff --git a/ui/src/test/mocks/queryProfileDiffFixtures.ts b/ui/src/test/mocks/queryProfileDiffFixtures.ts index cd3632d8..d54bda91 100644 --- a/ui/src/test/mocks/queryProfileDiffFixtures.ts +++ b/ui/src/test/mocks/queryProfileDiffFixtures.ts @@ -1,100 +1,102 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { QueryProfileDiffResponse } from '@quent/client'; +import type { DiffQuerySummary, QueryDiff } from '@quent/client'; -export const equalPlanQueryProfileDiffFixture: QueryProfileDiffResponse = { - scenario: 'plans_equal', - query_a: { - id: 'query-a', - engine_id: 'engine-a', - engine_name: 'Engine A', - instance_name: 'Query A', - query_group_id: 'group-1', - query_group_name: 'Group 1', - }, - query_b: { - id: 'query-b', - engine_id: 'engine-b', - engine_name: 'Engine B', - instance_name: 'Query B', - query_group_id: 'group-2', - query_group_name: 'Group 2', - }, - plan_comparison: { - matched_operator_count: 3, - unmatched_operator_a_count: 0, - unmatched_operator_b_count: 0, +export const baselineDiffQueryFixture: DiffQuerySummary = { + id: 'query-a', + engine_id: 'engine-a', + engine_name: 'Engine A', + instance_name: 'Query A', + query_group_id: 'group-1', + query_group_name: 'Group 1', +}; + +export const comparisonDiffQueryFixture: DiffQuerySummary = { + id: 'query-b', + engine_id: 'engine-b', + engine_name: 'Engine B', + instance_name: 'Query B', + query_group_id: 'group-2', + query_group_name: 'Group 2', +}; + +export const equalPlanQueryDiffFixture: QueryDiff = { + compatibility: 'compatible', + query: comparisonDiffQueryFixture, + stat_diffs: { + duration: { stats: [40, 44], delta: -4, percent_delta: -0.0909090909 }, }, operator_diffs: [ { - operator_a: { - id: 'scan-a', - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: 'plan-a', - }, - operator_b: { - id: 'scan-b', - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: 'plan-b', - }, + operators: [ + { + id: 'scan-a', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-a', + }, + { + id: 'scan-b', + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: 'plan-b', + }, + ], stats: { - duration_s: { a: 12, b: 10, delta: 2, percent_delta: 0.2 }, - input_rows: { a: 1000, b: 1200, delta: -200, percent_delta: -0.1666666667 }, - output_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, + duration_s: { stats: [12, 10], delta: 2, percent_delta: 0.2 }, + input_rows: { stats: [1000, 1200], delta: -200, percent_delta: -0.1666666667 }, + output_rows: { stats: [900, 950], delta: -50, percent_delta: -0.0526315789 }, }, }, { - operator_a: { - id: 'join-a', - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: 'plan-a', - }, - operator_b: { - id: 'join-b', - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: 'plan-b', - }, + operators: [ + { + id: 'join-a', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-a', + }, + { + id: 'join-b', + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: 'plan-b', + }, + ], stats: { - duration_s: { a: 24, b: 30, delta: -6, percent_delta: -0.2 }, - input_rows: { a: 900, b: 950, delta: -50, percent_delta: -0.0526315789 }, - output_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, + duration_s: { stats: [24, 30], delta: -6, percent_delta: -0.2 }, + input_rows: { stats: [900, 950], delta: -50, percent_delta: -0.0526315789 }, + output_rows: { stats: [400, 380], delta: 20, percent_delta: 0.0526315789 }, }, }, { - operator_a: { - id: 'agg-a', - label: 'Aggregate', - operator_type_name: 'Aggregate', - plan_id: 'plan-a', - }, - operator_b: { - id: 'agg-b', - label: 'Aggregate', - operator_type_name: 'Aggregate', - plan_id: 'plan-b', - }, + operators: [ + { + id: 'agg-a', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-a', + }, + { + id: 'agg-b', + label: 'Aggregate', + operator_type_name: 'Aggregate', + plan_id: 'plan-b', + }, + ], stats: { - duration_s: { a: 4, b: 4, delta: 0, percent_delta: 0 }, - input_rows: { a: 400, b: 380, delta: 20, percent_delta: 0.0526315789 }, - output_rows: { a: 20, b: 20, delta: 0, percent_delta: 0 }, + duration_s: { stats: [4, 4], delta: 0, percent_delta: 0 }, + input_rows: { stats: [400, 380], delta: 20, percent_delta: 0.0526315789 }, + output_rows: { stats: [20, 20], delta: 0, percent_delta: 0 }, }, }, ], }; -export const differentPlanQueryProfileDiffFixture: QueryProfileDiffResponse = { - ...equalPlanQueryProfileDiffFixture, - scenario: 'plans_different', - plan_comparison: { - matched_operator_count: 0, - unmatched_operator_a_count: 3, - unmatched_operator_b_count: 4, - }, +export const differentPlanQueryDiffFixture: QueryDiff = { + ...equalPlanQueryDiffFixture, + compatibility: 'incompatible', operator_diffs: [], warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], }; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index c687bd51..dfa5b6d1 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -68,7 +68,7 @@ interface SingleTimelineResponse { }; } -interface QueryProfileDiffTimelineRequest { +interface DiffTimelineRequest { timelines: Array<{ engine_id: string; timeline: unknown; @@ -76,6 +76,16 @@ interface QueryProfileDiffTimelineRequest { delta_config: TimelineConfig; } +interface DiffQueryRef { + engine_id: string; + query_id: string; +} + +interface DiffRequest { + baselineQuery: DiffQueryRef; + comparisonQueries: DiffQueryRef[]; +} + const QUERY_A_HIGHER_SERIES = 'Query A higher'; const QUERY_B_HIGHER_SERIES = 'Query B higher'; @@ -148,6 +158,120 @@ function writeJson(res: ServerResponse, statusCode: number, body: unknown) { res.end(JSON.stringify(body)); } +function queryNameFromId(queryId: string): string { + const parts = queryId.split('-'); + const suffix = parts[parts.length - 1]; + return suffix ? `Query ${suffix.toUpperCase()}` : queryId; +} + +function makeQueryProfileDiffResponse(body: DiffRequest) { + return { + comparisonQueries: body.comparisonQueries.map((query, index) => { + const baselineDuration = 40; + const comparisonDuration = 44 + index * 3; + return { + compatibility: 'compatible', + query: { + id: query.query_id, + engine_id: query.engine_id, + engine_name: query.engine_id, + instance_name: queryNameFromId(query.query_id), + query_group_id: null, + query_group_name: null, + }, + stat_diffs: { + duration: { + stats: [baselineDuration, comparisonDuration], + delta: baselineDuration - comparisonDuration, + percent_delta: + comparisonDuration === 0 + ? null + : (baselineDuration - comparisonDuration) / comparisonDuration, + }, + }, + operator_diffs: [ + { + operators: [ + { + id: `scan-${body.baselineQuery.query_id}`, + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: `plan-${body.baselineQuery.query_id}`, + }, + { + id: `scan-${query.query_id}`, + label: 'Scan orders', + operator_type_name: 'Scan', + plan_id: `plan-${query.query_id}`, + }, + ], + stats: { + duration_s: { stats: [12, 10 + index], delta: 2 - index, percent_delta: 0.2 }, + input_rows: { + stats: [1000, 1200 + index * 100], + delta: -200 - index * 100, + percent_delta: -0.1666666667, + }, + }, + }, + { + operators: [ + { + id: `join-${body.baselineQuery.query_id}`, + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: `plan-${body.baselineQuery.query_id}`, + }, + { + id: `join-${query.query_id}`, + label: 'Join lineitem', + operator_type_name: 'Join', + plan_id: `plan-${query.query_id}`, + }, + ], + stats: { + duration_s: { stats: [24, 30 + index], delta: -6 - index, percent_delta: -0.2 }, + output_rows: { + stats: [400, 380 - index * 20], + delta: 20 + index * 20, + percent_delta: 0.0526315789, + }, + }, + }, + ], + }; + }), + }; +} + +function installQueryProfileDiffMock(server: ViteDevServer | PreviewServer) { + server.middlewares.use(async (req, res, next) => { + if (req.method !== 'POST' || !req.url) { + next(); + return; + } + + const match = req.url.match(/^\/api\/query-profile-diff(?:\?|$)/); + if (!match) { + next(); + return; + } + + try { + const body = JSON.parse(await readRequestBody(req)) as DiffRequest; + if (!body.baselineQuery?.query_id || body.comparisonQueries.length === 0) { + throw new Error('query profile diff requires a baseline query and comparison queries'); + } + + writeJson(res, 200, makeQueryProfileDiffResponse(body)); + } catch (error) { + writeJson(res, 500, { + error: error instanceof Error ? error.message : 'Failed to mock query profile diff', + }); + } + }); +} + function installTimelineDiffMock(server: ViteDevServer | PreviewServer) { server.middlewares.use(async (req, res, next) => { if (req.method !== 'POST' || !req.url) { @@ -162,7 +286,7 @@ function installTimelineDiffMock(server: ViteDevServer | PreviewServer) { } try { - const body = JSON.parse(await readRequestBody(req)) as QueryProfileDiffTimelineRequest; + const body = JSON.parse(await readRequestBody(req)) as DiffTimelineRequest; if (body.timelines.length < 2) { throw new Error('timeline diff requires at least two timeline requests'); } @@ -216,11 +340,20 @@ function vitePluginTimelineDiffMock() { }; } +function vitePluginQueryProfileDiffMock() { + return { + name: 'vite-plugin-query-profile-diff-mock', + configureServer: installQueryProfileDiffMock, + configurePreviewServer: installQueryProfileDiffMock, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), vitePluginScriptPriority(), + vitePluginQueryProfileDiffMock(), vitePluginTimelineDiffMock(), TanStackRouterVite({ routeFileIgnorePattern: '.test.|.spec.', From a3acf3259bd7d5bdb9928a27ba3788b785ad54b7 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 14:58:58 -0600 Subject: [PATCH 18/33] Operator diffs a little better --- ui/src/test/mocks/handlers.ts | 181 ++++++++++++++++++++++++---------- ui/vite.config.ts | 177 +++++++++++++++++++++++---------- 2 files changed, 258 insertions(+), 100 deletions(-) diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index ab1774fe..ee9fc93a 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -140,6 +140,132 @@ function queryNameFromId(queryId: string): string { return suffix ? `Query ${suffix.toUpperCase()}` : queryId; } +interface MockOperatorDiffSpec { + id: string; + label: string; + operatorType: string; + duration: [number, number]; + inputRows: [number, number]; + outputRows: [number, number]; +} + +const MOCK_OPERATOR_DIFF_SPECS: MockOperatorDiffSpec[] = [ + { + id: 'scan-orders', + label: 'Scan orders', + operatorType: 'Scan', + duration: [12, 10], + inputRows: [0, 0], + outputRows: [1_000_000, 1_200_000], + }, + { + id: 'filter-active', + label: 'Filter active rows', + operatorType: 'Filter', + duration: [4, 5], + inputRows: [1_000_000, 1_200_000], + outputRows: [750_000, 820_000], + }, + { + id: 'project-columns', + label: 'Project selected columns', + operatorType: 'Project', + duration: [2, 2.5], + inputRows: [750_000, 820_000], + outputRows: [750_000, 820_000], + }, + { + id: 'join-lineitem', + label: 'Join lineitem', + operatorType: 'Join', + duration: [24, 30], + inputRows: [750_000, 820_000], + outputRows: [400_000, 380_000], + }, + { + id: 'sort-revenue', + label: 'Sort by revenue', + operatorType: 'Sort', + duration: [6, 8], + inputRows: [400_000, 380_000], + outputRows: [400_000, 380_000], + }, + { + id: 'window-rank', + label: 'Window rank', + operatorType: 'Window', + duration: [8, 7], + inputRows: [400_000, 380_000], + outputRows: [400_000, 380_000], + }, + { + id: 'aggregate-status', + label: 'Aggregate by status', + operatorType: 'Aggregate', + duration: [4, 4], + inputRows: [400_000, 380_000], + outputRows: [20, 18], + }, +]; + +function buildMockDelta([baseline, comparison]: [number, number]) { + const delta = baseline - comparison; + return { + stats: [baseline, comparison] as [number, number], + delta, + percent_delta: comparison === 0 ? null : delta / comparison, + }; +} + +function adjustComparisonValue(value: number, competitorIndex: number, statIndex: number): number { + if (value === 0) return 0; + const direction = statIndex % 2 === 0 ? 1 : -1; + return Number((value + direction * competitorIndex * Math.max(1, value * 0.08)).toFixed(3)); +} + +function buildMockOperatorDiffs( + baselineQueryId: string, + comparisonQueryId: string, + competitorIndex: number +): QueryDiff['operator_diffs'] { + return MOCK_OPERATOR_DIFF_SPECS.map((spec, statIndex) => { + const duration = [ + spec.duration[0], + adjustComparisonValue(spec.duration[1], competitorIndex, statIndex), + ] as [number, number]; + const inputRows = [ + spec.inputRows[0], + adjustComparisonValue(spec.inputRows[1], competitorIndex, statIndex), + ] as [number, number]; + const outputRows = [ + spec.outputRows[0], + adjustComparisonValue(spec.outputRows[1], competitorIndex, statIndex), + ] as [number, number]; + + return { + operators: [ + { + id: `${spec.id}-${baselineQueryId}`, + label: spec.label, + operator_type_name: spec.operatorType, + plan_id: `plan-${baselineQueryId}`, + }, + { + id: `${spec.id}-${comparisonQueryId}`, + label: spec.label, + operator_type_name: spec.operatorType, + plan_id: `plan-${comparisonQueryId}`, + }, + ], + stats: { + duration_s: buildMockDelta(duration), + input_rows: buildMockDelta(inputRows), + output_rows: buildMockDelta(outputRows), + }, + }; + }); +} + function makeMockQueryProfileDiffResponse(request: DiffRequest): DiffResponse { return { comparisonQueries: request.comparisonQueries.map((query, index): QueryDiff => { @@ -162,56 +288,11 @@ function makeMockQueryProfileDiffResponse(request: DiffRequest): DiffResponse { percent_delta: durationB === 0 ? null : (durationA - durationB) / durationB, }, }, - operator_diffs: [ - { - operators: [ - { - id: `scan-${request.baselineQuery.query_id}`, - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: `plan-${request.baselineQuery.query_id}`, - }, - { - id: `scan-${query.query_id}`, - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: `plan-${query.query_id}`, - }, - ], - stats: { - duration_s: { stats: [12, 10 + index], delta: 2 - index, percent_delta: 0.2 }, - input_rows: { - stats: [1000, 1200 + index * 100], - delta: -200 - index * 100, - percent_delta: -0.1666666667, - }, - }, - }, - { - operators: [ - { - id: `join-${request.baselineQuery.query_id}`, - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: `plan-${request.baselineQuery.query_id}`, - }, - { - id: `join-${query.query_id}`, - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: `plan-${query.query_id}`, - }, - ], - stats: { - duration_s: { stats: [24, 30 + index], delta: -6 - index, percent_delta: -0.2 }, - output_rows: { - stats: [400, 380 - index * 20], - delta: 20 + index * 20, - percent_delta: 0.0526315789, - }, - }, - }, - ], + operator_diffs: buildMockOperatorDiffs( + request.baselineQuery.query_id, + query.query_id, + index + ), }; }), }; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index dfa5b6d1..4410f20b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -164,6 +164,132 @@ function queryNameFromId(queryId: string): string { return suffix ? `Query ${suffix.toUpperCase()}` : queryId; } +interface MockOperatorDiffSpec { + id: string; + label: string; + operatorType: string; + duration: [number, number]; + inputRows: [number, number]; + outputRows: [number, number]; +} + +const MOCK_OPERATOR_DIFF_SPECS: MockOperatorDiffSpec[] = [ + { + id: 'scan-orders', + label: 'Scan orders', + operatorType: 'Scan', + duration: [12, 10], + inputRows: [0, 0], + outputRows: [1_000_000, 1_200_000], + }, + { + id: 'filter-active', + label: 'Filter active rows', + operatorType: 'Filter', + duration: [4, 5], + inputRows: [1_000_000, 1_200_000], + outputRows: [750_000, 820_000], + }, + { + id: 'project-columns', + label: 'Project selected columns', + operatorType: 'Project', + duration: [2, 2.5], + inputRows: [750_000, 820_000], + outputRows: [750_000, 820_000], + }, + { + id: 'join-lineitem', + label: 'Join lineitem', + operatorType: 'Join', + duration: [24, 30], + inputRows: [750_000, 820_000], + outputRows: [400_000, 380_000], + }, + { + id: 'sort-revenue', + label: 'Sort by revenue', + operatorType: 'Sort', + duration: [6, 8], + inputRows: [400_000, 380_000], + outputRows: [400_000, 380_000], + }, + { + id: 'window-rank', + label: 'Window rank', + operatorType: 'Window', + duration: [8, 7], + inputRows: [400_000, 380_000], + outputRows: [400_000, 380_000], + }, + { + id: 'aggregate-status', + label: 'Aggregate by status', + operatorType: 'Aggregate', + duration: [4, 4], + inputRows: [400_000, 380_000], + outputRows: [20, 18], + }, +]; + +function buildMockDelta([baseline, comparison]: [number, number]) { + const delta = baseline - comparison; + return { + stats: [baseline, comparison] as [number, number], + delta, + percent_delta: comparison === 0 ? null : delta / comparison, + }; +} + +function adjustComparisonValue(value: number, competitorIndex: number, statIndex: number): number { + if (value === 0) return 0; + const direction = statIndex % 2 === 0 ? 1 : -1; + return Number((value + direction * competitorIndex * Math.max(1, value * 0.08)).toFixed(3)); +} + +function buildMockOperatorDiffs( + baselineQueryId: string, + comparisonQueryId: string, + competitorIndex: number +) { + return MOCK_OPERATOR_DIFF_SPECS.map((spec, statIndex) => { + const duration = [ + spec.duration[0], + adjustComparisonValue(spec.duration[1], competitorIndex, statIndex), + ] as [number, number]; + const inputRows = [ + spec.inputRows[0], + adjustComparisonValue(spec.inputRows[1], competitorIndex, statIndex), + ] as [number, number]; + const outputRows = [ + spec.outputRows[0], + adjustComparisonValue(spec.outputRows[1], competitorIndex, statIndex), + ] as [number, number]; + + return { + operators: [ + { + id: `${spec.id}-${baselineQueryId}`, + label: spec.label, + operator_type_name: spec.operatorType, + plan_id: `plan-${baselineQueryId}`, + }, + { + id: `${spec.id}-${comparisonQueryId}`, + label: spec.label, + operator_type_name: spec.operatorType, + plan_id: `plan-${comparisonQueryId}`, + }, + ], + stats: { + duration_s: buildMockDelta(duration), + input_rows: buildMockDelta(inputRows), + output_rows: buildMockDelta(outputRows), + }, + }; + }); +} + function makeQueryProfileDiffResponse(body: DiffRequest) { return { comparisonQueries: body.comparisonQueries.map((query, index) => { @@ -189,56 +315,7 @@ function makeQueryProfileDiffResponse(body: DiffRequest) { : (baselineDuration - comparisonDuration) / comparisonDuration, }, }, - operator_diffs: [ - { - operators: [ - { - id: `scan-${body.baselineQuery.query_id}`, - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: `plan-${body.baselineQuery.query_id}`, - }, - { - id: `scan-${query.query_id}`, - label: 'Scan orders', - operator_type_name: 'Scan', - plan_id: `plan-${query.query_id}`, - }, - ], - stats: { - duration_s: { stats: [12, 10 + index], delta: 2 - index, percent_delta: 0.2 }, - input_rows: { - stats: [1000, 1200 + index * 100], - delta: -200 - index * 100, - percent_delta: -0.1666666667, - }, - }, - }, - { - operators: [ - { - id: `join-${body.baselineQuery.query_id}`, - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: `plan-${body.baselineQuery.query_id}`, - }, - { - id: `join-${query.query_id}`, - label: 'Join lineitem', - operator_type_name: 'Join', - plan_id: `plan-${query.query_id}`, - }, - ], - stats: { - duration_s: { stats: [24, 30 + index], delta: -6 - index, percent_delta: -0.2 }, - output_rows: { - stats: [400, 380 - index * 20], - delta: 20 + index * 20, - percent_delta: 0.0526315789, - }, - }, - }, - ], + operator_diffs: buildMockOperatorDiffs(body.baselineQuery.query_id, query.query_id, index), }; }), }; From fa2fef0b5fa86b1f88df0ef8781ee0e2e971b503 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 15:57:47 -0600 Subject: [PATCH 19/33] Competitor->Comparison, use the real API for operator table stats --- ui/packages/@quent/client/src/index.ts | 4 + .../client/src/queryProfileDiffFromBundles.ts | 204 +++++++++++ .../query-diff/QueryDiffColors.test.ts | 26 +- .../components/query-diff/QueryDiffColors.ts | 14 +- .../components/query-diff/QueryDiffStats.tsx | 80 ++--- .../query-diff/QueryDiffTable.test.tsx | 9 +- .../components/query-diff/QueryDiffTable.tsx | 2 +- .../query-diff/QueryDiffTable.utils.ts | 18 +- .../query-diff/QueryDiffTimeline.tsx | 202 +++++------ .../QueryDiffTimeline.utils.test.ts | 10 +- .../query-diff/QueryDiffTimeline.utils.ts | 28 +- .../query-diff/queryProfileDiffFromBundles.ts | 163 +-------- ui/src/pages/DiffSelectionPage.tsx | 330 +++++++++--------- ...neQueryId.compare.$comparisonQueryIds.tsx} | 8 +- ui/src/routes/diff.test.tsx | 20 +- ui/src/test/mocks/handlers.ts | 236 ++++--------- ui/vite.config.ts | 224 +++--------- 17 files changed, 689 insertions(+), 889 deletions(-) create mode 100644 ui/packages/@quent/client/src/queryProfileDiffFromBundles.ts rename ui/src/routes/{diff.query.$baselineQueryId.compare.$competitorQueryIds.tsx => diff.query.$baselineQueryId.compare.$comparisonQueryIds.tsx} (65%) diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index b4ab7451..4080efb4 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -28,6 +28,10 @@ export { queryProfileDiffQueryOptions, queryProfileDiffTimelineQueryOptions, } from './queryProfileDiff'; +export { + buildQueryProfileDiffFromBundles, + buildQueryProfileDiffResponseFromBundles, +} from './queryProfileDiffFromBundles'; // Hooks export { useQueryBundle } from './queryBundle'; diff --git a/ui/packages/@quent/client/src/queryProfileDiffFromBundles.ts b/ui/packages/@quent/client/src/queryProfileDiffFromBundles.ts new file mode 100644 index 00000000..bbaba63a --- /dev/null +++ b/ui/packages/@quent/client/src/queryProfileDiffFromBundles.ts @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { EntityRef, Operator, PlanTree, QueryBundle, StatValue } from '@quent/utils'; +import type { + DiffDelta, + DiffOperatorDelta, + DiffOperatorRef, + DiffQuerySummary, + DiffResponse, + QueryDiff, +} from './queryProfileDiffTypes'; + +interface PlanSignature { + operators: string[]; + children: PlanSignature[]; +} + +function unwrapToString(val: unknown): string { + const result = unwrapTaggedValue(val); + return Array.isArray(result) ? result.join('\n') : String(result ?? ''); +} + +function unwrapTaggedValue(val: unknown): StatValue { + switch (true) { + case val === null || val === undefined: + return null; + case typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean': + return val as StatValue; + case Array.isArray(val): + return (val as unknown[]).map(unwrapToString); + case typeof val === 'object': { + const obj = val as Record; + const keys = Object.keys(obj); + if (keys.length === 2 && 'key' in obj && 'value' in obj) { + return `${obj.key}: ${unwrapToString(obj.value)}`; + } + if (keys.length === 1) { + return unwrapTaggedValue(Object.values(obj)[0]); + } + return JSON.stringify(val); + } + default: + return String(val); + } +} + +function parseCustomStatistics(rawNode: unknown): Array<{ key: string; value: StatValue }> { + const statistics = (rawNode as Operator)?.statistics?.custom_statistics; + if (!statistics) return []; + + return Object.entries(statistics).map(([key, tagged]) => ({ + key, + value: tagged + ? unwrapTaggedValue(Object.values(tagged as unknown as Record)[0]) + : null, + })); +} + +function getOperatorsForPlan(bundle: QueryBundle, planId: string): Operator[] { + return Object.values(bundle.entities.operators) + .filter((operator): operator is Operator => operator != null && operator.plan_id === planId) + .sort((a, b) => { + const typeCompare = (a.operator_type_name ?? '').localeCompare(b.operator_type_name ?? ''); + if (typeCompare !== 0) return typeCompare; + const nameCompare = (a.instance_name ?? '').localeCompare(b.instance_name ?? ''); + if (nameCompare !== 0) return nameCompare; + return a.id.localeCompare(b.id); + }); +} + +function getOperatorSignature(operator: Operator): string { + return `${operator.operator_type_name ?? ''}:${operator.instance_name ?? ''}`; +} + +function getPlanSignature(bundle: QueryBundle, node: PlanTree): PlanSignature { + return { + operators: getOperatorsForPlan(bundle, node.id).map(getOperatorSignature), + children: node.children.map(child => getPlanSignature(bundle, child)), + }; +} + +function signaturesEqual(a: PlanSignature, b: PlanSignature): boolean { + if (a.operators.length !== b.operators.length || a.children.length !== b.children.length) { + return false; + } + if (a.operators.some((signature, index) => signature !== b.operators[index])) { + return false; + } + return a.children.every((child, index) => signaturesEqual(child, b.children[index]!)); +} + +function flattenOperatorsByPlanTree(bundle: QueryBundle, node: PlanTree): Operator[] { + return [ + ...getOperatorsForPlan(bundle, node.id), + ...node.children.flatMap(child => flattenOperatorsByPlanTree(bundle, child)), + ]; +} + +function getQuerySummary(bundle: QueryBundle): DiffQuerySummary { + return { + id: bundle.entities.query.id, + engine_id: bundle.entities.engine.id, + engine_name: bundle.entities.engine.instance_name ?? null, + instance_name: bundle.entities.query.instance_name ?? null, + query_group_id: bundle.entities.query_group.id, + query_group_name: bundle.entities.query_group.instance_name ?? null, + }; +} + +function getOperatorRef(operator: Operator): DiffOperatorRef { + return { + id: operator.id, + label: operator.instance_name ?? operator.id, + operator_type_name: operator.operator_type_name ?? null, + plan_id: operator.plan_id ?? null, + }; +} + +function getOperatorStats(operator: Operator): Record { + const stats: Record = { + duration_s: operator.active_span + ? Number((operator.active_span.end - operator.active_span.start).toFixed(6)) + : null, + }; + + for (const stat of parseCustomStatistics(operator)) { + stats[stat.key] = stat.value; + } + + return stats; +} + +function buildStatDelta(a: StatValue, b: StatValue): DiffDelta { + const delta = typeof a === 'number' && typeof b === 'number' ? a - b : null; + return { + stats: [a, b], + delta, + percent_delta: delta != null && typeof b === 'number' && b !== 0 ? delta / b : null, + }; +} + +function buildOperatorDelta(operatorA: Operator, operatorB: Operator): DiffOperatorDelta { + const statsA = getOperatorStats(operatorA); + const statsB = getOperatorStats(operatorB); + const statNames = [...new Set([...Object.keys(statsA), ...Object.keys(statsB)])].sort(); + + return { + operators: [getOperatorRef(operatorA), getOperatorRef(operatorB)], + stats: Object.fromEntries( + statNames.map(statName => [ + statName, + buildStatDelta(statsA[statName] ?? null, statsB[statName] ?? null), + ]) + ), + }; +} + +export function buildQueryProfileDiffFromBundles( + baselineQuery: QueryBundle, + comparisonQuery: QueryBundle +): QueryDiff { + const query = getQuerySummary(comparisonQuery); + const stat_diffs = { + duration: buildStatDelta(baselineQuery.duration_s, comparisonQuery.duration_s), + }; + + const signatureA = getPlanSignature(baselineQuery, baselineQuery.plan_tree); + const signatureB = getPlanSignature(comparisonQuery, comparisonQuery.plan_tree); + + if (!signaturesEqual(signatureA, signatureB)) { + return { + compatibility: 'incompatible', + query, + stat_diffs, + operator_diffs: [], + warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], + }; + } + + const operatorsA = flattenOperatorsByPlanTree(baselineQuery, baselineQuery.plan_tree); + const operatorsB = flattenOperatorsByPlanTree(comparisonQuery, comparisonQuery.plan_tree); + const matchedCount = Math.min(operatorsA.length, operatorsB.length); + + return { + compatibility: 'compatible', + query, + stat_diffs, + operator_diffs: operatorsA + .slice(0, matchedCount) + .map((operatorA, index) => buildOperatorDelta(operatorA, operatorsB[index]!)), + }; +} + +export function buildQueryProfileDiffResponseFromBundles( + baselineQuery: QueryBundle, + comparisonQueries: QueryBundle[] +): DiffResponse { + return { + comparisonQueries: comparisonQueries.map(comparisonQuery => + buildQueryProfileDiffFromBundles(baselineQuery, comparisonQuery) + ), + }; +} diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts index 024644e3..f86b94b8 100644 --- a/ui/src/components/query-diff/QueryDiffColors.test.ts +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -20,38 +20,38 @@ describe('QueryDiffColors', () => { it('assigns distinct palette colors to the compared queries', () => { const colors = getQueryDiffQueryColors({ baselineQueryId: 'query-a', - competitorQueryId: 'query-b', + comparisonQueryId: 'query-b', theme: 'light', }); - expect(colors.baseline).not.toBe(colors.competitor); + expect(colors.baseline).not.toBe(colors.comparison); }); it('keeps colors distinct when the same query id is compared', () => { const colors = getQueryDiffQueryColors({ baselineQueryId: 'query-a', - competitorQueryId: 'query-a', + comparisonQueryId: 'query-a', theme: 'light', }); - expect(colors.baseline).not.toBe(colors.competitor); + expect(colors.baseline).not.toBe(colors.comparison); }); - it('assigns different colors to multiple competitor queries', () => { - const firstCompetitor = getQueryDiffQueryColors({ + it('assigns different colors to multiple comparison queries', () => { + const firstComparison = getQueryDiffQueryColors({ baselineQueryId: 'query-a', - competitorQueryId: 'query-b', - competitorIndex: 0, + comparisonQueryId: 'query-b', + comparisonIndex: 0, theme: 'light', }); - const secondCompetitor = getQueryDiffQueryColors({ + const secondComparison = getQueryDiffQueryColors({ baselineQueryId: 'query-a', - competitorQueryId: 'query-c', - competitorIndex: 1, + comparisonQueryId: 'query-c', + comparisonIndex: 1, theme: 'light', }); - expect(firstCompetitor.baseline).toBe(secondCompetitor.baseline); - expect(firstCompetitor.competitor).not.toBe(secondCompetitor.competitor); + expect(firstComparison.baseline).toBe(secondComparison.baseline); + expect(firstComparison.comparison).not.toBe(secondComparison.comparison); }); }); diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index 40aeb092..d761f8c5 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -11,7 +11,7 @@ import { const TOL_GREEN_INDEX = 0; const TOL_RED_INDEX = 1; const BASELINE_QUERY_COLOR_INDEX = 5; -const COMPETITOR_QUERY_COLOR_INDICES = [4, 6, 2, 3, 7, 8, 9, 10]; +const COMPARISON_QUERY_COLOR_INDICES = [4, 6, 2, 3, 7, 8, 9, 10]; export function getDiffPositiveColor(theme: PaletteTheme): string { return getPalette('extended', theme)[TOL_RED_INDEX]!; @@ -26,22 +26,22 @@ export const DIFF_NEGATIVE_COLOR = getDiffNegativeColor('light'); export interface QueryDiffQueryColors { baseline: string; - competitor: string; + comparison: string; } export function getQueryDiffQueryColors({ - competitorIndex = 0, + comparisonIndex = 0, theme, }: { baselineQueryId: string; - competitorQueryId: string; - competitorIndex?: number; + comparisonQueryId: string; + comparisonIndex?: number; theme: PaletteTheme; }): QueryDiffQueryColors { return { baseline: getColorByIndex(BASELINE_QUERY_COLOR_INDEX, theme), - competitor: getColorByIndex( - COMPETITOR_QUERY_COLOR_INDICES[competitorIndex % COMPETITOR_QUERY_COLOR_INDICES.length]!, + comparison: getColorByIndex( + COMPARISON_QUERY_COLOR_INDICES[comparisonIndex % COMPARISON_QUERY_COLOR_INDICES.length]!, theme ), }; diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index 9dbecbf6..631193d4 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -34,8 +34,8 @@ interface QueryDiffStatsProps { comparisonQuery: DiffQuerySummary; diff: QueryDiff; baselineBundle: QueryBundle; - competitorBundle: QueryBundle; - competitorIndex?: number; + comparisonBundle: QueryBundle; + comparisonIndex?: number; } export interface QueryDiffStatsOverviewComparison { @@ -44,8 +44,8 @@ export interface QueryDiffStatsOverviewComparison { comparisonQuery: DiffQuerySummary; diff: QueryDiff; baselineBundle: QueryBundle; - competitorBundle: QueryBundle; - competitorIndex: number; + comparisonBundle: QueryBundle; + comparisonIndex: number; } function runtimeValueStyle(delta: number, paletteTheme: PaletteTheme): CSSProperties | undefined { @@ -66,12 +66,12 @@ function displayPercentDelta(percentDelta: number | null): number | null { function runtimeComparisons({ comparison, baselineName, - competitorName, + comparisonName, queryColors, }: { comparison: RuntimeComparison; baselineName: string; - competitorName: string; + comparisonName: string; queryColors: QueryDiffQueryColors; }): StatisticCardComparison[] { return [ @@ -82,10 +82,10 @@ function runtimeComparisons({ color: queryColors.baseline, }, { - id: 'competitor', - label: competitorName, + id: 'comparison', + label: comparisonName, value: formatDurationSeconds(comparison.b), - color: queryColors.competitor, + color: queryColors.comparison, }, ]; } @@ -93,13 +93,13 @@ function runtimeComparisons({ function RuntimeComparisonCard({ comparison, baselineName, - competitorName, + comparisonName, queryColors, paletteTheme, }: { comparison: RuntimeComparison; baselineName: string; - competitorName: string; + comparisonName: string; queryColors: QueryDiffQueryColors; paletteTheme: PaletteTheme; }) { @@ -113,7 +113,7 @@ function RuntimeComparisonCard({ comparisons={runtimeComparisons({ comparison, baselineName, - competitorName, + comparisonName, queryColors, })} comparisonSeparator={ @@ -144,10 +144,10 @@ function operatorRuntimeChartRows( label: 'Baseline value', }, { - id: 'competitor', + id: 'comparison', value: comparison.b, - color: queryColors.competitor, - label: 'Competitor value', + color: queryColors.comparison, + label: 'Comparison value', }, ], })); @@ -165,18 +165,18 @@ function aggregateOperatorRuntimeChartRows({ { label: string; baselineValue: number; - competitorBars: Array<{ id: string; value: number; color: string; label: string }>; + comparisonBars: Array<{ id: string; value: number; color: string; label: string }>; } >(); for (const comparison of comparisons) { const operatorComparisons = buildOperatorTypeRuntimeComparisons(comparison.diff); - const competitorName = + const comparisonName = comparison.comparisonQuery.instance_name ?? comparison.comparisonQuery.id; const queryColors = getQueryDiffQueryColors({ baselineQueryId: comparison.baselineQuery.id, - competitorQueryId: comparison.comparisonQuery.id, - competitorIndex: comparison.competitorIndex, + comparisonQueryId: comparison.comparisonQuery.id, + comparisonIndex: comparison.comparisonIndex, theme: paletteTheme, }); @@ -184,14 +184,14 @@ function aggregateOperatorRuntimeChartRows({ const row = rowsByOperatorType.get(operatorComparison.id) ?? { label: operatorComparison.label, baselineValue: operatorComparison.a, - competitorBars: [], + comparisonBars: [], }; row.baselineValue = Math.max(row.baselineValue, operatorComparison.a); - row.competitorBars.push({ + row.comparisonBars.push({ id: comparison.id, value: operatorComparison.b, - color: queryColors.competitor, - label: `${competitorName} value`, + color: queryColors.comparison, + label: `${comparisonName} value`, }); rowsByOperatorType.set(operatorComparison.id, row); } @@ -203,7 +203,7 @@ function aggregateOperatorRuntimeChartRows({ label: row.label, labelColor: getQueryDiffOperatorTypeColor(operatorType), title: formatDurationSeconds( - Math.max(row.baselineValue, ...row.competitorBars.map(bar => bar.value)) + Math.max(row.baselineValue, ...row.comparisonBars.map(bar => bar.value)) ), bars: [ { @@ -211,12 +211,12 @@ function aggregateOperatorRuntimeChartRows({ value: row.baselineValue, color: getQueryDiffQueryColors({ baselineQueryId: comparisons[0]?.baselineQuery.id ?? '', - competitorQueryId: comparisons[0]?.comparisonQuery.id ?? '', + comparisonQueryId: comparisons[0]?.comparisonQuery.id ?? '', theme: paletteTheme, }).baseline, label: 'Baseline value', }, - ...row.competitorBars, + ...row.comparisonBars, ], })) .sort((left, right) => { @@ -231,22 +231,22 @@ export function QueryDiffStats({ comparisonQuery, diff, baselineBundle, - competitorBundle, - competitorIndex = 0, + comparisonBundle, + comparisonIndex = 0, }: QueryDiffStatsProps) { const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; const baselineName = baselineQuery.instance_name ?? baselineQuery.id; - const competitorName = comparisonQuery.instance_name ?? comparisonQuery.id; + const comparisonName = comparisonQuery.instance_name ?? comparisonQuery.id; const queryColors = useMemo( () => getQueryDiffQueryColors({ baselineQueryId: baselineQuery.id, - competitorQueryId: comparisonQuery.id, - competitorIndex, + comparisonQueryId: comparisonQuery.id, + comparisonIndex, theme: paletteTheme, }), - [baselineQuery.id, comparisonQuery.id, competitorIndex, paletteTheme] + [baselineQuery.id, comparisonQuery.id, comparisonIndex, paletteTheme] ); const operatorRuntimeComparisons = useMemo( () => buildOperatorTypeRuntimeComparisons(diff), @@ -257,9 +257,9 @@ export function QueryDiffStats({ buildRuntimeComparisonFromDelta( diff.stat_diffs?.duration, baselineBundle.duration_s, - competitorBundle.duration_s + comparisonBundle.duration_s ), - [baselineBundle.duration_s, competitorBundle.duration_s, diff.stat_diffs?.duration] + [baselineBundle.duration_s, comparisonBundle.duration_s, diff.stat_diffs?.duration] ); return ( @@ -268,7 +268,7 @@ export function QueryDiffStats({ @@ -302,16 +302,16 @@ export function QueryDiffOverviewStats({ comparisons.map(comparison => ({ ...comparison, baselineName: comparison.baselineQuery.instance_name ?? comparison.baselineQuery.id, - competitorName: comparison.comparisonQuery.instance_name ?? comparison.comparisonQuery.id, + comparisonName: comparison.comparisonQuery.instance_name ?? comparison.comparisonQuery.id, runtimeComparison: buildRuntimeComparisonFromDelta( comparison.diff.stat_diffs?.duration, comparison.baselineBundle.duration_s, - comparison.competitorBundle.duration_s + comparison.comparisonBundle.duration_s ), queryColors: getQueryDiffQueryColors({ baselineQueryId: comparison.baselineQuery.id, - competitorQueryId: comparison.comparisonQuery.id, - competitorIndex: comparison.competitorIndex, + comparisonQueryId: comparison.comparisonQuery.id, + comparisonIndex: comparison.comparisonIndex, theme: paletteTheme, }), })), @@ -330,7 +330,7 @@ export function QueryDiffOverviewStats({ key={comparison.id} comparison={comparison.runtimeComparison} baselineName={comparison.baselineName} - competitorName={comparison.competitorName} + comparisonName={comparison.comparisonName} queryColors={comparison.queryColors} paletteTheme={paletteTheme} /> diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index f4826aa1..ea1ed396 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -24,12 +24,9 @@ describe('QueryDiffTable helpers', () => { expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ - engineGroupId: 'engine-a:engine-b', - engineGroupLabel: 'Engine A, Engine B', - engines: [ - { id: 'engine-a', label: 'Engine A' }, - { id: 'engine-b', label: 'Engine B' }, - ], + engineGroupId: 'engine-b', + engineGroupLabel: 'Engine B', + engines: [{ id: 'engine-b', label: 'Engine B' }], operatorType: 'Scan', operatorLabel: 'Scan orders <-> Scan orders\nscan-a <-> scan-b', operatorAId: 'scan-a', diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index 4e8ef137..ddbe46f4 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -148,7 +148,7 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi const indexLabels: Record = useMemo( () => ({ - engine: 'Engine', + engine: 'Comparison Engine', operator_type: 'Operator Type', operator: 'Operator Pair', }), diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index ca2d7bce..895dbbdb 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -32,15 +32,6 @@ function getQueryEngine(query: DiffQuerySummary): QueryDiffTableEngine { }; } -function uniqueEngines(engines: QueryDiffTableEngine[]): QueryDiffTableEngine[] { - const seen = new Set(); - return engines.filter(engine => { - if (seen.has(engine.id)) return false; - seen.add(engine.id); - return true; - }); -} - function displayDeltaValue(value: StatValue): StatValue { if (typeof value !== 'number') return value; return value === 0 || Object.is(value, -0) ? 0 : -value; @@ -56,13 +47,14 @@ function formatOperatorPairLabel( } export function buildQueryDiffRows( - baselineQuery: DiffQuerySummary, + _baselineQuery: DiffQuerySummary, comparisonQuery: DiffQuerySummary, diff: QueryDiff ): QueryDiffTableRow[] { - const engines = uniqueEngines([getQueryEngine(baselineQuery), getQueryEngine(comparisonQuery)]); - const engineGroupId = engines.map(engine => engine.id).join(':'); - const engineGroupLabel = engines.map(engine => engine.label).join(', '); + const comparisonEngine = getQueryEngine(comparisonQuery); + const engines = [comparisonEngine]; + const engineGroupId = comparisonEngine.id; + const engineGroupLabel = comparisonEngine.label; return (diff.operator_diffs ?? []).flatMap(entry => { const [operatorA, operatorB] = entry.operators; diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 45cdc9c2..416d3685 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -49,20 +49,20 @@ import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { baselineEngineId: string; - competitorEngineId: string; + comparisonEngineId: string; diff: QueryDiff; baselineBundle: QueryBundle; - competitorBundle: QueryBundle; - competitorIndex?: number; + comparisonBundle: QueryBundle; + comparisonIndex?: number; } export interface QueryDiffTimelineListComparison { id: string; - competitorIndex: number; - competitorEngineId: string; + comparisonIndex: number; + comparisonEngineId: string; comparisonQuery: DiffQuerySummary; diff: QueryDiff; - competitorBundle: QueryBundle; + comparisonBundle: QueryBundle; } interface QueryDiffTimelineListProps { @@ -116,13 +116,13 @@ function querySummaryLabel(query: DiffQuerySummary): string { return query.instance_name ?? query.id; } -function getBaselineResourceTypesSharedWithCompetitors( +function getBaselineResourceTypesSharedWithComparisons( baselineTarget: TimelineTarget | null, - competitorTargets: Array + comparisonTargets: Array ): string[] { if (!baselineTarget) return []; return baselineTarget.resourceTypes.filter(type => - competitorTargets.some(target => target?.resourceTypes.includes(type)) + comparisonTargets.some(target => target?.resourceTypes.includes(type)) ); } @@ -223,22 +223,22 @@ function QueryDiffTimelinePairRows({ const { theme } = useTheme(); const isDark = theme === THEME_DARK; const paletteTheme = isDark ? 'dark' : 'light'; - const competitorTarget = useMemo( - () => getTimelineTarget(comparison.competitorBundle), - [comparison.competitorBundle] + const comparisonTarget = useMemo( + () => getTimelineTarget(comparison.comparisonBundle), + [comparison.comparisonBundle] ); const canRenderResourceType = Boolean( - baselineTarget && competitorTarget?.resourceTypes.includes(resourceType) + baselineTarget && comparisonTarget?.resourceTypes.includes(resourceType) ); const queryColors = useMemo( () => getQueryDiffQueryColors({ baselineQueryId, - competitorQueryId: comparison.comparisonQuery.id, - competitorIndex: comparison.competitorIndex, + comparisonQueryId: comparison.comparisonQuery.id, + comparisonIndex: comparison.comparisonIndex, theme: paletteTheme, }), - [baselineQueryId, comparison.comparisonQuery.id, comparison.competitorIndex, paletteTheme] + [baselineQueryId, comparison.comparisonQuery.id, comparison.comparisonIndex, paletteTheme] ); const baselineRequest = useMemo(() => { @@ -257,28 +257,28 @@ function QueryDiffTimelinePairRows({ resourceType, ]); - const competitorRequest = useMemo(() => { - if (!competitorTarget || !resourceType || !canRenderResourceType) return null; + const comparisonRequest = useMemo(() => { + if (!comparisonTarget || !resourceType || !canRenderResourceType) return null; return buildRootTimelineRequest({ - queryId: comparison.competitorBundle.query_id, - rootResourceGroupId: competitorTarget.rootResourceGroupId, + queryId: comparison.comparisonBundle.query_id, + rootResourceGroupId: comparisonTarget.rootResourceGroupId, resourceTypeName: resourceType, - durationSeconds: comparison.competitorBundle.duration_s, + durationSeconds: comparison.comparisonBundle.duration_s, }); }, [ - comparison.competitorBundle.duration_s, - comparison.competitorBundle.query_id, + comparison.comparisonBundle.duration_s, + comparison.comparisonBundle.query_id, canRenderResourceType, - competitorTarget, + comparisonTarget, resourceType, ]); const timelineDiffRequest = useMemo(() => { - if (!baselineRequest || !competitorRequest || durationSeconds <= 0) return null; + if (!baselineRequest || !comparisonRequest || durationSeconds <= 0) return null; return { timelines: [ { engine_id: baselineEngineId, timeline: baselineRequest }, - { engine_id: comparison.competitorEngineId, timeline: competitorRequest }, + { engine_id: comparison.comparisonEngineId, timeline: comparisonRequest }, ], delta_config: { num_bins: getAdaptiveNumBins(), @@ -289,8 +289,8 @@ function QueryDiffTimelinePairRows({ }, [ baselineEngineId, baselineRequest, - comparison.competitorEngineId, - competitorRequest, + comparison.comparisonEngineId, + comparisonRequest, durationSeconds, ]); @@ -299,10 +299,10 @@ function QueryDiffTimelinePairRows({ 'queryDiffTimelineListPair', baselineEngineId, baselineQueryId, - comparison.competitorEngineId, + comparison.comparisonEngineId, comparison.comparisonQuery.id, baselineTarget?.rootResourceGroupId, - competitorTarget?.rootResourceGroupId, + comparisonTarget?.rootResourceGroupId, timelineDiffRequest, ], queryFn: () => fetchQueryProfileDiffTimeline(timelineDiffRequest!), @@ -312,19 +312,19 @@ function QueryDiffTimelinePairRows({ const timelineData = useMemo(() => { if (!timelineDiff.data || durationSeconds <= 0) return null; - const resourceTypeDecl = comparison.competitorBundle.entities.resource_types[resourceType]; + const resourceTypeDecl = comparison.comparisonBundle.entities.resource_types[resourceType]; return buildDiffTimelineData({ timelineDiff: timelineDiff.data, theme: paletteTheme, capacities: resourceTypeDecl?.capacities, - quantitySpecs: comparison.competitorBundle.quantity_specs, - fsmTypes: comparison.competitorBundle.entities.fsm_types, + quantitySpecs: comparison.comparisonBundle.quantity_specs, + fsmTypes: comparison.comparisonBundle.entities.fsm_types, queryColors, }); }, [ - comparison.competitorBundle.entities.fsm_types, - comparison.competitorBundle.entities.resource_types, - comparison.competitorBundle.quantity_specs, + comparison.comparisonBundle.entities.fsm_types, + comparison.comparisonBundle.entities.resource_types, + comparison.comparisonBundle.quantity_specs, durationSeconds, paletteTheme, queryColors, @@ -332,11 +332,11 @@ function QueryDiffTimelinePairRows({ timelineDiff.data, ]); - const competitorName = querySummaryLabel(comparison.comparisonQuery); + const comparisonName = querySummaryLabel(comparison.comparisonQuery); if (!canRenderResourceType) { return ( - +
No shared resource type available.
@@ -346,7 +346,7 @@ function QueryDiffTimelinePairRows({ if (timelineDiff.isLoading) { return ( - +
Loading timeline...
@@ -356,7 +356,7 @@ function QueryDiffTimelinePairRows({ if (timelineDiff.isError || !timelineData) { return ( - +
Failed to load timeline delta
@@ -367,20 +367,20 @@ function QueryDiffTimelinePairRows({ return ( <> {competitorName}} + label="Comparison" + color={queryColors.comparison} + detail={{comparisonName}} > - + getTimelineTarget(baselineBundle), [baselineBundle]); - const competitorTargets = useMemo( - () => comparisons.map(comparison => getTimelineTarget(comparison.competitorBundle)), + const comparisonTargets = useMemo( + () => comparisons.map(comparison => getTimelineTarget(comparison.comparisonBundle)), [comparisons] ); const sharedResourceTypes = useMemo( - () => getBaselineResourceTypesSharedWithCompetitors(baselineTarget, competitorTargets), - [baselineTarget, competitorTargets] + () => getBaselineResourceTypesSharedWithComparisons(baselineTarget, comparisonTargets), + [baselineTarget, comparisonTargets] ); const [resourceType, setResourceType] = useState(''); const durationSeconds = Math.max( baselineBundle.duration_s, - ...comparisons.map(comparison => comparison.competitorBundle.duration_s) + ...comparisons.map(comparison => comparison.comparisonBundle.duration_s) ); const baselineName = baselineBundle.entities.query.instance_name ?? baselineBundle.query_id; - const competitorCountLabel = - comparisons.length === 1 ? '1 competitor query' : `${comparisons.length} competitor queries`; + const comparisonCountLabel = + comparisons.length === 1 ? '1 comparison query' : `${comparisons.length} comparison queries`; const queryColors = useMemo( () => getQueryDiffQueryColors({ baselineQueryId: baselineBundle.query_id, - competitorQueryId: comparisons[0]?.comparisonQuery.id ?? '', + comparisonQueryId: comparisons[0]?.comparisonQuery.id ?? '', theme: paletteTheme, }), [baselineBundle.query_id, comparisons, paletteTheme] @@ -508,7 +508,7 @@ export function QueryDiffTimelineList({
{baselineName} vs - {competitorCountLabel} + {comparisonCountLabel} {durationSeconds > 0 && {formatDuration(durationSeconds * 1_000)}}
@@ -521,14 +521,14 @@ export function QueryDiffTimelineList({ className="h-2 w-2 rounded-full" style={{ backgroundColor: diffNegativeColor }} /> - Competitor lower + Comparison lower - Competitor higher + Comparison higher
)} @@ -625,11 +625,11 @@ export function QueryDiffTimelineList({ export function QueryDiffTimeline({ baselineEngineId, - competitorEngineId, + comparisonEngineId, diff, baselineBundle, - competitorBundle, - competitorIndex = 0, + comparisonBundle, + comparisonIndex = 0, }: QueryDiffTimelineProps) { const { theme } = useTheme(); const isDark = theme === THEME_DARK; @@ -638,36 +638,36 @@ export function QueryDiffTimeline({ const setDebouncedZoomRange = useSetDebouncedZoomRange(); const baselineQuery = useMemo(() => bundleQuerySummary(baselineBundle), [baselineBundle]); - const competitorQuery = useMemo( - () => diff.query ?? bundleQuerySummary(competitorBundle), - [competitorBundle, diff.query] + const comparisonQuery = useMemo( + () => diff.query ?? bundleQuerySummary(comparisonBundle), + [comparisonBundle, diff.query] ); const baselineQueryId = baselineQuery.id; - const competitorQueryId = competitorQuery.id; + const comparisonQueryId = comparisonQuery.id; const baselineName = querySummaryLabel(baselineQuery); - const competitorName = querySummaryLabel(competitorQuery); + const comparisonName = querySummaryLabel(comparisonQuery); const queryColors = useMemo( () => getQueryDiffQueryColors({ baselineQueryId, - competitorQueryId, - competitorIndex, + comparisonQueryId, + comparisonIndex, theme: paletteTheme, }), - [baselineQueryId, competitorIndex, competitorQueryId, paletteTheme] + [baselineQueryId, comparisonIndex, comparisonQueryId, paletteTheme] ); const diffPositiveColor = getDiffPositiveColor(paletteTheme); const diffNegativeColor = getDiffNegativeColor(paletteTheme); const baselineTarget = useMemo(() => getTimelineTarget(baselineBundle), [baselineBundle]); - const competitorTarget = useMemo(() => getTimelineTarget(competitorBundle), [competitorBundle]); + const comparisonTarget = useMemo(() => getTimelineTarget(comparisonBundle), [comparisonBundle]); const sharedResourceTypes = useMemo( () => getSharedResourceTypes( baselineTarget?.resourceTypes ?? [], - competitorTarget?.resourceTypes ?? [] + comparisonTarget?.resourceTypes ?? [] ), - [baselineTarget?.resourceTypes, competitorTarget?.resourceTypes] + [baselineTarget?.resourceTypes, comparisonTarget?.resourceTypes] ); const [resourceType, setResourceType] = useState(''); @@ -679,14 +679,14 @@ export function QueryDiffTimeline({ setResourceType(prev => (sharedResourceTypes.includes(prev) ? prev : sharedResourceTypes[0]!)); }, [sharedResourceTypes]); - const durationSeconds = Math.max(baselineBundle.duration_s, competitorBundle.duration_s); + const durationSeconds = Math.max(baselineBundle.duration_s, comparisonBundle.duration_s); useEffect(() => { if (durationSeconds <= 0) return; const full = { start: 0, end: durationSeconds }; setZoomRange(full); setDebouncedZoomRange(full); - }, [durationSeconds, baselineQueryId, competitorQueryId, setZoomRange, setDebouncedZoomRange]); + }, [durationSeconds, baselineQueryId, comparisonQueryId, setZoomRange, setDebouncedZoomRange]); const baselineRequest = useMemo(() => { if (!baselineTarget || !resourceType) return null; @@ -698,22 +698,22 @@ export function QueryDiffTimeline({ }); }, [baselineBundle.duration_s, baselineBundle.query_id, baselineTarget, resourceType]); - const competitorRequest = useMemo(() => { - if (!competitorTarget || !resourceType) return null; + const comparisonRequest = useMemo(() => { + if (!comparisonTarget || !resourceType) return null; return buildRootTimelineRequest({ - queryId: competitorBundle.query_id, - rootResourceGroupId: competitorTarget.rootResourceGroupId, + queryId: comparisonBundle.query_id, + rootResourceGroupId: comparisonTarget.rootResourceGroupId, resourceTypeName: resourceType, - durationSeconds: competitorBundle.duration_s, + durationSeconds: comparisonBundle.duration_s, }); - }, [competitorBundle.duration_s, competitorBundle.query_id, competitorTarget, resourceType]); + }, [comparisonBundle.duration_s, comparisonBundle.query_id, comparisonTarget, resourceType]); const timelineDiffRequest = useMemo(() => { - if (!baselineRequest || !competitorRequest || durationSeconds <= 0) return null; + if (!baselineRequest || !comparisonRequest || durationSeconds <= 0) return null; return { timelines: [ { engine_id: baselineEngineId, timeline: baselineRequest }, - { engine_id: competitorEngineId, timeline: competitorRequest }, + { engine_id: comparisonEngineId, timeline: comparisonRequest }, ], delta_config: { num_bins: getAdaptiveNumBins(), @@ -721,17 +721,17 @@ export function QueryDiffTimeline({ end: durationSeconds, }, }; - }, [baselineEngineId, baselineRequest, competitorEngineId, competitorRequest, durationSeconds]); + }, [baselineEngineId, baselineRequest, comparisonEngineId, comparisonRequest, durationSeconds]); const timelineDiff = useQuery({ queryKey: [ 'queryDiffTimeline', baselineEngineId, baselineQueryId, - competitorEngineId, - competitorQueryId, + comparisonEngineId, + comparisonQueryId, baselineTarget?.rootResourceGroupId, - competitorTarget?.rootResourceGroupId, + comparisonTarget?.rootResourceGroupId, timelineDiffRequest, ], queryFn: () => fetchQueryProfileDiffTimeline(timelineDiffRequest!), @@ -743,22 +743,22 @@ export function QueryDiffTimeline({ if (!timelineDiff.data || durationSeconds <= 0) return null; const resourceTypeDecl = baselineBundle.entities.resource_types[resourceType] ?? - competitorBundle.entities.resource_types[resourceType]; + comparisonBundle.entities.resource_types[resourceType]; return buildDiffTimelineData({ timelineDiff: timelineDiff.data, theme: paletteTheme, capacities: resourceTypeDecl?.capacities, - quantitySpecs: baselineBundle.quantity_specs ?? competitorBundle.quantity_specs, - fsmTypes: baselineBundle.entities.fsm_types ?? competitorBundle.entities.fsm_types, + quantitySpecs: baselineBundle.quantity_specs ?? comparisonBundle.quantity_specs, + fsmTypes: baselineBundle.entities.fsm_types ?? comparisonBundle.entities.fsm_types, queryColors, }); }, [ baselineBundle.entities.fsm_types, baselineBundle.entities.resource_types, baselineBundle.quantity_specs, - competitorBundle.entities.fsm_types, - competitorBundle.entities.resource_types, - competitorBundle.quantity_specs, + comparisonBundle.entities.fsm_types, + comparisonBundle.entities.resource_types, + comparisonBundle.quantity_specs, durationSeconds, paletteTheme, queryColors, @@ -783,7 +783,7 @@ export function QueryDiffTimeline({ aria-label="delta" role="img" /> - {competitorName} + {comparisonName} {durationSeconds > 0 && {formatDuration(durationSeconds * 1_000)}}
@@ -796,14 +796,14 @@ export function QueryDiffTimeline({ className="h-2 w-2 rounded-full" style={{ backgroundColor: diffNegativeColor }} /> - Competitor lower + Comparison lower - Competitor higher + Comparison higher
)} @@ -844,7 +844,7 @@ export function QueryDiffTimeline({
Failed to load timeline delta
- ) : !resourceType || !baselineTarget || !competitorTarget ? ( + ) : !resourceType || !baselineTarget || !comparisonTarget ? (
No shared resource type available for timeline delta.
@@ -881,20 +881,20 @@ export function QueryDiffTimeline({ /> {competitorName}} + label="Comparison" + color={queryColors.comparison} + detail={{comparisonName}} > - + { const data = buildDiffTimelineData({ timelineDiff: response, theme: 'light', - queryColors: { baseline: '#0072B2', competitor: '#E69F00' }, + queryColors: { baseline: '#0072B2', comparison: '#E69F00' }, }); expect(data.baseline.series.slots?.values).toEqual([100, 100]); - expect(data.competitor.series.slots?.values).toEqual([0, 0]); + expect(data.comparison.series.slots?.values).toEqual([0, 0]); expect(data.delta.series['Baseline higher']?.values).toEqual([2, 0]); - expect(data.delta.series['Competitor higher']?.values).toEqual([0, 3]); + expect(data.delta.series['Comparison higher']?.values).toEqual([0, 3]); expect(data.baseline.series.slots?.color).toBe('#0072B2'); - expect(data.competitor.series.slots?.color).toBe('#E69F00'); + expect(data.comparison.series.slots?.color).toBe('#E69F00'); expect(data.delta.series['Baseline higher']?.color).toBe(DIFF_NEGATIVE_COLOR); - expect(data.delta.series['Competitor higher']?.color).toBe(DIFF_POSITIVE_COLOR); + expect(data.delta.series['Comparison higher']?.color).toBe(DIFF_POSITIVE_COLOR); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index 3b9a93b9..df0ebdd0 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -13,7 +13,7 @@ import { const QUERY_A_HIGHER_LABEL = 'Query A higher'; const QUERY_B_HIGHER_LABEL = 'Query B higher'; const BASELINE_HIGHER_LABEL = 'Baseline higher'; -const COMPETITOR_HIGHER_LABEL = 'Competitor higher'; +const COMPARISON_HIGHER_LABEL = 'Comparison higher'; interface TimelineRowData { timestamps: number[]; @@ -22,7 +22,7 @@ interface TimelineRowData { export interface DiffTimelineData { baseline: TimelineRowData; - competitor: TimelineRowData; + comparison: TimelineRowData; delta: TimelineRowData; } @@ -46,15 +46,15 @@ function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { function formatDeltaSeries({ delta, baseline, - competitor, + comparison, theme, }: { delta: TimelineRowData; baseline: TimelineRowData; - competitor: TimelineRowData; + comparison: TimelineRowData; theme: PaletteTheme; }): TimelineSeries { - const formatter = getFirstFormatter(baseline.series, competitor.series); + const formatter = getFirstFormatter(baseline.series, comparison.series); const positiveColor = getDiffPositiveColor(theme); const negativeColor = getDiffNegativeColor(theme); return Object.fromEntries( @@ -63,7 +63,7 @@ function formatDeltaSeries({ name === QUERY_A_HIGHER_LABEL ? BASELINE_HIGHER_LABEL : name === QUERY_B_HIGHER_LABEL - ? COMPETITOR_HIGHER_LABEL + ? COMPARISON_HIGHER_LABEL : name; return [ displayName, @@ -102,7 +102,7 @@ export function buildDiffTimelineData({ fsmTypes, queryColors, }: BuildDiffTimelineDataParams): DiffTimelineData { - const [baselineTimeline, competitorTimeline] = timelineDiff.timelines; + const [baselineTimeline, comparisonTimeline] = timelineDiff.timelines; const baseline = buildBinnedTimelineSeries( baselineTimeline.data, baselineTimeline.config, @@ -112,9 +112,9 @@ export function buildDiffTimelineData({ quantitySpecs, fsmTypes ); - const competitor = buildBinnedTimelineSeries( - competitorTimeline.data, - competitorTimeline.config, + const comparison = buildBinnedTimelineSeries( + comparisonTimeline.data, + comparisonTimeline.config, 0n, theme, capacities, @@ -133,13 +133,13 @@ export function buildDiffTimelineData({ ...baseline, series: recolorTimelineSeries(baseline.series, queryColors.baseline), }, - competitor: { - ...competitor, - series: recolorTimelineSeries(competitor.series, queryColors.competitor), + comparison: { + ...comparison, + series: recolorTimelineSeries(comparison.series, queryColors.comparison), }, delta: { timestamps: delta.timestamps, - series: formatDeltaSeries({ delta, baseline, competitor, theme }), + series: formatDeltaSeries({ delta, baseline, comparison, theme }), }, }; } diff --git a/ui/src/components/query-diff/queryProfileDiffFromBundles.ts b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts index 9a64e41c..558d0f17 100644 --- a/ui/src/components/query-diff/queryProfileDiffFromBundles.ts +++ b/ui/src/components/query-diff/queryProfileDiffFromBundles.ts @@ -1,164 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { parseCustomStatistics } from '@quent/components'; -import type { - DiffDelta, - DiffOperatorDelta, - DiffOperatorRef, - DiffResponse, - DiffQuerySummary, - QueryDiff, +export { + buildQueryProfileDiffFromBundles, + buildQueryProfileDiffResponseFromBundles, } from '@quent/client'; -import type { EntityRef, Operator, PlanTree, QueryBundle, StatValue } from '@quent/utils'; - -interface PlanSignature { - operators: string[]; - children: PlanSignature[]; -} - -function getOperatorsForPlan(bundle: QueryBundle, planId: string): Operator[] { - return Object.values(bundle.entities.operators) - .filter((operator): operator is Operator => operator != null && operator.plan_id === planId) - .sort((a, b) => { - const typeCompare = (a.operator_type_name ?? '').localeCompare(b.operator_type_name ?? ''); - if (typeCompare !== 0) return typeCompare; - const nameCompare = (a.instance_name ?? '').localeCompare(b.instance_name ?? ''); - if (nameCompare !== 0) return nameCompare; - return a.id.localeCompare(b.id); - }); -} - -function getOperatorSignature(operator: Operator): string { - return `${operator.operator_type_name ?? ''}:${operator.instance_name ?? ''}`; -} - -function getPlanSignature(bundle: QueryBundle, node: PlanTree): PlanSignature { - return { - operators: getOperatorsForPlan(bundle, node.id).map(getOperatorSignature), - children: node.children.map(child => getPlanSignature(bundle, child)), - }; -} - -function signaturesEqual(a: PlanSignature, b: PlanSignature): boolean { - if (a.operators.length !== b.operators.length || a.children.length !== b.children.length) { - return false; - } - if (a.operators.some((signature, index) => signature !== b.operators[index])) { - return false; - } - return a.children.every((child, index) => signaturesEqual(child, b.children[index]!)); -} - -function flattenOperatorsByPlanTree(bundle: QueryBundle, node: PlanTree): Operator[] { - return [ - ...getOperatorsForPlan(bundle, node.id), - ...node.children.flatMap(child => flattenOperatorsByPlanTree(bundle, child)), - ]; -} - -function getQuerySummary(bundle: QueryBundle): DiffQuerySummary { - return { - id: bundle.entities.query.id, - engine_id: bundle.entities.engine.id, - engine_name: bundle.entities.engine.instance_name ?? null, - instance_name: bundle.entities.query.instance_name ?? null, - query_group_id: bundle.entities.query_group.id, - query_group_name: bundle.entities.query_group.instance_name ?? null, - }; -} - -function getOperatorRef(operator: Operator): DiffOperatorRef { - return { - id: operator.id, - label: operator.instance_name ?? operator.id, - operator_type_name: operator.operator_type_name ?? null, - plan_id: operator.plan_id ?? null, - }; -} - -function getOperatorStats(operator: Operator): Record { - const stats: Record = { - duration_s: operator.active_span - ? Number((operator.active_span.end - operator.active_span.start).toFixed(6)) - : null, - }; - - for (const stat of parseCustomStatistics(operator)) { - stats[stat.key] = stat.value; - } - - return stats; -} - -function buildStatDelta(a: StatValue, b: StatValue): DiffDelta { - const delta = typeof a === 'number' && typeof b === 'number' ? a - b : null; - return { - stats: [a, b], - delta, - percent_delta: delta != null && typeof b === 'number' && b !== 0 ? delta / b : null, - }; -} - -function buildOperatorDelta(operatorA: Operator, operatorB: Operator): DiffOperatorDelta { - const statsA = getOperatorStats(operatorA); - const statsB = getOperatorStats(operatorB); - const statNames = [...new Set([...Object.keys(statsA), ...Object.keys(statsB)])].sort(); - - return { - operators: [getOperatorRef(operatorA), getOperatorRef(operatorB)], - stats: Object.fromEntries( - statNames.map(statName => [ - statName, - buildStatDelta(statsA[statName] ?? null, statsB[statName] ?? null), - ]) - ), - }; -} - -export function buildQueryProfileDiffFromBundles( - baselineQuery: QueryBundle, - comparisonQuery: QueryBundle -): QueryDiff { - const query = getQuerySummary(comparisonQuery); - const stat_diffs = { - duration: buildStatDelta(baselineQuery.duration_s, comparisonQuery.duration_s), - }; - - const signatureA = getPlanSignature(baselineQuery, baselineQuery.plan_tree); - const signatureB = getPlanSignature(comparisonQuery, comparisonQuery.plan_tree); - - if (!signaturesEqual(signatureA, signatureB)) { - return { - compatibility: 'incompatible', - query, - stat_diffs, - operator_diffs: [], - warnings: ['Plans are structurally different; operator-to-operator diff is unavailable.'], - }; - } - - const operatorsA = flattenOperatorsByPlanTree(baselineQuery, baselineQuery.plan_tree); - const operatorsB = flattenOperatorsByPlanTree(comparisonQuery, comparisonQuery.plan_tree); - const matchedCount = Math.min(operatorsA.length, operatorsB.length); - - return { - compatibility: 'compatible', - query, - stat_diffs, - operator_diffs: operatorsA - .slice(0, matchedCount) - .map((operatorA, index) => buildOperatorDelta(operatorA, operatorsB[index]!)), - }; -} - -export function buildQueryProfileDiffResponseFromBundles( - baselineQuery: QueryBundle, - comparisonQueries: QueryBundle[] -): DiffResponse { - return { - comparisonQueries: comparisonQueries.map(comparisonQuery => - buildQueryProfileDiffFromBundles(baselineQuery, comparisonQuery) - ), - }; -} diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 797b9029..07f9fd9c 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -37,7 +37,7 @@ import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; interface DiffSelectionPageProps { initialBaselineQueryId?: string; - initialCompetitorQueryIds?: readonly string[]; + initialComparisonQueryIds?: readonly string[]; } interface QuerySideState { @@ -46,7 +46,7 @@ interface QuerySideState { queryId: string; } -interface CompetitorQueryState extends QuerySideState { +interface ComparisonQueryState extends QuerySideState { id: string; } @@ -76,22 +76,22 @@ const EMPTY_QUERY_GROUPS: QueryGroup[] = []; const EMPTY_QUERIES_BY_GROUP: Record = {}; let pendingSelectionOpenAfterNavigation: boolean | null = null; -let nextCompetitorId = 1; +let nextComparisonId = 1; function makeQuerySide(queryId = ''): QuerySideState { return { engineId: '', groupId: '', queryId }; } -function makeCompetitorQuery(queryId = ''): CompetitorQueryState { +function makeComparisonQuery(queryId = ''): ComparisonQueryState { return { - id: `competitor-${nextCompetitorId++}`, + id: `comparison-${nextComparisonId++}`, ...makeQuerySide(queryId), }; } -function makeCompetitorQueries(queryIds: readonly string[] = []): CompetitorQueryState[] { +function makeComparisonQueries(queryIds: readonly string[] = []): ComparisonQueryState[] { const initialQueryIds = queryIds.length > 0 ? queryIds : ['']; - return initialQueryIds.map(queryId => makeCompetitorQuery(queryId)); + return initialQueryIds.map(queryId => makeComparisonQuery(queryId)); } function toQuerySide(side: QuerySideState): QuerySideState { @@ -110,27 +110,27 @@ function queryIdsEqual(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } -export function parseCompetitorQueryIds(competitorQueryIds: string): string[] { - return competitorQueryIds +export function parseComparisonQueryIds(comparisonQueryIds: string): string[] { + return comparisonQueryIds .split(',') .map(queryId => queryId.trim()) .filter(Boolean); } -function formatCompetitorQueryIds(competitors: readonly QuerySideState[]): string { - return competitors - .map(competitor => competitor.queryId) +function formatComparisonQueryIds(comparisons: readonly QuerySideState[]): string { + return comparisons + .map(comparison => comparison.queryId) .filter(Boolean) .join(','); } -function getInitialSelectionOpen(baselineQueryId: string, competitorQueryIds: readonly string[]) { +function getInitialSelectionOpen(baselineQueryId: string, comparisonQueryIds: readonly string[]) { if (pendingSelectionOpenAfterNavigation !== null) { const selectionOpen = pendingSelectionOpenAfterNavigation; pendingSelectionOpenAfterNavigation = null; return selectionOpen; } - return !(baselineQueryId && competitorQueryIds.length > 0); + return !(baselineQueryId && comparisonQueryIds.length > 0); } function findGroupForQuery( @@ -386,10 +386,10 @@ function QuerySelectorColumn({ ); } -interface CompetitorQuerySelectorColumnProps { +interface ComparisonQuerySelectorColumnProps { label: string; idPrefix: string; - side: CompetitorQueryState; + side: ComparisonQueryState; engines: Engine[]; action?: React.ReactNode; onEngineChange: (engineId: string) => void; @@ -397,7 +397,7 @@ interface CompetitorQuerySelectorColumnProps { onQueryChange: (queryId: string) => void; } -function CompetitorQuerySelectorColumn({ +function ComparisonQuerySelectorColumn({ label, idPrefix, side, @@ -406,7 +406,7 @@ function CompetitorQuerySelectorColumn({ onEngineChange, onGroupChange, onQueryChange, -}: CompetitorQuerySelectorColumnProps) { +}: ComparisonQuerySelectorColumnProps) { const catalog = useQueryCatalog(side.engineId); return ( @@ -428,7 +428,7 @@ function CompetitorQuerySelectorColumn({ interface DiffDashboardProps { baselineQuery: QuerySideState; - competitorQueries: CompetitorQueryState[]; + comparisonQueries: ComparisonQueryState[]; } type DiffDashboardTab = 'overview' | 'operator' | 'timelines'; @@ -439,7 +439,7 @@ const DIFF_DASHBOARD_TABS: Array<{ id: DiffDashboardTab; label: string }> = [ { id: 'timelines', label: 'Timelines' }, ]; -function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) { +function DiffDashboard({ baselineQuery, comparisonQueries }: DiffDashboardProps) { const [activeTab, setActiveTab] = useState('overview'); const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; @@ -450,13 +450,13 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) }), enabled: isQuerySideComplete(baselineQuery), }); - const competitorBundles = useQueries({ - queries: competitorQueries.map(competitorQuery => ({ + const comparisonBundles = useQueries({ + queries: comparisonQueries.map(comparisonQuery => ({ ...queryBundleQueryOptions({ - engineId: competitorQuery.engineId, - queryId: competitorQuery.queryId, + engineId: comparisonQuery.engineId, + queryId: comparisonQuery.queryId, }), - enabled: isQuerySideComplete(baselineQuery) && isQuerySideComplete(competitorQuery), + enabled: isQuerySideComplete(baselineQuery) && isQuerySideComplete(comparisonQuery), })), }); const diffRequest = useMemo( @@ -465,39 +465,39 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) engine_id: baselineQuery.engineId, query_id: baselineQuery.queryId, }, - comparisonQueries: competitorQueries.map(competitorQuery => ({ - engine_id: competitorQuery.engineId, - query_id: competitorQuery.queryId, + comparisonQueries: comparisonQueries.map(comparisonQuery => ({ + engine_id: comparisonQuery.engineId, + query_id: comparisonQuery.queryId, })), }), - [baselineQuery.engineId, baselineQuery.queryId, competitorQueries] + [baselineQuery.engineId, baselineQuery.queryId, comparisonQueries] ); const diffResponse = useQuery(queryProfileDiffQueryOptions({ request: diffRequest })); const comparisons = useMemo( () => baselineBundle.data && diffResponse.data - ? competitorQueries.flatMap((competitorQuery, index) => { - const competitorBundle = competitorBundles[index]?.data; + ? comparisonQueries.flatMap((comparisonSelection, index) => { + const comparisonBundle = comparisonBundles[index]?.data; const diff = diffResponse.data.comparisonQueries[index]; - if (!competitorBundle || !diff) return []; + if (!comparisonBundle || !diff) return []; const baselineQuerySummary = querySummaryFromBundle(baselineBundle.data); - const comparisonQuerySummary = diff.query ?? querySummaryFromBundle(competitorBundle); + const comparisonQuerySummary = diff.query ?? querySummaryFromBundle(comparisonBundle); return [ { - id: competitorQuery.id, - competitorIndex: index, - competitorQuery, + id: comparisonSelection.id, + comparisonIndex: index, + comparisonSelection, baselineQuery: baselineQuerySummary, comparisonQuery: comparisonQuerySummary, diff, baselineBundle: baselineBundle.data, - competitorBundle, + comparisonBundle, }, ]; }) : [], - [baselineBundle.data, competitorBundles, competitorQueries, diffResponse.data] + [baselineBundle.data, comparisonBundles, comparisonQueries, diffResponse.data] ); const legendItems = useMemo(() => { if (!baselineBundle.data || comparisons.length === 0) return []; @@ -505,7 +505,7 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) const baselineQueryEntity = baselineBundle.data.entities.query; const baselineColor = getQueryDiffQueryColors({ baselineQueryId: baselineQueryEntity.id, - competitorQueryId: comparisons[0]?.comparisonQuery.id ?? '', + comparisonQueryId: comparisons[0]?.comparisonQuery.id ?? '', theme: paletteTheme, }).baseline; @@ -519,15 +519,15 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) ...comparisons.map((comparison, index) => { const queryColors = getQueryDiffQueryColors({ baselineQueryId: baselineQueryEntity.id, - competitorQueryId: comparison.comparisonQuery.id, - competitorIndex: comparison.competitorIndex, + comparisonQueryId: comparison.comparisonQuery.id, + comparisonIndex: comparison.comparisonIndex, theme: paletteTheme, }); return { id: `comparison-${comparison.id}`, label: querySummaryLabel(comparison.comparisonQuery), - color: queryColors.competitor, + color: queryColors.comparison, roleLabel: `Comparison ${index + 1}`, }; }), @@ -535,15 +535,15 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) }, [baselineBundle.data, comparisons, paletteTheme]); const diffLoading = baselineBundle.isLoading || - competitorBundles.some(query => query.isLoading) || + comparisonBundles.some(query => query.isLoading) || diffResponse.isLoading; const diffError = baselineBundle.error ?? - competitorBundles.find(query => query.error)?.error ?? + comparisonBundles.find(query => query.error)?.error ?? diffResponse.error; const baselineLabel = baselineBundle.data?.entities.query.instance_name ?? baselineQuery.queryId; - const competitorCountLabel = - comparisons.length === 1 ? '1 competitor query' : `${comparisons.length} competitor queries`; + const comparisonCountLabel = + comparisons.length === 1 ? '1 comparison query' : `${comparisons.length} comparison queries`; return (
Dashboard {baselineLabel} vs - {competitorCountLabel} + {comparisonCountLabel}
@@ -615,8 +615,8 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) comparisonQuery: comparison.comparisonQuery, diff: comparison.diff, baselineBundle: comparison.baselineBundle, - competitorBundle: comparison.competitorBundle, - competitorIndex: comparison.competitorIndex, + comparisonBundle: comparison.comparisonBundle, + comparisonIndex: comparison.comparisonIndex, }))} /> ({ id: comparison.id, - competitorIndex: comparison.competitorIndex, - competitorEngineId: comparison.competitorQuery.engineId, + comparisonIndex: comparison.comparisonIndex, + comparisonEngineId: comparison.comparisonSelection.engineId, comparisonQuery: comparison.comparisonQuery, diff: comparison.diff, - competitorBundle: comparison.competitorBundle, + comparisonBundle: comparison.comparisonBundle, }))} />
@@ -642,7 +642,7 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) >
- Competitor Query {index + 1} + Comparison Query {index + 1} {querySummaryLabel(comparison.comparisonQuery)} @@ -666,11 +666,11 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) baselineBundle={baselineBundle.data} comparisons={comparisons.map(comparison => ({ id: comparison.id, - competitorIndex: comparison.competitorIndex, - competitorEngineId: comparison.competitorQuery.engineId, + comparisonIndex: comparison.comparisonIndex, + comparisonEngineId: comparison.comparisonSelection.engineId, comparisonQuery: comparison.comparisonQuery, diff: comparison.diff, - competitorBundle: comparison.competitorBundle, + comparisonBundle: comparison.comparisonBundle, }))} />
@@ -684,63 +684,63 @@ function DiffDashboard({ baselineQuery, competitorQueries }: DiffDashboardProps) export function DiffSelectionPage({ initialBaselineQueryId = '', - initialCompetitorQueryIds = [], + initialComparisonQueryIds = [], }: DiffSelectionPageProps) { const navigate = useNavigate(); const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; - const initialCompetitorQueryIdsKey = initialCompetitorQueryIds.join('\0'); - const resolvedInitialCompetitorQueryIds = useMemo( - () => (initialCompetitorQueryIdsKey ? initialCompetitorQueryIdsKey.split('\0') : []), - [initialCompetitorQueryIdsKey] + const initialComparisonQueryIdsKey = initialComparisonQueryIds.join('\0'); + const resolvedInitialComparisonQueryIds = useMemo( + () => (initialComparisonQueryIdsKey ? initialComparisonQueryIdsKey.split('\0') : []), + [initialComparisonQueryIdsKey] ); const [baselineQuery, setBaselineQuery] = useState(() => makeQuerySide(initialBaselineQueryId) ); - const [competitorQueries, setCompetitorQueries] = useState(() => - makeCompetitorQueries(initialCompetitorQueryIds) + const [comparisonQueries, setComparisonQueries] = useState(() => + makeComparisonQueries(initialComparisonQueryIds) ); const [selectionOpen, setSelectionOpen] = useState(() => - getInitialSelectionOpen(initialBaselineQueryId, initialCompetitorQueryIds) + getInitialSelectionOpen(initialBaselineQueryId, initialComparisonQueryIds) ); - const primaryCompetitorQuery = competitorQueries.find(query => query.queryId) ?? - competitorQueries[0] ?? { - id: 'primary-competitor-query', + const primaryComparisonQuery = comparisonQueries.find(query => query.queryId) ?? + comparisonQueries[0] ?? { + id: 'primary-comparison-query', ...makeQuerySide(), }; - const completeCompetitorQueries = useMemo( - () => competitorQueries.filter(isQuerySideComplete), - [competitorQueries] + const completeComparisonQueries = useMemo( + () => comparisonQueries.filter(isQuerySideComplete), + [comparisonQueries] ); - const diffableCompetitorQueries = useMemo( - () => completeCompetitorQueries.filter(query => query.queryId !== baselineQuery.queryId), - [baselineQuery.queryId, completeCompetitorQueries] + const diffableComparisonQueries = useMemo( + () => completeComparisonQueries.filter(query => query.queryId !== baselineQuery.queryId), + [baselineQuery.queryId, completeComparisonQueries] ); - const sameAsBaselineCompetitorQueries = useMemo( + const sameAsBaselineComparisonQueries = useMemo( () => - completeCompetitorQueries.filter( + completeComparisonQueries.filter( query => Boolean(baselineQuery.queryId) && query.queryId === baselineQuery.queryId ), - [baselineQuery.queryId, completeCompetitorQueries] + [baselineQuery.queryId, completeComparisonQueries] ); useEffect(() => { setBaselineQuery(prev => prev.queryId === initialBaselineQueryId ? prev : makeQuerySide(initialBaselineQueryId) ); - setCompetitorQueries(prev => { - const nextQueryIds = resolvedInitialCompetitorQueryIds; + setComparisonQueries(prev => { + const nextQueryIds = resolvedInitialComparisonQueryIds; const currentQueryIds = prev.map(query => query.queryId); if (queryIdsEqual(currentQueryIds, nextQueryIds)) { - return prev.length > 0 ? prev : makeCompetitorQueries(nextQueryIds); + return prev.length > 0 ? prev : makeComparisonQueries(nextQueryIds); } - return makeCompetitorQueries(nextQueryIds).map((query, index) => + return makeComparisonQueries(nextQueryIds).map((query, index) => prev[index] ? { ...query, id: prev[index].id } : query ); }); - }, [initialBaselineQueryId, resolvedInitialCompetitorQueryIds]); + }, [initialBaselineQueryId, resolvedInitialComparisonQueryIds]); const { data: engines = [] } = useQuery({ queryKey: ['list_engines'], @@ -750,16 +750,16 @@ export function DiffSelectionPage({ const unresolvedQueryIds = useMemo( () => [ ...(baselineQuery.queryId && !baselineQuery.engineId ? [baselineQuery.queryId] : []), - ...competitorQueries.flatMap(query => + ...comparisonQueries.flatMap(query => query.queryId && !query.engineId ? [query.queryId] : [] ), ], - [baselineQuery.engineId, baselineQuery.queryId, competitorQueries] + [baselineQuery.engineId, baselineQuery.queryId, comparisonQueries] ); const queryLocationResolution = useQueryLocations(unresolvedQueryIds, engines); const baselineCatalog = useQueryCatalog(baselineQuery.engineId); - const primaryCompetitorCatalog = useQueryCatalog(primaryCompetitorQuery.engineId); + const primaryComparisonCatalog = useQueryCatalog(primaryComparisonQuery.engineId); useEffect(() => { const locations = queryLocationResolution.data; @@ -770,7 +770,7 @@ export function DiffSelectionPage({ const location = locations[prev.queryId]; return location ? { ...prev, engineId: location.engineId, groupId: location.groupId } : prev; }); - setCompetitorQueries(prev => + setComparisonQueries(prev => prev.map(query => { if (!query.queryId || query.engineId) return query; const location = locations[query.queryId]; @@ -790,26 +790,26 @@ export function DiffSelectionPage({ }, [baselineCatalog.queriesByGroup, baselineQuery.groupId, baselineQuery.queryId]); useEffect(() => { - setCompetitorQueries(prev => + setComparisonQueries(prev => prev.map((query, index) => { if (index !== 0 || query.groupId || !query.queryId) return query; - const groupId = findGroupForQuery(query.queryId, primaryCompetitorCatalog.queriesByGroup); + const groupId = findGroupForQuery(query.queryId, primaryComparisonCatalog.queriesByGroup); return groupId && groupId !== query.groupId ? { ...query, groupId } : query; }) ); }, [ - primaryCompetitorCatalog.queriesByGroup, - primaryCompetitorQuery.groupId, - primaryCompetitorQuery.queryId, + primaryComparisonCatalog.queriesByGroup, + primaryComparisonQuery.groupId, + primaryComparisonQuery.queryId, ]); const baselineEngineSummary = useMemo( () => engineDisplayLabel(baselineQuery.engineId, engines, 'Select Engine'), [engines, baselineQuery.engineId] ); - const primaryCompetitorEngineSummary = useMemo( - () => engineDisplayLabel(primaryCompetitorQuery.engineId, engines, 'Select Engine'), - [engines, primaryCompetitorQuery.engineId] + const primaryComparisonEngineSummary = useMemo( + () => engineDisplayLabel(primaryComparisonQuery.engineId, engines, 'Select Engine'), + [engines, primaryComparisonQuery.engineId] ); const baselineSummary = useMemo( () => @@ -820,49 +820,49 @@ export function DiffSelectionPage({ ), [baselineCatalog.queriesByGroup, baselineQuery.queryId] ); - const primaryCompetitorSummary = useMemo( + const primaryComparisonSummary = useMemo( () => queryDisplayLabel( - primaryCompetitorQuery.queryId, - primaryCompetitorCatalog.queriesByGroup, - 'Select Competitor Query' + primaryComparisonQuery.queryId, + primaryComparisonCatalog.queriesByGroup, + 'Select Comparison Query' ), - [primaryCompetitorCatalog.queriesByGroup, primaryCompetitorQuery.queryId] + [primaryComparisonCatalog.queriesByGroup, primaryComparisonQuery.queryId] ); - const competitorSummary = useMemo( + const comparisonSummary = useMemo( () => - completeCompetitorQueries.length > 1 - ? `${completeCompetitorQueries.length} competitor queries` - : primaryCompetitorSummary, - [completeCompetitorQueries.length, primaryCompetitorSummary] + completeComparisonQueries.length > 1 + ? `${completeComparisonQueries.length} comparison queries` + : primaryComparisonSummary, + [completeComparisonQueries.length, primaryComparisonSummary] ); const queryColors = useMemo( () => getQueryDiffQueryColors({ baselineQueryId: baselineQuery.queryId, - competitorQueryId: primaryCompetitorQuery.queryId, + comparisonQueryId: primaryComparisonQuery.queryId, theme: paletteTheme, }), - [baselineQuery.queryId, paletteTheme, primaryCompetitorQuery.queryId] + [baselineQuery.queryId, paletteTheme, primaryComparisonQuery.queryId] ); const baselineComplete = isQuerySideComplete(baselineQuery); - const hasDiffableCompetitors = diffableCompetitorQueries.length > 0; - const hasSameAsBaselineCompetitors = sameAsBaselineCompetitorQueries.length > 0; + const hasDiffableComparisons = diffableComparisonQueries.length > 0; + const hasSameAsBaselineComparisons = sameAsBaselineComparisonQueries.length > 0; const maybeNavigateToDiff = ( nextBaseline: QuerySideState, - nextCompetitors: readonly QuerySideState[] + nextComparisons: readonly QuerySideState[] ) => { - const competitorQueryIds = formatCompetitorQueryIds(nextCompetitors); - if (!nextBaseline.queryId || !competitorQueryIds) { + const comparisonQueryIds = formatComparisonQueryIds(nextComparisons); + if (!nextBaseline.queryId || !comparisonQueryIds) { return; } pendingSelectionOpenAfterNavigation = selectionOpen; navigate({ - to: '/diff/query/$baselineQueryId/compare/$competitorQueryIds', + to: '/diff/query/$baselineQueryId/compare/$comparisonQueryIds', params: { baselineQueryId: nextBaseline.queryId, - competitorQueryIds: competitorQueryIds, + comparisonQueryIds: comparisonQueryIds, }, }); }; @@ -880,55 +880,55 @@ export function DiffSelectionPage({ const handleBaselineQueryChange = (queryId: string) => { const nextBaseline = { ...baselineQuery, queryId }; setBaselineQuery(nextBaseline); - maybeNavigateToDiff(nextBaseline, competitorQueries); + maybeNavigateToDiff(nextBaseline, comparisonQueries); }; - const handleCompetitorEngineChange = (competitorId: string, engineId: string) => { + const handleComparisonEngineChange = (comparisonId: string, engineId: string) => { setSelectionOpen(true); - setCompetitorQueries(prev => + setComparisonQueries(prev => prev.map(query => - query.id === competitorId ? { ...query, engineId, groupId: '', queryId: '' } : query + query.id === comparisonId ? { ...query, engineId, groupId: '', queryId: '' } : query ) ); }; - const handleCompetitorGroupChange = (competitorId: string, groupId: string) => { + const handleComparisonGroupChange = (comparisonId: string, groupId: string) => { setSelectionOpen(true); - setCompetitorQueries(prev => - prev.map(query => (query.id === competitorId ? { ...query, groupId, queryId: '' } : query)) + setComparisonQueries(prev => + prev.map(query => (query.id === comparisonId ? { ...query, groupId, queryId: '' } : query)) ); }; - const handleCompetitorQueryChange = (competitorId: string, queryId: string) => { - const currentCompetitor = competitorQueries.find(query => query.id === competitorId); - if (!currentCompetitor) return; + const handleComparisonQueryChange = (comparisonId: string, queryId: string) => { + const currentComparison = comparisonQueries.find(query => query.id === comparisonId); + if (!currentComparison) return; - const nextCompetitor = { ...currentCompetitor, queryId }; - const nextCompetitors = competitorQueries.map(query => - query.id === competitorId ? nextCompetitor : query + const nextComparison = { ...currentComparison, queryId }; + const nextComparisons = comparisonQueries.map(query => + query.id === comparisonId ? nextComparison : query ); - setCompetitorQueries(nextCompetitors); - maybeNavigateToDiff(baselineQuery, nextCompetitors); + setComparisonQueries(nextComparisons); + maybeNavigateToDiff(baselineQuery, nextComparisons); }; - const handleAddCompetitorQuery = () => { + const handleAddComparisonQuery = () => { setSelectionOpen(true); - setCompetitorQueries(prev => [...prev, makeCompetitorQuery()]); + setComparisonQueries(prev => [...prev, makeComparisonQuery()]); }; - const handleMakeBaseline = (competitorId: string) => { - const selectedCompetitor = competitorQueries.find(query => query.id === competitorId); - if (!selectedCompetitor || !isQuerySideComplete(selectedCompetitor)) return; + const handleMakeBaseline = (comparisonId: string) => { + const selectedComparison = comparisonQueries.find(query => query.id === comparisonId); + if (!selectedComparison || !isQuerySideComplete(selectedComparison)) return; - const nextBaseline = toQuerySide(selectedCompetitor); - const nextCompetitor = { ...selectedCompetitor, ...baselineQuery }; - const nextCompetitors = [ - nextCompetitor, - ...competitorQueries.filter(query => query.id !== competitorId), + const nextBaseline = toQuerySide(selectedComparison); + const nextComparison = { ...selectedComparison, ...baselineQuery }; + const nextComparisons = [ + nextComparison, + ...comparisonQueries.filter(query => query.id !== comparisonId), ]; setBaselineQuery(nextBaseline); - setCompetitorQueries(nextCompetitors); - maybeNavigateToDiff(nextBaseline, nextCompetitors); + setComparisonQueries(nextComparisons); + maybeNavigateToDiff(nextBaseline, nextComparisons); }; return ( @@ -955,14 +955,14 @@ export function DiffSelectionPage({ className="inline-block max-w-[18rem] truncate align-bottom" style={{ color: - primaryCompetitorQuery.queryId && completeCompetitorQueries.length <= 1 - ? queryColors.competitor + primaryComparisonQuery.queryId && completeComparisonQueries.length <= 1 + ? queryColors.comparison : undefined, }} > - {primaryCompetitorQuery.engineId && completeCompetitorQueries.length <= 1 - ? `${primaryCompetitorEngineSummary} / ${competitorSummary}` - : competitorSummary} + {primaryComparisonQuery.engineId && completeComparisonQueries.length <= 1 + ? `${primaryComparisonEngineSummary} / ${comparisonSummary}` + : comparisonSummary} @@ -985,35 +985,35 @@ export function DiffSelectionPage({ onQueryChange={handleBaselineQueryChange} />
- {competitorQueries.map((competitorQuery, index) => { + {comparisonQueries.map((comparisonQuery, index) => { return ( - handleMakeBaseline(competitorQuery.id)} + onClick={() => handleMakeBaseline(comparisonQuery.id)} > Make Baseline ) : null } onEngineChange={engineId => - handleCompetitorEngineChange(competitorQuery.id, engineId) + handleComparisonEngineChange(comparisonQuery.id, engineId) } onGroupChange={groupId => - handleCompetitorGroupChange(competitorQuery.id, groupId) + handleComparisonGroupChange(comparisonQuery.id, groupId) } onQueryChange={queryId => - handleCompetitorQueryChange(competitorQuery.id, queryId) + handleComparisonQueryChange(comparisonQuery.id, queryId) } /> ); @@ -1023,10 +1023,10 @@ export function DiffSelectionPage({ variant="outline" size="sm" className="h-8 w-full rounded-sm text-xs" - onClick={handleAddCompetitorQuery} + onClick={handleAddComparisonQuery} > - Add Competitor + Add Comparison
@@ -1038,29 +1038,29 @@ export function DiffSelectionPage({
{queryLocationResolution.isLoading ? (
Loading diff...
- ) : !baselineQuery.engineId || !competitorQueries.some(query => query.engineId) ? ( + ) : !baselineQuery.engineId || !comparisonQueries.some(query => query.engineId) ? (
- Select engines for Baseline Query and at least one competitor query. + Select engines for Baseline Query and at least one comparison query.
- ) : !baselineComplete || !hasDiffableCompetitors ? ( + ) : !baselineComplete || !hasDiffableComparisons ? (
- {hasSameAsBaselineCompetitors - ? 'Choose competitor queries different from the baseline.' - : 'Select Baseline Query and at least one competitor query.'} + {hasSameAsBaselineComparisons + ? 'Choose comparison queries different from the baseline.' + : 'Select Baseline Query and at least one comparison query.'}
) : (
diff --git a/ui/src/routes/diff.query.$baselineQueryId.compare.$competitorQueryIds.tsx b/ui/src/routes/diff.query.$baselineQueryId.compare.$comparisonQueryIds.tsx similarity index 65% rename from ui/src/routes/diff.query.$baselineQueryId.compare.$competitorQueryIds.tsx rename to ui/src/routes/diff.query.$baselineQueryId.compare.$comparisonQueryIds.tsx index 14c331d4..27bf3d80 100644 --- a/ui/src/routes/diff.query.$baselineQueryId.compare.$competitorQueryIds.tsx +++ b/ui/src/routes/diff.query.$baselineQueryId.compare.$comparisonQueryIds.tsx @@ -1,19 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { DiffSelectionPage, parseCompetitorQueryIds } from '@/pages/DiffSelectionPage'; +import { DiffSelectionPage, parseComparisonQueryIds } from '@/pages/DiffSelectionPage'; import { createFileRoute } from '@tanstack/react-router'; -export const Route = createFileRoute('/diff/query/$baselineQueryId/compare/$competitorQueryIds')({ +export const Route = createFileRoute('/diff/query/$baselineQueryId/compare/$comparisonQueryIds')({ component: DiffComparison, }); function DiffComparison() { - const { baselineQueryId, competitorQueryIds } = Route.useParams(); + const { baselineQueryId, comparisonQueryIds } = Route.useParams(); return ( ); } diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index 78871fda..2a836b2b 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -189,20 +189,20 @@ describe('Diff routes', () => { renderWithRouter({ initialPath: '/diff' }); expect(await screen.findByText('Baseline Query')).toBeInTheDocument(); - expect(screen.getByText('Competitor Query 1')).toBeInTheDocument(); + expect(screen.getByText('Comparison Query 1')).toBeInTheDocument(); expect( - screen.getByText('Select engines for Baseline Query and at least one competitor query.') + screen.getByText('Select engines for Baseline Query and at least one comparison query.') ).toBeInTheDocument(); }); - it('adds another competitor query selector', async () => { + it('adds another comparison query selector', async () => { const user = userEvent.setup(); renderWithRouter({ initialPath: '/diff' }); - await screen.findByText('Competitor Query 1'); - await user.click(screen.getByRole('button', { name: 'Add Competitor' })); + await screen.findByText('Comparison Query 1'); + await user.click(screen.getByRole('button', { name: 'Add Comparison' })); - expect(screen.getByText('Competitor Query 2')).toBeInTheDocument(); + expect(screen.getByText('Comparison Query 2')).toBeInTheDocument(); expect(screen.getAllByRole('combobox')).toHaveLength(9); }); @@ -251,14 +251,14 @@ describe('Diff routes', () => { expect(screen.queryByText('Total Run Time')).not.toBeInTheDocument(); }); - it('renders one diff panel for each selected competitor query', async () => { + it('renders one diff panel for each selected comparison query', async () => { renderWithRouter({ initialPath: '/diff/query/query-a/compare/query-b,query-c', }); const overviewTabs = await screen.findAllByRole('tab', { name: 'Overview' }); expect(overviewTabs).toHaveLength(1); - expect(screen.getAllByText('2 competitor queries').length).toBeGreaterThan(0); + expect(screen.getAllByText('2 comparison queries').length).toBeGreaterThan(0); expect(screen.getAllByText('Total Run Time')).toHaveLength(2); expect(screen.getByText('Operator Run Time')).toBeInTheDocument(); @@ -325,7 +325,7 @@ describe('Diff routes', () => { }); }); - it('makes a competitor query the baseline from the selector', async () => { + it('makes a comparison query the baseline from the selector', async () => { const user = userEvent.setup(); const { router } = renderWithRouter({ initialPath: '/diff/query/query-a/compare/query-b', @@ -351,7 +351,7 @@ describe('Diff routes', () => { }); expect( - await screen.findByText('Choose competitor queries different from the baseline.') + await screen.findByText('Choose comparison queries different from the baseline.') ).toBeInTheDocument(); }); }); diff --git a/ui/src/test/mocks/handlers.ts b/ui/src/test/mocks/handlers.ts index ee9fc93a..632da5f8 100644 --- a/ui/src/test/mocks/handlers.ts +++ b/ui/src/test/mocks/handlers.ts @@ -3,24 +3,21 @@ import { http, HttpResponse } from 'msw'; import { MAX_TIMELINE_BINS } from '@quent/utils'; +import { buildQueryProfileDiffResponseFromBundles } from '@quent/client'; import type { BinnedSpanSec, BulkTimelineRequest, BulkTimelinesResponse, + EntityRef, QueryFilter, + QueryBundle, SingleTimelineRequest, SingleTimelineResponse, TaskFilter, TimelineConfig, TimelineRequest, } from '@quent/utils'; -import type { - DiffRequest, - DiffResponse, - DiffTimelineRequest, - DiffTimelineResponse, - QueryDiff, -} from '@quent/client'; +import type { DiffRequest, DiffTimelineRequest, DiffTimelineResponse } from '@quent/client'; const QUERY_A_HIGHER_SERIES = 'Query A higher'; const QUERY_B_HIGHER_SERIES = 'Query B higher'; @@ -95,15 +92,11 @@ function sampleAggregateAt(response: SingleTimelineResponse, targetSeconds: numb return timelineValueArrays(response).reduce((sum, values) => sum + (values[index] ?? 0), 0); } -function makeMockTimelineDiffResponse(request: DiffTimelineRequest): DiffTimelineResponse { - const [queryARequest, queryBRequest, ...restRequests] = request.timelines; - const queryA = makeMockTimelineResponse(queryARequest.timeline); - const queryB = makeMockTimelineResponse(queryBRequest.timeline); - const timelines: DiffTimelineResponse['timelines'] = [ - queryA, - queryB, - ...restRequests.map(request => makeMockTimelineResponse(request.timeline)), - ]; +function makeTimelineDiffResponse( + request: DiffTimelineRequest, + timelines: DiffTimelineResponse['timelines'] +): DiffTimelineResponse { + const [queryA, queryB] = timelines; const config = toBinnedSpanSec(request.delta_config); const queryAHigher: number[] = []; const queryBHigher: number[] = []; @@ -134,168 +127,48 @@ function makeMockTimelineDiffResponse(request: DiffTimelineRequest): DiffTimelin }; } -function queryNameFromId(queryId: string): string { - const parts = queryId.split('-'); - const suffix = parts[parts.length - 1]; - return suffix ? `Query ${suffix.toUpperCase()}` : queryId; -} - -interface MockOperatorDiffSpec { - id: string; - label: string; - operatorType: string; - duration: [number, number]; - inputRows: [number, number]; - outputRows: [number, number]; +function apiUrlFromRequest(request: Request, pathname: string): string { + return new URL(pathname, request.url).toString(); } -const MOCK_OPERATOR_DIFF_SPECS: MockOperatorDiffSpec[] = [ - { - id: 'scan-orders', - label: 'Scan orders', - operatorType: 'Scan', - duration: [12, 10], - inputRows: [0, 0], - outputRows: [1_000_000, 1_200_000], - }, - { - id: 'filter-active', - label: 'Filter active rows', - operatorType: 'Filter', - duration: [4, 5], - inputRows: [1_000_000, 1_200_000], - outputRows: [750_000, 820_000], - }, - { - id: 'project-columns', - label: 'Project selected columns', - operatorType: 'Project', - duration: [2, 2.5], - inputRows: [750_000, 820_000], - outputRows: [750_000, 820_000], - }, - { - id: 'join-lineitem', - label: 'Join lineitem', - operatorType: 'Join', - duration: [24, 30], - inputRows: [750_000, 820_000], - outputRows: [400_000, 380_000], - }, - { - id: 'sort-revenue', - label: 'Sort by revenue', - operatorType: 'Sort', - duration: [6, 8], - inputRows: [400_000, 380_000], - outputRows: [400_000, 380_000], - }, - { - id: 'window-rank', - label: 'Window rank', - operatorType: 'Window', - duration: [8, 7], - inputRows: [400_000, 380_000], - outputRows: [400_000, 380_000], - }, - { - id: 'aggregate-status', - label: 'Aggregate by status', - operatorType: 'Aggregate', - duration: [4, 4], - inputRows: [400_000, 380_000], - outputRows: [20, 18], - }, -]; - -function buildMockDelta([baseline, comparison]: [number, number]) { - const delta = baseline - comparison; - return { - stats: [baseline, comparison] as [number, number], - delta, - percent_delta: comparison === 0 ? null : delta / comparison, - }; +async function fetchJsonForDiff( + request: Request, + pathname: string, + init?: RequestInit +): Promise { + const response = await fetch(apiUrlFromRequest(request, pathname), init); + if (!response.ok) { + throw new Error(`diff backend fetch failed: ${response.status} ${response.statusText}`); + } + return (await response.json()) as T; } -function adjustComparisonValue(value: number, competitorIndex: number, statIndex: number): number { - if (value === 0) return 0; - const direction = statIndex % 2 === 0 ? 1 : -1; - return Number((value + direction * competitorIndex * Math.max(1, value * 0.08)).toFixed(3)); +function queryBundlePath(engineId: string, queryId: string): string { + return `/api/engines/${encodeURIComponent(engineId)}/query/${encodeURIComponent(queryId)}`; } -function buildMockOperatorDiffs( - baselineQueryId: string, - comparisonQueryId: string, - competitorIndex: number -): QueryDiff['operator_diffs'] { - return MOCK_OPERATOR_DIFF_SPECS.map((spec, statIndex) => { - const duration = [ - spec.duration[0], - adjustComparisonValue(spec.duration[1], competitorIndex, statIndex), - ] as [number, number]; - const inputRows = [ - spec.inputRows[0], - adjustComparisonValue(spec.inputRows[1], competitorIndex, statIndex), - ] as [number, number]; - const outputRows = [ - spec.outputRows[0], - adjustComparisonValue(spec.outputRows[1], competitorIndex, statIndex), - ] as [number, number]; - - return { - operators: [ - { - id: `${spec.id}-${baselineQueryId}`, - label: spec.label, - operator_type_name: spec.operatorType, - plan_id: `plan-${baselineQueryId}`, - }, - { - id: `${spec.id}-${comparisonQueryId}`, - label: spec.label, - operator_type_name: spec.operatorType, - plan_id: `plan-${comparisonQueryId}`, - }, - ], - stats: { - duration_s: buildMockDelta(duration), - input_rows: buildMockDelta(inputRows), - output_rows: buildMockDelta(outputRows), - }, - }; - }); +async function fetchQueryBundleForDiff( + request: Request, + engineId: string, + queryId: string +): Promise> { + return fetchJsonForDiff>(request, queryBundlePath(engineId, queryId)); } -function makeMockQueryProfileDiffResponse(request: DiffRequest): DiffResponse { - return { - comparisonQueries: request.comparisonQueries.map((query, index): QueryDiff => { - const durationA = 40; - const durationB = 44 + index * 3; - return { - compatibility: 'compatible', - query: { - id: query.query_id, - engine_id: query.engine_id, - engine_name: query.engine_id, - instance_name: queryNameFromId(query.query_id), - query_group_id: null, - query_group_name: null, - }, - stat_diffs: { - duration: { - stats: [durationA, durationB], - delta: durationA - durationB, - percent_delta: durationB === 0 ? null : (durationA - durationB) / durationB, - }, - }, - operator_diffs: buildMockOperatorDiffs( - request.baselineQuery.query_id, - query.query_id, - index - ), - }; - }), - }; +async function fetchSingleTimelineForDiff( + request: Request, + engineId: string, + timeline: SingleTimelineRequest +): Promise { + return fetchJsonForDiff( + request, + `/api/engines/${encodeURIComponent(engineId)}/timeline/single`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(timeline), + } + ); } /** @@ -367,11 +240,30 @@ export const handlers = [ http.post('*/api/query-profile-diff', async ({ request }) => { const body = (await request.json()) as DiffRequest; - return HttpResponse.json(makeMockQueryProfileDiffResponse(body)); + const baselineBundle = await fetchQueryBundleForDiff( + request, + body.baselineQuery.engine_id, + body.baselineQuery.query_id + ); + const comparisonBundles = await Promise.all( + body.comparisonQueries.map(query => + fetchQueryBundleForDiff(request, query.engine_id, query.query_id) + ) + ); + return HttpResponse.json( + buildQueryProfileDiffResponseFromBundles(baselineBundle, comparisonBundles) + ); }), http.post('*/api/timeline/diff', async ({ request }) => { const body = (await request.json()) as DiffTimelineRequest; - return HttpResponse.json(makeMockTimelineDiffResponse(body)); + const timelines = await Promise.all( + body.timelines.map(({ engine_id: engineId, timeline }) => + fetchSingleTimelineForDiff(request, engineId, timeline) + ) + ); + return HttpResponse.json( + makeTimelineDiffResponse(body, timelines as DiffTimelineResponse['timelines']) + ); }), ]; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 4410f20b..883c3bd4 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -8,6 +8,9 @@ import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; import { visualizer } from 'rollup-plugin-visualizer'; import tailwindcss from '@tailwindcss/vite'; +import { buildQueryProfileDiffResponseFromBundles } from './packages/@quent/client/src/queryProfileDiffFromBundles'; +import type { DiffRequest } from './packages/@quent/client/src/queryProfileDiffTypes'; +import type { EntityRef, QueryBundle } from '@quent/utils'; const API_TARGET = process.env.VITE_API_TARGET || 'http://localhost:8080'; @@ -76,16 +79,6 @@ interface DiffTimelineRequest { delta_config: TimelineConfig; } -interface DiffQueryRef { - engine_id: string; - query_id: string; -} - -interface DiffRequest { - baselineQuery: DiffQueryRef; - comparisonQueries: DiffQueryRef[]; -} - const QUERY_A_HIGHER_SERIES = 'Query A higher'; const QUERY_B_HIGHER_SERIES = 'Query B higher'; @@ -139,11 +132,14 @@ async function fetchSingleTimelineFromTarget( engineId: string, request: unknown ): Promise { - const response = await fetch(`${API_TARGET}/api/engines/${engineId}/timeline/single`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }); + const response = await fetch( + apiTargetUrl(`/api/engines/${encodeURIComponent(engineId)}/timeline/single`), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + } + ); if (!response.ok) { throw new Error(`single timeline fetch failed: ${response.status} ${response.statusText}`); @@ -152,173 +148,31 @@ async function fetchSingleTimelineFromTarget( return (await response.json()) as SingleTimelineResponse; } -function writeJson(res: ServerResponse, statusCode: number, body: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(body)); -} - -function queryNameFromId(queryId: string): string { - const parts = queryId.split('-'); - const suffix = parts[parts.length - 1]; - return suffix ? `Query ${suffix.toUpperCase()}` : queryId; -} - -interface MockOperatorDiffSpec { - id: string; - label: string; - operatorType: string; - duration: [number, number]; - inputRows: [number, number]; - outputRows: [number, number]; -} - -const MOCK_OPERATOR_DIFF_SPECS: MockOperatorDiffSpec[] = [ - { - id: 'scan-orders', - label: 'Scan orders', - operatorType: 'Scan', - duration: [12, 10], - inputRows: [0, 0], - outputRows: [1_000_000, 1_200_000], - }, - { - id: 'filter-active', - label: 'Filter active rows', - operatorType: 'Filter', - duration: [4, 5], - inputRows: [1_000_000, 1_200_000], - outputRows: [750_000, 820_000], - }, - { - id: 'project-columns', - label: 'Project selected columns', - operatorType: 'Project', - duration: [2, 2.5], - inputRows: [750_000, 820_000], - outputRows: [750_000, 820_000], - }, - { - id: 'join-lineitem', - label: 'Join lineitem', - operatorType: 'Join', - duration: [24, 30], - inputRows: [750_000, 820_000], - outputRows: [400_000, 380_000], - }, - { - id: 'sort-revenue', - label: 'Sort by revenue', - operatorType: 'Sort', - duration: [6, 8], - inputRows: [400_000, 380_000], - outputRows: [400_000, 380_000], - }, - { - id: 'window-rank', - label: 'Window rank', - operatorType: 'Window', - duration: [8, 7], - inputRows: [400_000, 380_000], - outputRows: [400_000, 380_000], - }, - { - id: 'aggregate-status', - label: 'Aggregate by status', - operatorType: 'Aggregate', - duration: [4, 4], - inputRows: [400_000, 380_000], - outputRows: [20, 18], - }, -]; +async function fetchQueryBundleFromTarget( + engineId: string, + queryId: string +): Promise> { + const response = await fetch( + apiTargetUrl( + `/api/engines/${encodeURIComponent(engineId)}/query/${encodeURIComponent(queryId)}` + ) + ); -function buildMockDelta([baseline, comparison]: [number, number]) { - const delta = baseline - comparison; - return { - stats: [baseline, comparison] as [number, number], - delta, - percent_delta: comparison === 0 ? null : delta / comparison, - }; -} + if (!response.ok) { + throw new Error(`query bundle fetch failed: ${response.status} ${response.statusText}`); + } -function adjustComparisonValue(value: number, competitorIndex: number, statIndex: number): number { - if (value === 0) return 0; - const direction = statIndex % 2 === 0 ? 1 : -1; - return Number((value + direction * competitorIndex * Math.max(1, value * 0.08)).toFixed(3)); + return (await response.json()) as QueryBundle; } -function buildMockOperatorDiffs( - baselineQueryId: string, - comparisonQueryId: string, - competitorIndex: number -) { - return MOCK_OPERATOR_DIFF_SPECS.map((spec, statIndex) => { - const duration = [ - spec.duration[0], - adjustComparisonValue(spec.duration[1], competitorIndex, statIndex), - ] as [number, number]; - const inputRows = [ - spec.inputRows[0], - adjustComparisonValue(spec.inputRows[1], competitorIndex, statIndex), - ] as [number, number]; - const outputRows = [ - spec.outputRows[0], - adjustComparisonValue(spec.outputRows[1], competitorIndex, statIndex), - ] as [number, number]; - - return { - operators: [ - { - id: `${spec.id}-${baselineQueryId}`, - label: spec.label, - operator_type_name: spec.operatorType, - plan_id: `plan-${baselineQueryId}`, - }, - { - id: `${spec.id}-${comparisonQueryId}`, - label: spec.label, - operator_type_name: spec.operatorType, - plan_id: `plan-${comparisonQueryId}`, - }, - ], - stats: { - duration_s: buildMockDelta(duration), - input_rows: buildMockDelta(inputRows), - output_rows: buildMockDelta(outputRows), - }, - }; - }); +function apiTargetUrl(pathname: string): string { + return `${API_TARGET.replace(/\/$/, '')}${pathname}`; } -function makeQueryProfileDiffResponse(body: DiffRequest) { - return { - comparisonQueries: body.comparisonQueries.map((query, index) => { - const baselineDuration = 40; - const comparisonDuration = 44 + index * 3; - return { - compatibility: 'compatible', - query: { - id: query.query_id, - engine_id: query.engine_id, - engine_name: query.engine_id, - instance_name: queryNameFromId(query.query_id), - query_group_id: null, - query_group_name: null, - }, - stat_diffs: { - duration: { - stats: [baselineDuration, comparisonDuration], - delta: baselineDuration - comparisonDuration, - percent_delta: - comparisonDuration === 0 - ? null - : (baselineDuration - comparisonDuration) / comparisonDuration, - }, - }, - operator_diffs: buildMockOperatorDiffs(body.baselineQuery.query_id, query.query_id, index), - }; - }), - }; +function writeJson(res: ServerResponse, statusCode: number, body: unknown) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); } function installQueryProfileDiffMock(server: ViteDevServer | PreviewServer) { @@ -340,10 +194,24 @@ function installQueryProfileDiffMock(server: ViteDevServer | PreviewServer) { throw new Error('query profile diff requires a baseline query and comparison queries'); } - writeJson(res, 200, makeQueryProfileDiffResponse(body)); + const baselineBundle = await fetchQueryBundleFromTarget( + body.baselineQuery.engine_id, + body.baselineQuery.query_id + ); + const comparisonBundles = await Promise.all( + body.comparisonQueries.map(query => + fetchQueryBundleFromTarget(query.engine_id, query.query_id) + ) + ); + + writeJson( + res, + 200, + buildQueryProfileDiffResponseFromBundles(baselineBundle, comparisonBundles) + ); } catch (error) { writeJson(res, 500, { - error: error instanceof Error ? error.message : 'Failed to mock query profile diff', + error: error instanceof Error ? error.message : 'Failed to build query profile diff', }); } }); From 35c4b1e3b2b917fd5e027502a7f91403207b5105 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 16:10:41 -0600 Subject: [PATCH 20/33] One option for multi value table cells --- .../src/pivot-table/PivotedStatTable.tsx | 1 + .../components/src/pivot-table/types.ts | 1 + .../query-diff/QueryDiffTable.test.tsx | 22 +++ .../components/query-diff/QueryDiffTable.tsx | 127 +++++++++++++++++- .../query-diff/QueryDiffTable.utils.ts | 47 ++++++- 5 files changed, 189 insertions(+), 9 deletions(-) diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index 8914c86a..247cf42d 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -194,6 +194,7 @@ function DataCell({ row, stat }: DataCellProps) { boxShadow: cellHighlight, }; const customContent = renderConfig.formatDataCellValue?.({ + row, stat, value: rawValue, numericValue: numVal, diff --git a/ui/packages/@quent/components/src/pivot-table/types.ts b/ui/packages/@quent/components/src/pivot-table/types.ts index b13ce39f..adc1c080 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -82,6 +82,7 @@ export interface PivotTableRenderConfig { aggMode: AggMode; }) => React.CSSProperties | undefined; formatDataCellValue?: (args: { + row: PivotedRow; stat: string; value: StatValue; numericValue: number | null; diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index ea1ed396..94ce51ae 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { buildQueryDiffRows, formatSignedDiffValue, + formatSignedPercentDelta, getDeltaCellStyle, } from './QueryDiffTable.utils'; import { @@ -37,6 +38,20 @@ describe('QueryDiffTable helpers', () => { duration_s: -2, input_rows: 200, }, + statDetails: { + duration_s: { + baseline: 12, + comparison: 10, + delta: -2, + percentDelta: -0.2, + }, + input_rows: { + baseline: 1000, + comparison: 1200, + delta: 200, + percentDelta: 0.1666666667, + }, + }, }); }); @@ -52,6 +67,13 @@ describe('QueryDiffTable helpers', () => { expect(formatSignedDiffValue(-0.125, 'probe_selectivity')).toBe('-12.5%'); }); + it('formats percent deltas with signs', () => { + expect(formatSignedPercentDelta(0.2)).toBe('+20.0%'); + expect(formatSignedPercentDelta(-0.2)).toBe('-20.0%'); + expect(formatSignedPercentDelta(0)).toBe('0.0%'); + expect(formatSignedPercentDelta(null)).toBe('-'); + }); + it('returns diverging styles for positive and negative deltas only', () => { expect(getDeltaCellStyle(5, 10)?.backgroundColor).toContain(DIFF_POSITIVE_COLOR); expect(getDeltaCellStyle(-5, 10)?.backgroundColor).toContain(DIFF_NEGATIVE_COLOR); diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index ddbe46f4..db832391 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -7,7 +7,9 @@ import { DataText, PivotedStatTable, PivotTableToolbar, + formatStatValue, getSchemaStatNames, + type AggMode, type HoveredStatInfo, type PivotedRow, type PivotedStatTableSchema, @@ -16,13 +18,16 @@ import { } from '@quent/components'; import { useStatGroupTableControls } from '@quent/hooks'; import type { DiffQuerySummary, QueryDiff } from '@quent/client'; +import type { StatValue } from '@quent/utils'; import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; import { getQueryDiffOperatorTypeColor } from './QueryDiffColors'; import { buildMaxAbsByStat, buildQueryDiffRows, formatSignedDiffValue, + formatSignedPercentDelta, getDeltaCellStyle, + type QueryDiffTableCellValues, type QueryDiffTableRow, } from './QueryDiffTable.utils'; @@ -56,7 +61,7 @@ const DEFAULT_ENABLED: Record = { operator: false, }; -const VIRTUALIZATION_CONFIG = { enabled: true, overscan: 12 } as const; +const VIRTUALIZATION_CONFIG = { enabled: true, estimateRowHeight: 66, overscan: 12 } as const; const getOperatorTypeColor = (key: string, id: string): string | undefined => key === 'operator_type' ? getQueryDiffOperatorTypeColor(id) : undefined; @@ -93,6 +98,111 @@ function OperatorPairCell({ row }: { row: QueryDiffTableRow }) { ); } +function aggregateNumericValues(values: number[], aggMode: AggMode): number | null { + if (values.length === 0) return null; + switch (aggMode) { + case 'mean': + return values.reduce((sum, value) => sum + value, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + case 'stdev': { + if (values.length <= 1) return null; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const variance = + values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1); + return Math.sqrt(variance); + } + case 'sum': + default: + return values.reduce((sum, value) => sum + value, 0); + } +} + +function aggregateStatValues(values: StatValue[], aggMode: AggMode): StatValue { + if (values.length === 0) return null; + if (values.length === 1) return values[0]; + + const numericValues = values.filter((value): value is number => typeof value === 'number'); + if (numericValues.length !== values.length) return null; + return aggregateNumericValues(numericValues, aggMode); +} + +function getPercentDeltaFromValues(baseline: StatValue, comparison: StatValue): number | null { + if (typeof baseline !== 'number' || typeof comparison !== 'number' || comparison === 0) { + return null; + } + + const percentDelta = (comparison - baseline) / Math.abs(comparison); + return percentDelta === 0 || Object.is(percentDelta, -0) ? 0 : percentDelta; +} + +function getTableCellValues({ + row, + stat, + value, + aggMode, + rowsByOperatorPairId, +}: { + row: PivotedRow; + stat: string; + value: StatValue; + aggMode: AggMode; + rowsByOperatorPairId: Map; +}): QueryDiffTableCellValues | null { + const sourceValues = [...row.itemIds] + .map(itemId => rowsByOperatorPairId.get(itemId)?.statDetails[stat]) + .filter((cellValues): cellValues is QueryDiffTableCellValues => cellValues != null); + + if (sourceValues.length === 0) return null; + if (sourceValues.length === 1) return sourceValues[0]; + + const baseline = aggregateStatValues( + sourceValues.map(cellValues => cellValues.baseline), + aggMode + ); + const comparison = aggregateStatValues( + sourceValues.map(cellValues => cellValues.comparison), + aggMode + ); + + return { + baseline, + comparison, + delta: value, + percentDelta: getPercentDeltaFromValues(baseline, comparison), + }; +} + +function QueryDiffDataCell({ values, stat }: { values: QueryDiffTableCellValues; stat: string }) { + return ( +
+
+ Baseline + + {formatStatValue(values.baseline, stat)} + +
+
+ Comparison + + {formatStatValue(values.comparison, stat)} + +
+
+ Delta + + {formatSignedDiffValue(values.delta, stat)} + + ({formatSignedPercentDelta(values.percentDelta)}) + + +
+
+ ); +} + interface QueryDiffTableProps { baselineQuery: DiffQuerySummary; comparisonQuery: DiffQuerySummary; @@ -188,7 +298,20 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi }, getDataCellStyle: ({ stat, value }) => getDeltaCellStyle(value, maxAbsByStat.get(stat), paletteTheme), - formatDataCellValue: ({ stat, value }) => formatSignedDiffValue(value, stat), + formatDataCellValue: ({ row, stat, value, aggMode }) => { + const cellValues = getTableCellValues({ + row, + stat, + value, + aggMode, + rowsByOperatorPairId, + }); + return cellValues ? ( + + ) : ( + formatSignedDiffValue(value, stat) + ); + }, }), [maxAbsByStat, paletteTheme, rowsByEngineGroupId, rowsByOperatorPairId] ); diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index 895dbbdb..c701999b 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { DiffQuerySummary, QueryDiff } from '@quent/client'; +import type { DiffDelta, DiffQuerySummary, QueryDiff } from '@quent/client'; import type { PaletteTheme, StatValue } from '@quent/utils'; import { formatStatValue } from '@quent/components'; import { getDiffNegativeColor, getDiffPositiveColor } from './QueryDiffColors'; @@ -11,6 +11,13 @@ export interface QueryDiffTableEngine { label: string; } +export interface QueryDiffTableCellValues { + baseline: StatValue; + comparison: StatValue; + delta: StatValue; + percentDelta: number | null; +} + export interface QueryDiffTableRow { engineGroupId: string; engineGroupLabel: string; @@ -23,6 +30,7 @@ export interface QueryDiffTableRow { operatorBId: string; operatorBLabel: string; stats: Record; + statDetails: Record; } function getQueryEngine(query: DiffQuerySummary): QueryDiffTableEngine { @@ -37,6 +45,21 @@ function displayDeltaValue(value: StatValue): StatValue { return value === 0 || Object.is(value, -0) ? 0 : -value; } +function displayPercentDeltaValue(value: number | null): number | null { + if (value === null) return null; + const displayedValue = -value; + return displayedValue === 0 || Object.is(displayedValue, -0) ? 0 : displayedValue; +} + +function buildCellValues(stat: DiffDelta): QueryDiffTableCellValues { + return { + baseline: stat.stats[0], + comparison: stat.stats[1], + delta: displayDeltaValue(stat.delta), + percentDelta: displayPercentDeltaValue(stat.percent_delta), + }; +} + function formatOperatorPairLabel( operatorALabel: string, operatorAId: string, @@ -65,12 +88,14 @@ export function buildQueryDiffRows( operatorB.label, operatorB.id ); - const stats = Object.fromEntries( - Object.entries(entry.stats).map(([statName, stat]) => [ - statName, - displayDeltaValue(stat.delta), - ]) - ); + const stats: Record = {}; + const statDetails: Record = {}; + for (const [statName, stat] of Object.entries(entry.stats)) { + const cellValues = buildCellValues(stat); + stats[statName] = cellValues.delta; + statDetails[statName] = cellValues; + } + return [ { engineGroupId, @@ -84,6 +109,7 @@ export function buildQueryDiffRows( operatorBId: operatorB.id, operatorBLabel: operatorB.label, stats, + statDetails, }, ]; }); @@ -95,6 +121,13 @@ export function formatSignedDiffValue(value: StatValue, statName: string): strin return value > 0 ? `+${formattedValue}` : formattedValue; } +export function formatSignedPercentDelta(percentDelta: number | null): string { + if (percentDelta === null) return '-'; + const displayedValue = percentDelta === 0 || Object.is(percentDelta, -0) ? 0 : percentDelta; + const formattedValue = `${(displayedValue * 100).toFixed(1)}%`; + return displayedValue > 0 ? `+${formattedValue}` : formattedValue; +} + export function getDeltaCellStyle( value: StatValue, maxAbs: number | undefined, From 7debd3e5d1227b956a6e0fdbf33c40767ab7c6fb Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 16:23:27 -0600 Subject: [PATCH 21/33] Selectable stat for bar spark charts, combine engines into one table --- .../src/pivot-table/PivotTableToolbar.tsx | 32 ++- .../components/query-diff/QueryDiffStats.tsx | 213 ++++++++++++++++-- .../query-diff/QueryDiffStats.utils.test.ts | 30 +++ .../query-diff/QueryDiffStats.utils.ts | 31 ++- .../query-diff/QueryDiffTable.test.tsx | 1 + .../components/query-diff/QueryDiffTable.tsx | 59 ++++- .../query-diff/QueryDiffTable.utils.ts | 5 +- ui/src/pages/DiffSelectionPage.tsx | 115 ++++++---- 8 files changed, 392 insertions(+), 94 deletions(-) diff --git a/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx b/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx index 87e42877..b6fdd8a3 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx @@ -12,6 +12,7 @@ export interface IndexConfigEntry { key: string; label: React.ReactNode; enabled: boolean; + locked?: boolean; } export interface PivotTableToolbarProps { @@ -48,6 +49,7 @@ export function PivotTableToolbar({ const fromIndex = keys.indexOf(fromKey); const targetIndex = keys.indexOf(toKey); if (fromIndex < 0 || targetIndex < 0) return; + if (indexConfig[fromIndex]?.locked || indexConfig[targetIndex]?.locked) return; let anchorKey = toKey; if (position === 'before' && fromIndex < targetIndex) { @@ -67,8 +69,8 @@ export function PivotTableToolbar({ <>
Group by: - {indexConfig.map(({ key, label, enabled }) => { - const dropPosition = dragDrop.getDropTargetPosition(key); + {indexConfig.map(({ key, label, enabled, locked }) => { + const dropPosition = locked ? undefined : dragDrop.getDropTargetPosition(key); const dropIndicatorStyle = dropPosition ? { boxShadow: @@ -80,18 +82,32 @@ export function PivotTableToolbar({ return ( + + +
+ + setSearch(event.target.value)} + autoFocus + /> +
+
+ {filteredStatNames.map(statName => { + const selected = statName === value; + return ( + + ); + })} + {filteredStatNames.length === 0 && ( +

No stats found

+ )} +
+
+ +
+ ); +} + +function OperatorStatChart({ + rows, + statNames, + selectedStat, + onSelectedStatChange, +}: { + rows: StatisticMiniBarChartRow[]; + statNames: string[]; + selectedStat: string; + onSelectedStatChange: (statName: string) => void; +}) { + return ( +
+ + +
+ ); +} + function operatorRuntimeChartRows( comparisons: OperatorTypeRuntimeComparison[], - queryColors: QueryDiffQueryColors + queryColors: QueryDiffQueryColors, + statName: string ): StatisticMiniBarChartRow[] { return comparisons.map(comparison => ({ id: comparison.id, label: comparison.label, labelColor: getQueryDiffOperatorTypeColor(comparison.id), - title: formatDurationSeconds(Math.max(comparison.a, comparison.b)), + title: formatOperatorChartValue(Math.max(comparison.a, comparison.b), statName), bars: [ { id: 'baseline', @@ -156,9 +295,11 @@ function operatorRuntimeChartRows( function aggregateOperatorRuntimeChartRows({ comparisons, paletteTheme, + statName, }: { comparisons: QueryDiffStatsOverviewComparison[]; paletteTheme: PaletteTheme; + statName: string; }): StatisticMiniBarChartRow[] { const rowsByOperatorType = new Map< string, @@ -170,7 +311,7 @@ function aggregateOperatorRuntimeChartRows({ >(); for (const comparison of comparisons) { - const operatorComparisons = buildOperatorTypeRuntimeComparisons(comparison.diff); + const operatorComparisons = buildOperatorTypeRuntimeComparisons(comparison.diff, statName); const comparisonName = comparison.comparisonQuery.instance_name ?? comparison.comparisonQuery.id; const queryColors = getQueryDiffQueryColors({ @@ -202,8 +343,9 @@ function aggregateOperatorRuntimeChartRows({ id: operatorType, label: row.label, labelColor: getQueryDiffOperatorTypeColor(operatorType), - title: formatDurationSeconds( - Math.max(row.baselineValue, ...row.comparisonBars.map(bar => bar.value)) + title: formatOperatorChartValue( + Math.max(row.baselineValue, ...row.comparisonBars.map(bar => bar.value)), + statName ), bars: [ { @@ -248,9 +390,16 @@ export function QueryDiffStats({ }), [baselineQuery.id, comparisonQuery.id, comparisonIndex, paletteTheme] ); + const operatorStatNames = useMemo(() => getOperatorDiffStatNames([diff]), [diff]); + const [requestedOperatorStat, setRequestedOperatorStat] = useState('duration_s'); + const selectedOperatorStat = useMemo( + () => resolveOperatorStat(operatorStatNames, requestedOperatorStat), + [operatorStatNames, requestedOperatorStat] + ); const operatorRuntimeComparisons = useMemo( - () => buildOperatorTypeRuntimeComparisons(diff), - [diff] + () => + selectedOperatorStat ? buildOperatorTypeRuntimeComparisons(diff, selectedOperatorStat) : [], + [diff, selectedOperatorStat] ); const totalRuntimeComparison = useMemo( () => @@ -272,14 +421,19 @@ export function QueryDiffStats({ queryColors={queryColors} paletteTheme={paletteTheme} /> - {operatorRuntimeComparisons.length > 0 && ( + {selectedOperatorStat && operatorRuntimeComparisons.length > 0 && ( } /> @@ -296,6 +450,15 @@ export function QueryDiffOverviewStats({ }) { const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? 'dark' : 'light'; + const operatorStatNames = useMemo( + () => getOperatorDiffStatNames(comparisons.map(comparison => comparison.diff)), + [comparisons] + ); + const [requestedOperatorStat, setRequestedOperatorStat] = useState('duration_s'); + const selectedOperatorStat = useMemo( + () => resolveOperatorStat(operatorStatNames, requestedOperatorStat), + [operatorStatNames, requestedOperatorStat] + ); const totalRuntimeComparisons = useMemo( () => @@ -318,8 +481,15 @@ export function QueryDiffOverviewStats({ [comparisons, paletteTheme] ); const operatorRuntimeRows = useMemo( - () => aggregateOperatorRuntimeChartRows({ comparisons, paletteTheme }), - [comparisons, paletteTheme] + () => + selectedOperatorStat + ? aggregateOperatorRuntimeChartRows({ + comparisons, + paletteTheme, + statName: selectedOperatorStat, + }) + : [], + [comparisons, paletteTheme, selectedOperatorStat] ); return ( @@ -335,14 +505,15 @@ export function QueryDiffOverviewStats({ paletteTheme={paletteTheme} /> ))} - {operatorRuntimeRows.length > 0 && ( + {selectedOperatorStat && operatorRuntimeRows.length > 0 && ( } /> diff --git a/ui/src/components/query-diff/QueryDiffStats.utils.test.ts b/ui/src/components/query-diff/QueryDiffStats.utils.test.ts index 9b135b8c..445e4b73 100644 --- a/ui/src/components/query-diff/QueryDiffStats.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffStats.utils.test.ts @@ -7,6 +7,8 @@ import { buildOperatorTypeRuntimeComparisons, buildRuntimeComparison, buildRuntimeComparisonFromDelta, + getDefaultOperatorDiffStatName, + getOperatorDiffStatNames, formatPercentDelta, formatSignedDurationSeconds, sumRuntimeComparisons, @@ -74,6 +76,34 @@ describe('QueryDiffStats helpers', () => { }); }); + it('extracts numeric operator diff stat names in payload order', () => { + expect(getOperatorDiffStatNames([equalPlanQueryDiffFixture])).toEqual([ + 'duration_s', + 'input_rows', + 'output_rows', + ]); + }); + + it('defaults operator stat selection to duration_s or the first available stat', () => { + expect(getDefaultOperatorDiffStatName(['input_rows', 'duration_s'])).toBe('duration_s'); + expect(getDefaultOperatorDiffStatName(['input_rows', 'output_rows'])).toBe('input_rows'); + expect(getDefaultOperatorDiffStatName([])).toBeNull(); + }); + + it('extracts sorted per-operator-type comparisons for a selected stat', () => { + const comparisons = buildOperatorTypeRuntimeComparisons( + equalPlanQueryDiffFixture, + 'input_rows' + ); + + expect(comparisons.map(comparison => comparison.label)).toEqual(['Scan', 'Join', 'Aggregate']); + expect(sumRuntimeComparisons(comparisons)).toMatchObject({ + a: 2300, + b: 2530, + delta: -230, + }); + }); + it('formats duration and percent deltas for cards', () => { expect(formatSignedDurationSeconds(2)).toBe('+2.00s'); expect(formatSignedDurationSeconds(-0.5)).toBe('-500.00ms'); diff --git a/ui/src/components/query-diff/QueryDiffStats.utils.ts b/ui/src/components/query-diff/QueryDiffStats.utils.ts index edefd662..f5549822 100644 --- a/ui/src/components/query-diff/QueryDiffStats.utils.ts +++ b/ui/src/components/query-diff/QueryDiffStats.utils.ts @@ -51,14 +51,15 @@ export function buildRuntimeComparisonFromDelta( } export function buildOperatorTypeRuntimeComparisons( - diff: QueryDiff + diff: QueryDiff, + statName = 'duration_s' ): OperatorTypeRuntimeComparison[] { const totalsByOperatorType = new Map(); for (const entry of diff.operator_diffs ?? []) { const [operatorA, operatorB] = entry.operators; - const duration = entry.stats.duration_s; - const [a, b] = duration?.stats ?? ([] as StatValue[]); + const stat = entry.stats[statName]; + const [a, b] = stat?.stats ?? ([] as StatValue[]); if (typeof a !== 'number' || typeof b !== 'number') continue; const operatorType = operatorA.operator_type_name ?? operatorB.operator_type_name ?? 'Unknown'; @@ -77,6 +78,30 @@ export function buildOperatorTypeRuntimeComparisons( .sort((left, right) => Math.max(right.a, right.b) - Math.max(left.a, left.b)); } +export function getOperatorDiffStatNames(diffs: QueryDiff[]): string[] { + const statNames: string[] = []; + const seen = new Set(); + + for (const diff of diffs) { + for (const entry of diff.operator_diffs ?? []) { + for (const [statName, stat] of Object.entries(entry.stats)) { + if (seen.has(statName)) continue; + const [a, b] = stat.stats; + if (typeof a !== 'number' || typeof b !== 'number') continue; + seen.add(statName); + statNames.push(statName); + } + } + } + + return statNames; +} + +export function getDefaultOperatorDiffStatName(statNames: readonly string[]): string | null { + if (statNames.length === 0) return null; + return statNames.includes('duration_s') ? 'duration_s' : statNames[0]!; +} + export function sumRuntimeComparisons(entries: RuntimeComparison[]): RuntimeComparison { return buildRuntimeComparison( entries.reduce((sum, entry) => sum + entry.a, 0), diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index 94ce51ae..70620efb 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -30,6 +30,7 @@ describe('QueryDiffTable helpers', () => { engines: [{ id: 'engine-b', label: 'Engine B' }], operatorType: 'Scan', operatorLabel: 'Scan orders <-> Scan orders\nscan-a <-> scan-b', + operatorPairId: 'query-b:scan-a:scan-b', operatorAId: 'scan-a', operatorALabel: 'Scan orders', operatorBId: 'scan-b', diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index db832391..150919ea 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -56,7 +56,7 @@ const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { const INDEX_ORDER: IndexKey[] = ['engine', 'operator_type', 'operator']; const DEFAULT_ENABLED: Record = { - engine: false, + engine: true, operator_type: true, operator: false, }; @@ -203,16 +203,44 @@ function QueryDiffDataCell({ values, stat }: { values: QueryDiffTableCellValues; ); } -interface QueryDiffTableProps { - baselineQuery: DiffQuerySummary; +export interface QueryDiffTableComparison { + id: string; comparisonQuery: DiffQuerySummary; diff: QueryDiff; } -export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDiffTableProps) { +interface QueryDiffTableProps { + baselineQuery: DiffQuerySummary; + comparisons: QueryDiffTableComparison[]; +} + +function comparisonCountLabel(comparisons: QueryDiffTableComparison[]): string { + if (comparisons.length === 1) { + return ( + comparisons[0]?.comparisonQuery.instance_name ?? comparisons[0]?.comparisonQuery.id ?? '' + ); + } + return `${comparisons.length} comparison queries`; +} + +export function QueryDiffTable({ baselineQuery, comparisons }: QueryDiffTableProps) { const rows = useMemo( - () => buildQueryDiffRows(baselineQuery, comparisonQuery, diff), - [baselineQuery, comparisonQuery, diff] + () => + comparisons.flatMap(comparison => + comparison.diff.compatibility === 'compatible' + ? buildQueryDiffRows( + baselineQuery, + comparison.comparisonQuery, + comparison.diff, + comparison.id + ) + : [] + ), + [baselineQuery, comparisons] + ); + const incompatibleDiffs = useMemo( + () => comparisons.filter(comparison => comparison.diff.compatibility !== 'compatible'), + [comparisons] ); const rowsByEngineGroupId = useMemo( () => new Map(rows.map(row => [row.engineGroupId, row])), @@ -251,7 +279,8 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi defaultEnabled: DEFAULT_ENABLED, allStatNames, defaultStatSelector: stats => stats, - persistKey: 'queryDiffTable:v3', + filterIndexOrder: indexOrder => ['engine', ...indexOrder.filter(key => key !== 'engine')], + persistKey: 'queryDiffTable:v4', rows, getRowIndexId: (row, key) => DIFF_TABLE_SCHEMA.groups[key].id(row), }); @@ -270,7 +299,8 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi visibleIndexOrder.map(key => ({ key, label: indexLabels[key], - enabled: enabledIndices[key], + enabled: key === 'engine' || enabledIndices[key], + locked: key === 'engine', })), [enabledIndices, indexLabels, visibleIndexOrder] ); @@ -316,10 +346,11 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi [maxAbsByStat, paletteTheme, rowsByEngineGroupId, rowsByOperatorPairId] ); - if (diff.compatibility !== 'compatible') { + if (comparisons.length > 0 && incompatibleDiffs.length === comparisons.length) { return (
- {diff.warnings?.[0] ?? 'Plans are not structurally equal; operator diff is unavailable.'} + {incompatibleDiffs[0]?.diff.warnings?.[0] ?? + 'Plans are not structurally equal; operator diff is unavailable.'}
); } @@ -345,7 +376,13 @@ export function QueryDiffTable({ baselineQuery, comparisonQuery, diff }: QueryDi aria-label="delta" role="img" /> - {comparisonQuery.instance_name ?? comparisonQuery.id} + {comparisonCountLabel(comparisons)} + {incompatibleDiffs.length > 0 && ( + + {incompatibleDiffs.length} incompatible comparison + {incompatibleDiffs.length === 1 ? '' : 's'} omitted + + )}
diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index c701999b..564405a1 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -72,7 +72,8 @@ function formatOperatorPairLabel( export function buildQueryDiffRows( _baselineQuery: DiffQuerySummary, comparisonQuery: DiffQuerySummary, - diff: QueryDiff + diff: QueryDiff, + comparisonId = comparisonQuery.id ): QueryDiffTableRow[] { const comparisonEngine = getQueryEngine(comparisonQuery); const engines = [comparisonEngine]; @@ -103,7 +104,7 @@ export function buildQueryDiffRows( engines, operatorType, operatorLabel, - operatorPairId: `${operatorA.id}:${operatorB.id}`, + operatorPairId: `${comparisonId}:${operatorA.id}:${operatorB.id}`, operatorAId: operatorA.id, operatorALabel: operatorA.label, operatorBId: operatorB.id, diff --git a/ui/src/pages/DiffSelectionPage.tsx b/ui/src/pages/DiffSelectionPage.tsx index 07f9fd9c..13894333 100644 --- a/ui/src/pages/DiffSelectionPage.tsx +++ b/ui/src/pages/DiffSelectionPage.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useQueries, useQuery } from '@tanstack/react-query'; -import { ChevronDown, Plus } from 'lucide-react'; +import { ArrowLeftRight, ChevronDown, Plus, X } from 'lucide-react'; import { fetchListCoordinators, fetchListEngines, @@ -633,30 +633,16 @@ function DiffDashboard({ baselineQuery, comparisonQueries }: DiffDashboardProps) />
) : activeTab === 'operator' ? ( -
-
- {comparisons.map((comparison, index) => ( -
-
- - Comparison Query {index + 1} - - - {querySummaryLabel(comparison.comparisonQuery)} - -
-
- -
-
- ))} +
+
+ ({ + id: comparison.id, + comparisonQuery: comparison.comparisonQuery, + diff: comparison.diff, + }))} + />
) : ( @@ -916,6 +902,15 @@ export function DiffSelectionPage({ setComparisonQueries(prev => [...prev, makeComparisonQuery()]); }; + const handleRemoveComparisonQuery = (comparisonId: string) => { + const nextComparisons = comparisonQueries.filter(query => query.id !== comparisonId); + if (nextComparisons.length === 0) return; + + setSelectionOpen(true); + setComparisonQueries(nextComparisons); + maybeNavigateToDiff(baselineQuery, nextComparisons); + }; + const handleMakeBaseline = (comparisonId: string) => { const selectedComparison = comparisonQueries.find(query => query.id === comparisonId); if (!selectedComparison || !isQuerySideComplete(selectedComparison)) return; @@ -971,19 +966,26 @@ export function DiffSelectionPage({
-
- +
+
+ +
+
{comparisonQueries.map((comparisonQuery, index) => { return ( @@ -994,17 +996,32 @@ export function DiffSelectionPage({ side={comparisonQuery} engines={engines} action={ - isQuerySideComplete(comparisonQuery) ? ( - - ) : null +
+ {isQuerySideComplete(comparisonQuery) && ( + + )} + {comparisonQueries.length > 1 && ( + + )} +
} onEngineChange={engineId => handleComparisonEngineChange(comparisonQuery.id, engineId) From f9daeb8916da824513e5fe07e3c8fd592dede381 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 20 May 2026 16:41:14 -0600 Subject: [PATCH 22/33] Configure grid a little different --- .../src/pivot-table/PivotTableToolbar.tsx | 23 ++++----------- .../src/pivot-table/PivotedStatTable.tsx | 5 +++- .../components/query-diff/QueryDiffStats.tsx | 28 +++++++++++++++++-- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx b/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx index b6fdd8a3..dafc25c6 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotTableToolbar.tsx @@ -49,7 +49,6 @@ export function PivotTableToolbar({ const fromIndex = keys.indexOf(fromKey); const targetIndex = keys.indexOf(toKey); if (fromIndex < 0 || targetIndex < 0) return; - if (indexConfig[fromIndex]?.locked || indexConfig[targetIndex]?.locked) return; let anchorKey = toKey; if (position === 'before' && fromIndex < targetIndex) { @@ -70,7 +69,7 @@ export function PivotTableToolbar({
Group by: {indexConfig.map(({ key, label, enabled, locked }) => { - const dropPosition = locked ? undefined : dragDrop.getDropTargetPosition(key); + const dropPosition = dragDrop.getDropTargetPosition(key); const dropIndicatorStyle = dropPosition ? { boxShadow: @@ -82,22 +81,13 @@ export function PivotTableToolbar({ return ( + + +
+ + setSearch(event.target.value)} + autoFocus + /> +
+
+ {filteredStatNames.map(statName => { + const selected = statName === value; + return ( + + ); + })} + {filteredStatNames.length === 0 && ( +

{emptyMessage}

+ )} +
+
+ +
+ ); +} + +export function MultiStatStackedBarChart({ + rows, + statNames, + selectedStat, + onSelectedStatChange, + statLabel = 'Stat', + searchPlaceholder = 'Search stats...', + emptyMessage = 'No stats found', + className, + chartClassName, + maxRows, +}: MultiStatStackedBarChartProps) { + return ( +
+ + +
+ ); +} diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index e6f19d88..341766c0 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -2,17 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo, useState, type CSSProperties } from 'react'; -import { Check, ChevronDown, Search, Triangle } from 'lucide-react'; +import { Triangle } from 'lucide-react'; import type { DiffQuerySummary, QueryDiff } from '@quent/client'; import { - Button, - DataText, - Input, - Popover, - PopoverContent, - PopoverTrigger, + MultiStatStackedBarChart, StatisticCard, - StatisticMiniBarChart, formatStatValue, type StatisticCardComparison, type StatisticMiniBarChartRow, @@ -151,133 +145,15 @@ function resolveOperatorStat(statNames: string[], requestedStat: string): string : getDefaultOperatorDiffStatName(statNames); } -function OperatorStatSelect({ - statNames, - value, - onValueChange, -}: { - statNames: string[]; - value: string; - onValueChange: (statName: string) => void; -}) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const filteredStatNames = useMemo(() => { - if (!search) return statNames; - const needle = search.toLowerCase(); - return statNames.filter(statName => statName.toLowerCase().includes(needle)); - }, [search, statNames]); - - return ( -
- Stat - { - setOpen(nextOpen); - if (!nextOpen) setSearch(''); - }} - > - - - - -
- - setSearch(event.target.value)} - autoFocus - /> -
-
- {filteredStatNames.map(statName => { - const selected = statName === value; - return ( - - ); - })} - {filteredStatNames.length === 0 && ( -

No stats found

- )} -
-
-
-
- ); +function getOverviewStatColumnCount(statCardCount: number): number { + if (statCardCount <= 0) return 1; + return statCardCount <= 5 ? statCardCount : 3; } -function OperatorStatChart({ - rows, - statNames, - selectedStat, - onSelectedStatChange, -}: { - rows: StatisticMiniBarChartRow[]; - statNames: string[]; - selectedStat: string; - onSelectedStatChange: (statName: string) => void; -}) { - return ( -
- - -
- ); -} - -function overviewStatCardClassName(index: number, totalCards: number): string { - const isLast = index === totalCards - 1; - const isLastTwo = index >= totalCards - 2; - - return cn( - 'col-span-12 md:col-span-6 xl:col-span-4', - totalCards % 2 === 1 && isLast && 'md:col-span-12', - totalCards % 3 === 1 && isLast && 'xl:col-span-12', - totalCards % 3 === 2 && isLastTwo && 'xl:col-span-6' - ); +function overviewRuntimeCardClassName(index: number, statCardCount: number): string { + const columnCount = getOverviewStatColumnCount(statCardCount); + const isEndOfRow = (index + 1) % columnCount === 0 || index === statCardCount - 1; + return cn(isEndOfRow && 'border-r-0'); } function operatorRuntimeChartRows( @@ -440,7 +316,7 @@ export function QueryDiffStats({ 0); - const totalOverviewCards = totalRuntimeComparisons.length + (hasOperatorRuntimeChart ? 1 : 0); + const overviewGridStyle = useMemo( + () => ({ + gridTemplateColumns: `repeat(${getOverviewStatColumnCount(totalRuntimeComparisons.length)}, minmax(0, 1fr))`, + }), + [totalRuntimeComparisons.length] + ); return (
-
+
{totalRuntimeComparisons.map((comparison, index) => ( ))} {hasOperatorRuntimeChart && selectedOperatorStat && ( Date: Thu, 21 May 2026 10:10:10 -0600 Subject: [PATCH 25/33] Make tooltips useful --- .../client/src/queryProfileDiffTypes.ts | 5 +- ui/packages/@quent/components/src/index.ts | 6 + .../stat-card/MultiStatStackedBarChart.tsx | 21 +- .../src/stat-card/NumberComparisonCard.tsx | 68 +++++ .../src/stat-card/StatisticCard.tsx | 262 +++++++++++++++++- ui/packages/@quent/utils/src/dagTypes.ts | 6 +- ui/packages/@quent/utils/src/index.ts | 1 - .../components/query-diff/QueryDiffLegend.tsx | 2 +- .../components/query-diff/QueryDiffStats.tsx | 139 ++++++---- 9 files changed, 447 insertions(+), 63 deletions(-) create mode 100644 ui/packages/@quent/components/src/stat-card/NumberComparisonCard.tsx diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index 2bcc4cd6..7c6d5562 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -5,9 +5,9 @@ import type { QueryFilter, SingleTimelineRequest, SingleTimelineResponse, - StatValue, TaskFilter, TimelineConfig, + Value, } from '@quent/utils'; export interface DiffQueryRef { @@ -45,12 +45,13 @@ export interface DiffOperatorRef { } export interface DiffDelta { - stats: [StatValue, StatValue]; + stats: [Value, Value]; delta: number | null; percent_delta: number | null; } export interface DiffOperatorDelta { + /* Should we be using Operator type here? */ operators: [DiffOperatorRef, DiffOperatorRef]; /* stat name -> delta values */ stats: Record; diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 04049b43..e578e586 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -86,15 +86,21 @@ export { // ─── Statistic card components ─────────────────────────────────────────────── export { StatisticCard, StatisticMiniBarChart } from './stat-card/StatisticCard'; export { MultiStatStackedBarChart } from './stat-card/MultiStatStackedBarChart'; +export { NumberComparisonCard } from './stat-card/NumberComparisonCard'; export type { StatisticCardComparison, StatisticCardProps, StatisticCardValueTone, StatisticMiniBarChartBar, + StatisticMiniBarChartBarDetail, + StatisticMiniBarChartPercentDeltaFormatter, StatisticMiniBarChartProps, + StatisticMiniBarChartRelativeValueStyle, StatisticMiniBarChartRow, + StatisticMiniBarChartValueFormatter, } from './stat-card/StatisticCard'; export type { MultiStatStackedBarChartProps } from './stat-card/MultiStatStackedBarChart'; +export type { NumberComparisonCardProps } from './stat-card/NumberComparisonCard'; // ─── ECharts ────────────────────────────────────────────────────────────────── export { echarts } from './lib/echarts'; diff --git a/ui/packages/@quent/components/src/stat-card/MultiStatStackedBarChart.tsx b/ui/packages/@quent/components/src/stat-card/MultiStatStackedBarChart.tsx index dea0f6ea..2584589b 100644 --- a/ui/packages/@quent/components/src/stat-card/MultiStatStackedBarChart.tsx +++ b/ui/packages/@quent/components/src/stat-card/MultiStatStackedBarChart.tsx @@ -8,7 +8,11 @@ import { Button } from '../ui/button'; import { DataText } from '../ui/data-text'; import { Input } from '../ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -import { StatisticMiniBarChart, type StatisticMiniBarChartRow } from './StatisticCard'; +import { + StatisticMiniBarChart, + type StatisticMiniBarChartProps, + type StatisticMiniBarChartRow, +} from './StatisticCard'; export interface MultiStatStackedBarChartProps { rows: StatisticMiniBarChartRow[]; @@ -21,6 +25,11 @@ export interface MultiStatStackedBarChartProps { className?: string; chartClassName?: string; maxRows?: number; + formatValue?: StatisticMiniBarChartProps['formatValue']; + formatDeltaValue?: StatisticMiniBarChartProps['formatDeltaValue']; + formatPercentDelta?: StatisticMiniBarChartProps['formatPercentDelta']; + getRelativeValueStyle?: StatisticMiniBarChartProps['getRelativeValueStyle']; + tooltipTitleSuffix?: StatisticMiniBarChartProps['tooltipTitleSuffix']; } function MultiStatSelect({ @@ -130,6 +139,11 @@ export function MultiStatStackedBarChart({ className, chartClassName, maxRows, + formatValue, + formatDeltaValue, + formatPercentDelta, + getRelativeValueStyle, + tooltipTitleSuffix = selectedStat, }: MultiStatStackedBarChartProps) { return (
@@ -144,6 +158,11 @@ export function MultiStatStackedBarChart({
diff --git a/ui/packages/@quent/components/src/stat-card/NumberComparisonCard.tsx b/ui/packages/@quent/components/src/stat-card/NumberComparisonCard.tsx new file mode 100644 index 00000000..ff13c993 --- /dev/null +++ b/ui/packages/@quent/components/src/stat-card/NumberComparisonCard.tsx @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CSSProperties, ReactNode } from 'react'; +import { StatisticCard, type StatisticCardComparison } from './StatisticCard'; + +export interface NumberComparisonCardProps { + title: ReactNode; + baselineLabel: ReactNode; + comparisonLabel: ReactNode; + baselineValue: number; + comparisonValue: number; + deltaValue: number; + percentDelta?: number | null; + baselineColor?: string; + comparisonColor?: string; + formatValue: (value: number) => ReactNode; + formatDeltaValue: (value: number) => ReactNode; + formatPercentDelta?: (value: number | null) => ReactNode; + valueStyle?: CSSProperties; + comparisonSeparator?: ReactNode; + className?: string; +} + +export function NumberComparisonCard({ + title, + baselineLabel, + comparisonLabel, + baselineValue, + comparisonValue, + deltaValue, + percentDelta = null, + baselineColor, + comparisonColor, + formatValue, + formatDeltaValue, + formatPercentDelta, + valueStyle, + comparisonSeparator, + className, +}: NumberComparisonCardProps) { + const comparisons: StatisticCardComparison[] = [ + { + id: 'baseline', + label: baselineLabel, + value: formatValue(baselineValue), + color: baselineColor, + }, + { + id: 'comparison', + label: comparisonLabel, + value: formatValue(comparisonValue), + color: comparisonColor, + }, + ]; + + return ( + + ); +} diff --git a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx index 28d9e35d..dc732e65 100644 --- a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx +++ b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { CSSProperties, ReactNode } from 'react'; +import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@quent/utils'; import { DataText } from '../ui/data-text'; @@ -33,6 +34,15 @@ export interface StatisticMiniBarChartBar { value: number; color: string; label?: string; + details?: StatisticMiniBarChartBarDetail[]; + deltaValue?: number; + percentDelta?: number | null; +} + +export interface StatisticMiniBarChartBarDetail { + id: string; + label: ReactNode; + value: ReactNode; } export interface StatisticMiniBarChartRow { @@ -43,12 +53,44 @@ export interface StatisticMiniBarChartRow { labelColor?: string; } +export type StatisticMiniBarChartValueFormatter = ( + value: number, + bar: StatisticMiniBarChartBar, + row: StatisticMiniBarChartRow +) => ReactNode; + +export type StatisticMiniBarChartPercentDeltaFormatter = ( + percentDelta: number | null, + bar: StatisticMiniBarChartBar, + row: StatisticMiniBarChartRow +) => ReactNode; + +export type StatisticMiniBarChartRelativeValueStyle = ( + value: number | null, + bar: StatisticMiniBarChartBar, + row: StatisticMiniBarChartRow +) => CSSProperties | undefined; + export interface StatisticMiniBarChartProps { rows: StatisticMiniBarChartRow[]; maxRows?: number; className?: string; + tooltipTitleSuffix?: ReactNode; + formatValue?: StatisticMiniBarChartValueFormatter; + formatDeltaValue?: StatisticMiniBarChartValueFormatter; + formatPercentDelta?: StatisticMiniBarChartPercentDeltaFormatter; + getRelativeValueStyle?: StatisticMiniBarChartRelativeValueStyle; +} + +interface StatisticMiniBarChartHover { + row: StatisticMiniBarChartRow; + clientX: number; + clientY: number; } +const MINI_BAR_TOOLTIP_POINTER_OFFSET = 12; +const MINI_BAR_TOOLTIP_VIEWPORT_MARGIN = 4; + function valueToneClassName(tone: StatisticCardValueTone): string { switch (tone) { case 'positive': @@ -65,11 +107,204 @@ function valueBarWidth(value: number, maxValue: number): string { return `${Math.max(2, (Math.max(0, value) / maxValue) * 100)}%`; } +function defaultFormatMiniBarChartValue(value: number): ReactNode { + return value.toLocaleString(); +} + +function defaultFormatMiniBarChartDeltaValue(value: number): ReactNode { + if (value === 0 || Object.is(value, -0)) return '0'; + const formatted = Math.abs(value).toLocaleString(); + return value > 0 ? `+${formatted}` : `-${formatted}`; +} + +function defaultFormatMiniBarChartPercentDelta(percentDelta: number | null): ReactNode { + if (percentDelta === null) return '-'; + if (percentDelta === 0 || Object.is(percentDelta, -0)) return '0.0%'; + const formatted = `${Math.abs(percentDelta * 100).toFixed(1)}%`; + return percentDelta > 0 ? `+${formatted}` : `-${formatted}`; +} + +function StatisticMiniBarChartTooltip({ + row, + formatValue, + formatDeltaValue, + formatPercentDelta, + getRelativeValueStyle, + tooltipTitleSuffix, +}: { + row: StatisticMiniBarChartRow; + formatValue: StatisticMiniBarChartValueFormatter; + formatDeltaValue: StatisticMiniBarChartValueFormatter; + formatPercentDelta: StatisticMiniBarChartPercentDeltaFormatter; + getRelativeValueStyle?: StatisticMiniBarChartRelativeValueStyle; + tooltipTitleSuffix?: ReactNode; +}) { + const showComparisonColumns = row.bars.some( + bar => bar.deltaValue !== undefined || bar.percentDelta !== undefined + ); + + return ( +
+
+ {row.label} + {tooltipTitleSuffix != null && ( + + ({tooltipTitleSuffix}) + + )} +
+
+ {row.bars.map(bar => { + return ( +
+
+ + + {bar.label ?? bar.id} + + {bar.details && bar.details.length > 0 && ( +
+ {bar.details.map(detail => ( +
+ {/* {detail.label}: */} + {detail.value} +
+ ))} +
+ )} +
+ + {formatValue(bar.value, bar, row)} + + {showComparisonColumns && ( + <> + + {bar.deltaValue !== undefined + ? formatDeltaValue(bar.deltaValue, bar, row) + : null} + + + {bar.percentDelta !== undefined + ? formatPercentDelta(bar.percentDelta, bar, row) + : null} + + + )} +
+ ); + })} +
+
+ ); +} + +function PositionedStatisticMiniBarChartTooltip({ + hover, + formatValue, + formatDeltaValue, + formatPercentDelta, + getRelativeValueStyle, + tooltipTitleSuffix, +}: { + hover: StatisticMiniBarChartHover; + formatValue: StatisticMiniBarChartValueFormatter; + formatDeltaValue: StatisticMiniBarChartValueFormatter; + formatPercentDelta: StatisticMiniBarChartPercentDeltaFormatter; + getRelativeValueStyle?: StatisticMiniBarChartRelativeValueStyle; + tooltipTitleSuffix?: ReactNode; +}) { + const hostRef = useRef(null); + const [position, setPosition] = useState({ + left: hover.clientX + MINI_BAR_TOOLTIP_POINTER_OFFSET, + top: hover.clientY + MINI_BAR_TOOLTIP_POINTER_OFFSET, + }); + + useLayoutEffect(() => { + const el = hostRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let left = hover.clientX + MINI_BAR_TOOLTIP_POINTER_OFFSET; + let top = hover.clientY + MINI_BAR_TOOLTIP_POINTER_OFFSET; + + if (left + rect.width + MINI_BAR_TOOLTIP_VIEWPORT_MARGIN > viewportWidth) { + left = Math.max( + MINI_BAR_TOOLTIP_VIEWPORT_MARGIN, + hover.clientX - rect.width - MINI_BAR_TOOLTIP_POINTER_OFFSET + ); + } + + if (top + rect.height + MINI_BAR_TOOLTIP_VIEWPORT_MARGIN > viewportHeight) { + top = Math.max( + MINI_BAR_TOOLTIP_VIEWPORT_MARGIN, + hover.clientY - rect.height - MINI_BAR_TOOLTIP_POINTER_OFFSET + ); + } + + setPosition({ left, top }); + }, [hover.clientX, hover.clientY, hover.row]); + + return createPortal( +
+ +
, + document.body + ); +} + export function StatisticMiniBarChart({ rows, maxRows = 5, className, + formatValue = defaultFormatMiniBarChartValue, + formatDeltaValue = defaultFormatMiniBarChartDeltaValue, + formatPercentDelta = defaultFormatMiniBarChartPercentDelta, + getRelativeValueStyle, + tooltipTitleSuffix, }: StatisticMiniBarChartProps) { + const [hover, setHover] = useState(null); const visibleRows = rows.slice(0, maxRows); const maxValue = Math.max( ...visibleRows.flatMap(row => row.bars.map(bar => Math.max(0, bar.value))), @@ -89,18 +324,39 @@ export function StatisticMiniBarChart({ > {row.label} -
+
+ setHover({ row, clientX: event.clientX, clientY: event.clientY }) + } + onMouseMove={event => setHover({ row, clientX: event.clientX, clientY: event.clientY })} + onMouseLeave={() => setHover(null)} + > {row.bars.map(bar => (
))}
))} + {hover && ( + + )}
); } diff --git a/ui/packages/@quent/utils/src/dagTypes.ts b/ui/packages/@quent/utils/src/dagTypes.ts index d82ad48f..de270e3d 100644 --- a/ui/packages/@quent/utils/src/dagTypes.ts +++ b/ui/packages/@quent/utils/src/dagTypes.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { Value } from './types'; + // Pure-data types for DAG coloring, width configuration, and node/edge shapes. // These are kept in @quent/utils to avoid circular dependencies between // @quent/hooks (which holds DAG atoms) and @quent/components (which holds DAG rendering). @@ -50,8 +52,6 @@ export const NODE_LABEL_FIELD = { export type NodeLabelField = (typeof NODE_LABEL_FIELD)[keyof typeof NODE_LABEL_FIELD]; -export type StatValue = string | number | boolean | null | string[]; - export interface DAGNode { id: string; label: string; @@ -71,5 +71,5 @@ export interface DAGEdge { source: string; target: string; type?: 'smoothstep' | 'default' | 'straight'; - portStats?: Array<{ key: string; value: StatValue }>; // from source port + portStats?: Array<{ key: string; value: Value }>; // from source port } diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index de09bdc3..11d6bdf6 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -62,7 +62,6 @@ export type { CategoricalEdgeColoring, EdgeColoring, NodeLabelField, - StatValue, DAGNode, DAGEdge, } from './dagTypes'; diff --git a/ui/src/components/query-diff/QueryDiffLegend.tsx b/ui/src/components/query-diff/QueryDiffLegend.tsx index f7175685..36753e90 100644 --- a/ui/src/components/query-diff/QueryDiffLegend.tsx +++ b/ui/src/components/query-diff/QueryDiffLegend.tsx @@ -22,7 +22,7 @@ interface QueryDiffLegendProps { export function QueryDiffLegend({ items, - title = 'Legend', + title = '', ariaLabel = 'Query diff legend', compact = false, className, diff --git a/ui/src/components/query-diff/QueryDiffStats.tsx b/ui/src/components/query-diff/QueryDiffStats.tsx index 341766c0..ad646c45 100644 --- a/ui/src/components/query-diff/QueryDiffStats.tsx +++ b/ui/src/components/query-diff/QueryDiffStats.tsx @@ -6,9 +6,10 @@ import { Triangle } from 'lucide-react'; import type { DiffQuerySummary, QueryDiff } from '@quent/client'; import { MultiStatStackedBarChart, + NumberComparisonCard, StatisticCard, formatStatValue, - type StatisticCardComparison, + type StatisticMiniBarChartBar, type StatisticMiniBarChartRow, } from '@quent/components'; import { cn, type EntityRef, type PaletteTheme, type QueryBundle } from '@quent/utils'; @@ -57,6 +58,13 @@ function runtimeValueStyle(delta: number, paletteTheme: PaletteTheme): CSSProper return undefined; } +function relativeValueStyle( + value: number | null, + paletteTheme: PaletteTheme +): CSSProperties | undefined { + return value === null ? undefined : runtimeValueStyle(value, paletteTheme); +} + function displayDelta(delta: number): number { return delta === 0 || Object.is(delta, -0) ? 0 : -delta; } @@ -66,34 +74,13 @@ function displayPercentDelta(percentDelta: number | null): number | null { return percentDelta === 0 || Object.is(percentDelta, -0) ? 0 : -percentDelta; } -function runtimeComparisons({ - comparison, - baselineName, - comparisonName, - queryColors, -}: { - comparison: RuntimeComparison; - baselineName: string; - comparisonName: string; - queryColors: QueryDiffQueryColors; -}): StatisticCardComparison[] { - return [ - { - id: 'baseline', - label: baselineName, - value: formatDurationSeconds(comparison.a), - color: queryColors.baseline, - }, - { - id: 'comparison', - label: comparisonName, - value: formatDurationSeconds(comparison.b), - color: queryColors.comparison, - }, - ]; +function runtimeComparisonSeparator() { + return ( + + ); } -function RuntimeComparisonCard({ +function RuntimeNumberComparisonCard({ comparison, baselineName, comparisonName, @@ -110,25 +97,22 @@ function RuntimeComparisonCard({ }) { const displayedDelta = displayDelta(comparison.delta); return ( - - } + comparisonSeparator={runtimeComparisonSeparator()} /> ); } @@ -139,6 +123,29 @@ function formatOperatorChartValue(value: number, statName: string): string { : formatStatValue(value, statName); } +function formatOperatorChartDeltaValue(value: number, statName: string): string { + if (statName === 'duration_s') return formatSignedDurationSeconds(value); + if (value === 0 || Object.is(value, -0)) return formatStatValue(0, statName); + const formattedValue = formatStatValue(Math.abs(value), statName); + return value > 0 ? `+${formattedValue}` : `-${formattedValue}`; +} + +function queryLabel(query: DiffQuerySummary): string { + return query.instance_name ?? query.id; +} + +function queryTooltipDetails(query: DiffQuerySummary): StatisticMiniBarChartBar['details'] { + const engineLabel = query.engine_name ?? query.engine_id; + const queryGroupLabel = query.query_group_name ?? query.query_group_id; + + return [ + { id: 'engine', label: 'Engine', value: engineLabel }, + ...(queryGroupLabel + ? [{ id: 'query-group', label: 'Query group', value: queryGroupLabel }] + : []), + ]; +} + function resolveOperatorStat(statNames: string[], requestedStat: string): string | null { return statNames.includes(requestedStat) ? requestedStat @@ -159,7 +166,9 @@ function overviewRuntimeCardClassName(index: number, statCardCount: number): str function operatorRuntimeChartRows( comparisons: OperatorTypeRuntimeComparison[], queryColors: QueryDiffQueryColors, - statName: string + statName: string, + baselineQuery: DiffQuerySummary, + comparisonQuery: DiffQuerySummary ): StatisticMiniBarChartRow[] { return comparisons.map(comparison => ({ id: comparison.id, @@ -171,13 +180,17 @@ function operatorRuntimeChartRows( id: 'baseline', value: comparison.a, color: queryColors.baseline, - label: 'Baseline value', + label: queryLabel(baselineQuery), + details: queryTooltipDetails(baselineQuery), }, { id: 'comparison', value: comparison.b, color: queryColors.comparison, - label: 'Comparison value', + label: queryLabel(comparisonQuery), + details: queryTooltipDetails(comparisonQuery), + deltaValue: displayDelta(comparison.delta), + percentDelta: displayPercentDelta(comparison.percentDelta), }, ], })); @@ -197,7 +210,7 @@ function aggregateOperatorRuntimeChartRows({ { label: string; baselineValue: number; - comparisonBars: Array<{ id: string; value: number; color: string; label: string }>; + comparisonBars: StatisticMiniBarChartRow['bars']; } >(); @@ -223,7 +236,10 @@ function aggregateOperatorRuntimeChartRows({ id: comparison.id, value: operatorComparison.b, color: queryColors.comparison, - label: `${comparisonName} value`, + label: comparisonName, + details: queryTooltipDetails(comparison.comparisonQuery), + deltaValue: displayDelta(operatorComparison.delta), + percentDelta: displayPercentDelta(operatorComparison.percentDelta), }); rowsByOperatorType.set(operatorComparison.id, row); } @@ -247,7 +263,12 @@ function aggregateOperatorRuntimeChartRows({ comparisonQueryId: comparisons[0]?.comparisonQuery.id ?? '', theme: paletteTheme, }).baseline, - label: 'Baseline value', + label: comparisons[0]?.baselineQuery + ? queryLabel(comparisons[0].baselineQuery) + : 'Baseline', + details: comparisons[0]?.baselineQuery + ? queryTooltipDetails(comparisons[0].baselineQuery) + : undefined, }, ...row.comparisonBars, ], @@ -305,7 +326,7 @@ export function QueryDiffStats({ return (
- formatOperatorChartValue(value, selectedOperatorStat)} + formatDeltaValue={value => + formatOperatorChartDeltaValue(value, selectedOperatorStat) + } + formatPercentDelta={formatPercentDelta} + getRelativeValueStyle={value => relativeValueStyle(value, paletteTheme)} /> } /> @@ -394,7 +423,7 @@ export function QueryDiffOverviewStats({
{totalRuntimeComparisons.map((comparison, index) => ( - formatOperatorChartValue(value, selectedOperatorStat)} + formatDeltaValue={value => + formatOperatorChartDeltaValue(value, selectedOperatorStat) + } + formatPercentDelta={formatPercentDelta} + getRelativeValueStyle={value => relativeValueStyle(value, paletteTheme)} /> } /> From 57918efab3c8a2f8eb1371da385fb03821b25ab3 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 21 May 2026 10:16:47 -0600 Subject: [PATCH 26/33] Fix diff table group by functionality --- .../client/src/queryProfileDiffTypes.ts | 4 +- ui/packages/@quent/utils/src/dagTypes.ts | 2 + ui/packages/@quent/utils/src/index.ts | 1 + .../query-diff/QueryDiffTable.test.tsx | 59 ++++++++++++++++++- .../components/query-diff/QueryDiffTable.tsx | 1 - 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts index 7c6d5562..5a2e3eff 100644 --- a/ui/packages/@quent/client/src/queryProfileDiffTypes.ts +++ b/ui/packages/@quent/client/src/queryProfileDiffTypes.ts @@ -5,9 +5,9 @@ import type { QueryFilter, SingleTimelineRequest, SingleTimelineResponse, + StatValue, TaskFilter, TimelineConfig, - Value, } from '@quent/utils'; export interface DiffQueryRef { @@ -45,7 +45,7 @@ export interface DiffOperatorRef { } export interface DiffDelta { - stats: [Value, Value]; + stats: [StatValue, StatValue]; delta: number | null; percent_delta: number | null; } diff --git a/ui/packages/@quent/utils/src/dagTypes.ts b/ui/packages/@quent/utils/src/dagTypes.ts index de270e3d..d07017e6 100644 --- a/ui/packages/@quent/utils/src/dagTypes.ts +++ b/ui/packages/@quent/utils/src/dagTypes.ts @@ -52,6 +52,8 @@ export const NODE_LABEL_FIELD = { export type NodeLabelField = (typeof NODE_LABEL_FIELD)[keyof typeof NODE_LABEL_FIELD]; +export type StatValue = string | number | boolean | null | string[]; + export interface DAGNode { id: string; label: string; diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 11d6bdf6..de09bdc3 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -62,6 +62,7 @@ export type { CategoricalEdgeColoring, EdgeColoring, NodeLabelField, + StatValue, DAGNode, DAGEdge, } from './dagTypes'; diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index 70620efb..cd22d1a5 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -1,13 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { Provider as JotaiProvider, createStore } from 'jotai'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@/test/test-utils'; +import { ThemeProvider } from '@/contexts/ThemeContext'; import { buildQueryDiffRows, formatSignedDiffValue, formatSignedPercentDelta, getDeltaCellStyle, } from './QueryDiffTable.utils'; +import { QueryDiffTable } from './QueryDiffTable'; import { baselineDiffQueryFixture, comparisonDiffQueryFixture, @@ -15,6 +19,28 @@ import { } from '@/test/mocks/queryProfileDiffFixtures'; import { DIFF_NEGATIVE_COLOR, DIFF_POSITIVE_COLOR } from './QueryDiffColors'; +function renderQueryDiffTable() { + const store = createStore(); + return render( + + +
+ +
+
+
+ ); +} + describe('QueryDiffTable helpers', () => { it('converts matched operator diffs into pivot rows', () => { const rows = buildQueryDiffRows( @@ -82,3 +108,34 @@ describe('QueryDiffTable helpers', () => { expect(getDeltaCellStyle(null, 10)).toBeUndefined(); }); }); + +describe('QueryDiffTable', () => { + it('allows the required comparison engine group to be reordered', () => { + renderQueryDiffTable(); + + const getGroupHeaders = () => + within(screen.getByRole('table')) + .getAllByRole('columnheader') + .slice(0, 2) + .map(header => header.textContent); + + expect(getGroupHeaders()).toEqual(['Comparison Engine', 'Operator Type']); + + const dataTransfer = { + effectAllowed: '', + dropEffect: '', + setData: vi.fn(), + }; + fireEvent.dragStart(screen.getByRole('button', { name: 'Comparison Engine' }), { + dataTransfer, + }); + fireEvent.dragOver(screen.getByRole('button', { name: 'Operator Type' }), { + dataTransfer, + }); + fireEvent.drop(screen.getByRole('button', { name: 'Operator Type' }), { + dataTransfer, + }); + + expect(getGroupHeaders()).toEqual(['Operator Type', 'Comparison Engine']); + }); +}); diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index 150919ea..b40fc645 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -279,7 +279,6 @@ export function QueryDiffTable({ baselineQuery, comparisons }: QueryDiffTablePro defaultEnabled: DEFAULT_ENABLED, allStatNames, defaultStatSelector: stats => stats, - filterIndexOrder: indexOrder => ['engine', ...indexOrder.filter(key => key !== 'engine')], persistKey: 'queryDiffTable:v4', rows, getRowIndexId: (row, key) => DIFF_TABLE_SCHEMA.groups[key].id(row), From 9b2d133ec91e63a8e84b648100eb0ed727268b15 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 21 May 2026 10:29:49 -0600 Subject: [PATCH 27/33] Update default group by setup --- .../query-diff/QueryDiffTable.test.tsx | 59 +++++++++++++++++-- .../components/query-diff/QueryDiffTable.tsx | 17 ++++-- .../query-diff/QueryDiffTable.utils.ts | 12 ++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/ui/src/components/query-diff/QueryDiffTable.test.tsx b/ui/src/components/query-diff/QueryDiffTable.test.tsx index cd22d1a5..988fd80f 100644 --- a/ui/src/components/query-diff/QueryDiffTable.test.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.test.tsx @@ -54,6 +54,8 @@ describe('QueryDiffTable helpers', () => { engineGroupId: 'engine-b', engineGroupLabel: 'Engine B', engines: [{ id: 'engine-b', label: 'Engine B' }], + queryGroupId: 'group-2', + queryGroupLabel: 'Group 2', operatorType: 'Scan', operatorLabel: 'Scan orders <-> Scan orders\nscan-a <-> scan-b', operatorPairId: 'query-b:scan-a:scan-b', @@ -110,7 +112,52 @@ describe('QueryDiffTable helpers', () => { }); describe('QueryDiffTable', () => { - it('allows the required comparison engine group to be reordered', () => { + it('orders group-by options and selects operator type plus comparison engine by default', () => { + renderQueryDiffTable(); + + const groupByToolbar = screen.getByText('Group by:').parentElement!; + const groupButtonLabels = within(groupByToolbar) + .getAllByRole('button') + .map(button => button.textContent) + .filter(label => + ['Operator Type', 'Engine', 'Query Group', 'Operator Pair'].includes(label ?? '') + ); + expect(groupButtonLabels).toEqual(['Operator Type', 'Engine', 'Query Group', 'Operator Pair']); + + const defaultGroupHeaders = within(screen.getByRole('table')) + .getAllByRole('columnheader') + .slice(0, 2) + .map(header => header.textContent); + expect(defaultGroupHeaders).toEqual(['Operator Type', 'Engine']); + }); + + it('offers query group name as a group-by column', () => { + renderQueryDiffTable(); + + fireEvent.click(screen.getByRole('button', { name: 'Query Group' })); + + const groupHeaders = within(screen.getByRole('table')) + .getAllByRole('columnheader') + .slice(0, 3) + .map(header => header.textContent); + + expect(groupHeaders).toEqual(['Operator Type', 'Engine', 'Query Group']); + }); + + it('allows comparison engine to be disabled as a group-by column', () => { + renderQueryDiffTable(); + + const engineButton = screen.getByRole('button', { name: 'Engine' }); + expect(engineButton).not.toHaveAttribute('aria-disabled'); + + fireEvent.click(engineButton); + + const table = within(screen.getByRole('table')); + expect(table.queryByRole('columnheader', { name: 'Engine' })).not.toBeInTheDocument(); + expect(table.getAllByRole('columnheader')[0]).toHaveTextContent('Operator Type'); + }); + + it('allows comparison engine group to be reordered', () => { renderQueryDiffTable(); const getGroupHeaders = () => @@ -119,23 +166,23 @@ describe('QueryDiffTable', () => { .slice(0, 2) .map(header => header.textContent); - expect(getGroupHeaders()).toEqual(['Comparison Engine', 'Operator Type']); + expect(getGroupHeaders()).toEqual(['Operator Type', 'Engine']); const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn(), }; - fireEvent.dragStart(screen.getByRole('button', { name: 'Comparison Engine' }), { + fireEvent.dragStart(screen.getByRole('button', { name: 'Operator Type' }), { dataTransfer, }); - fireEvent.dragOver(screen.getByRole('button', { name: 'Operator Type' }), { + fireEvent.dragOver(screen.getByRole('button', { name: 'Engine' }), { dataTransfer, }); - fireEvent.drop(screen.getByRole('button', { name: 'Operator Type' }), { + fireEvent.drop(screen.getByRole('button', { name: 'Engine' }), { dataTransfer, }); - expect(getGroupHeaders()).toEqual(['Operator Type', 'Comparison Engine']); + expect(getGroupHeaders()).toEqual(['Engine', 'Operator Type']); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTable.tsx b/ui/src/components/query-diff/QueryDiffTable.tsx index b40fc645..b5bbebe1 100644 --- a/ui/src/components/query-diff/QueryDiffTable.tsx +++ b/ui/src/components/query-diff/QueryDiffTable.tsx @@ -31,7 +31,7 @@ import { type QueryDiffTableRow, } from './QueryDiffTable.utils'; -type IndexKey = 'engine' | 'operator_type' | 'operator'; +type IndexKey = 'engine' | 'query_group' | 'operator_type' | 'operator'; const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { groups: { @@ -39,6 +39,10 @@ const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { id: row => row.engineGroupId, label: row => row.engineGroupLabel, }, + query_group: { + id: row => row.queryGroupId, + label: row => row.queryGroupLabel, + }, operator_type: { id: row => row.operatorType, }, @@ -53,10 +57,11 @@ const DIFF_TABLE_SCHEMA: PivotedStatTableSchema = { stats: row => row.stats, }; -const INDEX_ORDER: IndexKey[] = ['engine', 'operator_type', 'operator']; +const INDEX_ORDER: IndexKey[] = ['operator_type', 'engine', 'query_group', 'operator']; const DEFAULT_ENABLED: Record = { engine: true, + query_group: false, operator_type: true, operator: false, }; @@ -279,14 +284,15 @@ export function QueryDiffTable({ baselineQuery, comparisons }: QueryDiffTablePro defaultEnabled: DEFAULT_ENABLED, allStatNames, defaultStatSelector: stats => stats, - persistKey: 'queryDiffTable:v4', + persistKey: 'queryDiffTable:v6', rows, getRowIndexId: (row, key) => DIFF_TABLE_SCHEMA.groups[key].id(row), }); const indexLabels: Record = useMemo( () => ({ - engine: 'Comparison Engine', + engine: 'Engine', + query_group: 'Query Group', operator_type: 'Operator Type', operator: 'Operator Pair', }), @@ -298,8 +304,7 @@ export function QueryDiffTable({ baselineQuery, comparisons }: QueryDiffTablePro visibleIndexOrder.map(key => ({ key, label: indexLabels[key], - enabled: key === 'engine' || enabledIndices[key], - locked: key === 'engine', + enabled: enabledIndices[key], })), [enabledIndices, indexLabels, visibleIndexOrder] ); diff --git a/ui/src/components/query-diff/QueryDiffTable.utils.ts b/ui/src/components/query-diff/QueryDiffTable.utils.ts index 564405a1..45f09860 100644 --- a/ui/src/components/query-diff/QueryDiffTable.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTable.utils.ts @@ -22,6 +22,8 @@ export interface QueryDiffTableRow { engineGroupId: string; engineGroupLabel: string; engines: QueryDiffTableEngine[]; + queryGroupId: string; + queryGroupLabel: string; operatorType: string; operatorLabel: string; operatorPairId: string; @@ -40,6 +42,13 @@ function getQueryEngine(query: DiffQuerySummary): QueryDiffTableEngine { }; } +function getQueryGroup(query: DiffQuerySummary): { id: string; label: string } { + return { + id: query.query_group_id ?? query.query_group_name ?? '__no_query_group__', + label: query.query_group_name ?? query.query_group_id ?? 'No Query Group', + }; +} + function displayDeltaValue(value: StatValue): StatValue { if (typeof value !== 'number') return value; return value === 0 || Object.is(value, -0) ? 0 : -value; @@ -79,6 +88,7 @@ export function buildQueryDiffRows( const engines = [comparisonEngine]; const engineGroupId = comparisonEngine.id; const engineGroupLabel = comparisonEngine.label; + const queryGroup = getQueryGroup(comparisonQuery); return (diff.operator_diffs ?? []).flatMap(entry => { const [operatorA, operatorB] = entry.operators; @@ -102,6 +112,8 @@ export function buildQueryDiffRows( engineGroupId, engineGroupLabel, engines, + queryGroupId: queryGroup.id, + queryGroupLabel: queryGroup.label, operatorType, operatorLabel, operatorPairId: `${comparisonId}:${operatorA.id}:${operatorB.id}`, From b6e5b2682cbe7c29fe3a5952133ff876ead07658 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 21 May 2026 10:33:13 -0600 Subject: [PATCH 28/33] Fix unit test --- ui/src/routes/diff.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index 2a836b2b..4748e37b 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -260,7 +260,7 @@ describe('Diff routes', () => { expect(overviewTabs).toHaveLength(1); expect(screen.getAllByText('2 comparison queries').length).toBeGreaterThan(0); expect(screen.getAllByText('Total Run Time')).toHaveLength(2); - expect(screen.getByText('Operator Run Time')).toBeInTheDocument(); + expect(screen.getByText('Operator Summary')).toBeInTheDocument(); const legend = screen.getByRole('group', { name: 'Query diff legend' }); expect(within(legend).getByText('Baseline')).toBeInTheDocument(); From 0e1324deb1390a2bbf2c483a78ae3f7030879e67 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 21 May 2026 11:07:18 -0600 Subject: [PATCH 29/33] Heatmap implementation, line up axes --- .../query-diff/QueryDiffTimeline.tsx | 341 ++++++++++-- .../QueryDiffTimeline.utils.test.ts | 26 +- .../query-diff/QueryDiffTimeline.utils.ts | 80 +++ .../query-diff/QueryDiffTimelineHeatmap.tsx | 517 ++++++++++++++++++ ui/src/routes/diff.test.tsx | 8 + 5 files changed, 922 insertions(+), 50 deletions(-) create mode 100644 ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 430f5ae3..5ba86a20 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useEffect, useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { Triangle } from 'lucide-react'; import { DEFAULT_STALE_TIME, @@ -14,6 +14,7 @@ import { } from '@quent/client'; import { buildBinnedTimelineSeries, + Button, DataText, Select, SelectContent, @@ -34,6 +35,7 @@ import { formatDuration, type EntityRef, type EntityRefKey, + type PaletteTheme, type QueryBundle, type QueryFilter, type SingleTimelineRequest, @@ -45,7 +47,11 @@ import { getDiffPositiveColor, getQueryDiffQueryColors, } from './QueryDiffColors'; -import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; +import { + QueryDiffTimelineHeatmap, + type QueryDiffTimelineHeatmapRow, +} from './QueryDiffTimelineHeatmap'; +import { buildDiffHeatmapRowData, buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { baselineEngineId: string; @@ -80,6 +86,7 @@ const TIMELINE_ROW_HEIGHT = 55; const TIMELINE_START = 0n; const COMPACT_SELECT_TRIGGER_CLASS = 'h-7 min-w-36 rounded px-2 py-1 text-xs'; const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; +type QueryDiffTimelineView = 'overlay' | 'heatmap'; function getTimelineTarget(bundle: QueryBundle): TimelineTarget | null { if (!('ResourceGroup' in bundle.resource_tree)) return null; @@ -156,6 +163,53 @@ function buildRootTimelineRequest({ }; } +function buildPairTimelineDiffRequest({ + baselineEngineId, + baselineQueryId, + baselineTarget, + comparisonEngineId, + comparisonQueryId, + comparisonTarget, + resourceType, + durationSeconds, +}: { + baselineEngineId: string; + baselineQueryId: string; + baselineTarget: TimelineTarget | null; + comparisonEngineId: string; + comparisonQueryId: string; + comparisonTarget: TimelineTarget | null; + resourceType: string; + durationSeconds: number; +}): DiffTimelineRequest | null { + if (!baselineTarget || !comparisonTarget || !resourceType || durationSeconds <= 0) return null; + + const baselineRequest = buildRootTimelineRequest({ + queryId: baselineQueryId, + rootResourceGroupId: baselineTarget.rootResourceGroupId, + resourceTypeName: resourceType, + durationSeconds, + }); + const comparisonRequest = buildRootTimelineRequest({ + queryId: comparisonQueryId, + rootResourceGroupId: comparisonTarget.rootResourceGroupId, + resourceTypeName: resourceType, + durationSeconds, + }); + + return { + timelines: [ + { engine_id: baselineEngineId, timeline: baselineRequest }, + { engine_id: comparisonEngineId, timeline: comparisonRequest }, + ], + delta_config: { + num_bins: getAdaptiveNumBins(), + start: 0, + end: durationSeconds, + }, + }; +} + function TimelineLane({ label, detail, @@ -239,53 +293,30 @@ function QueryDiffTimelinePairRows({ [baselineQueryId, comparison.comparisonQuery.id, comparison.comparisonIndex, paletteTheme] ); - const baselineRequest = useMemo(() => { - if (!baselineTarget || !resourceType || !canRenderResourceType) return null; - return buildRootTimelineRequest({ - queryId: baselineQueryId, - rootResourceGroupId: baselineTarget.rootResourceGroupId, - resourceTypeName: resourceType, - durationSeconds, - }); - }, [baselineQueryId, baselineTarget, canRenderResourceType, durationSeconds, resourceType]); - - const comparisonRequest = useMemo(() => { - if (!comparisonTarget || !resourceType || !canRenderResourceType) return null; - return buildRootTimelineRequest({ - queryId: comparison.comparisonBundle.query_id, - rootResourceGroupId: comparisonTarget.rootResourceGroupId, - resourceTypeName: resourceType, + const timelineDiffRequest = useMemo(() => { + if (!canRenderResourceType) return null; + return buildPairTimelineDiffRequest({ + baselineEngineId, + baselineQueryId, + baselineTarget, + comparisonEngineId: comparison.comparisonEngineId, + comparisonQueryId: comparison.comparisonBundle.query_id, + comparisonTarget, + resourceType, durationSeconds, }); }, [ - comparison.comparisonBundle.query_id, + baselineEngineId, + baselineQueryId, + baselineTarget, canRenderResourceType, + comparison.comparisonEngineId, + comparison.comparisonBundle.query_id, comparisonTarget, durationSeconds, resourceType, ]); - const timelineDiffRequest = useMemo(() => { - if (!baselineRequest || !comparisonRequest || durationSeconds <= 0) return null; - return { - timelines: [ - { engine_id: baselineEngineId, timeline: baselineRequest }, - { engine_id: comparison.comparisonEngineId, timeline: comparisonRequest }, - ], - delta_config: { - num_bins: getAdaptiveNumBins(), - start: 0, - end: durationSeconds, - }, - }; - }, [ - baselineEngineId, - baselineRequest, - comparison.comparisonEngineId, - comparisonRequest, - durationSeconds, - ]); - const timelineDiff = useQuery({ queryKey: [ 'queryDiffTimelineListPair', @@ -376,6 +407,176 @@ function QueryDiffTimelinePairRows({ ); } +function QueryDiffTimelineHeatmapRows({ + baselineEngineId, + baselineQueryId, + baselineBundle, + baselineTarget, + comparisons, + resourceType, + durationSeconds, + fallbackTimestamps, + isDark, + paletteTheme, + positiveColor, + negativeColor, +}: { + baselineEngineId: string; + baselineQueryId: string; + baselineBundle: QueryBundle; + baselineTarget: TimelineTarget | null; + comparisons: QueryDiffTimelineListComparison[]; + resourceType: string; + durationSeconds: number; + fallbackTimestamps: number[]; + isDark: boolean; + paletteTheme: PaletteTheme; + positiveColor: string; + negativeColor: string; +}) { + const comparisonTargets = useMemo( + () => comparisons.map(comparison => getTimelineTarget(comparison.comparisonBundle)), + [comparisons] + ); + const requests = useMemo( + () => + comparisons.map((comparison, index) => { + const comparisonTarget = comparisonTargets[index] ?? null; + if (!comparisonTarget?.resourceTypes.includes(resourceType)) return null; + return buildPairTimelineDiffRequest({ + baselineEngineId, + baselineQueryId, + baselineTarget, + comparisonEngineId: comparison.comparisonEngineId, + comparisonQueryId: comparison.comparisonBundle.query_id, + comparisonTarget, + resourceType, + durationSeconds, + }); + }), + [ + baselineEngineId, + baselineQueryId, + baselineTarget, + comparisonTargets, + comparisons, + durationSeconds, + resourceType, + ] + ); + + const timelineDiffQueries = useQueries({ + queries: comparisons.map((comparison, index) => ({ + queryKey: [ + 'queryDiffTimelineHeatmapPair', + baselineEngineId, + baselineQueryId, + comparison.comparisonEngineId, + comparison.comparisonQuery.id, + baselineTarget?.rootResourceGroupId, + comparisonTargets[index]?.rootResourceGroupId, + requests[index], + ], + queryFn: () => fetchQueryProfileDiffTimeline(requests[index]!), + enabled: Boolean(requests[index]), + staleTime: DEFAULT_STALE_TIME, + })), + }); + + const activeRequestCount = requests.filter(Boolean).length; + const isLoading = timelineDiffQueries.some((query, index) => requests[index] && query.isLoading); + const hasError = timelineDiffQueries.some((query, index) => requests[index] && query.isError); + + if (activeRequestCount > 0 && isLoading) { + return ( +
+ Loading timeline heatmap... +
+ ); + } + + if (hasError) { + return ( +
+ Failed to load timeline heatmap +
+ ); + } + + const rows = comparisons.map((comparison, index): QueryDiffTimelineHeatmapRow => { + const comparisonTarget = comparisonTargets[index] ?? null; + const queryColors = getQueryDiffQueryColors({ + baselineQueryId, + comparisonQueryId: comparison.comparisonQuery.id, + comparisonIndex: comparison.comparisonIndex, + theme: paletteTheme, + }); + const baseRow = { + id: comparison.id, + label: querySummaryLabel(comparison.comparisonQuery), + detail: `Comparison ${comparison.comparisonIndex + 1}`, + color: queryColors.comparison, + }; + + if (!comparisonTarget?.resourceTypes.includes(resourceType)) { + return { + ...baseRow, + timestamps: fallbackTimestamps, + baselineValues: [], + comparisonValues: [], + signedDeltaValues: [], + relativeValues: [], + colorValues: [], + formatter: value => String(value), + disabledMessage: 'No shared resource type', + }; + } + + const timelineDiff = timelineDiffQueries[index]?.data; + if (!timelineDiff) { + return { + ...baseRow, + timestamps: fallbackTimestamps, + baselineValues: [], + comparisonValues: [], + signedDeltaValues: [], + relativeValues: [], + colorValues: [], + formatter: value => String(value), + disabledMessage: 'No timeline data', + }; + } + + const resourceTypeDecl = + baselineBundle.entities.resource_types[resourceType] ?? + comparison.comparisonBundle.entities.resource_types[resourceType]; + const timelineData = buildDiffTimelineData({ + timelineDiff, + theme: paletteTheme, + capacities: resourceTypeDecl?.capacities, + quantitySpecs: baselineBundle.quantity_specs ?? comparison.comparisonBundle.quantity_specs, + fsmTypes: baselineBundle.entities.fsm_types ?? comparison.comparisonBundle.entities.fsm_types, + queryColors, + }); + + return { + ...baseRow, + ...buildDiffHeatmapRowData(timelineData), + }; + }); + return ( + + ); +} + export function QueryDiffTimelineList({ baselineEngineId, baselineBundle, @@ -396,6 +597,7 @@ export function QueryDiffTimelineList({ [baselineTarget, comparisonTargets] ); const [resourceType, setResourceType] = useState(''); + const [timelineView, setTimelineView] = useState('overlay'); const durationSeconds = Math.max( baselineBundle.duration_s, ...comparisons.map(comparison => comparison.comparisonBundle.duration_s) @@ -436,9 +638,9 @@ export function QueryDiffTimelineList({ queryId: baselineBundle.query_id, rootResourceGroupId: baselineTarget.rootResourceGroupId, resourceTypeName: resourceType, - durationSeconds: baselineBundle.duration_s, + durationSeconds, }); - }, [baselineBundle.duration_s, baselineBundle.query_id, baselineTarget, resourceType]); + }, [baselineBundle.query_id, baselineTarget, durationSeconds, resourceType]); const baselineTimeline = useQuery({ queryKey: [ @@ -448,8 +650,7 @@ export function QueryDiffTimelineList({ baselineTarget?.rootResourceGroupId, baselineRequest, ], - queryFn: () => - fetchSingleTimeline(baselineEngineId, baselineRequest!, baselineBundle.duration_s), + queryFn: () => fetchSingleTimeline(baselineEngineId, baselineRequest!, durationSeconds), enabled: Boolean(baselineRequest), staleTime: DEFAULT_STALE_TIME, }); @@ -496,6 +697,31 @@ export function QueryDiffTimelineList({
+
+ {(['overlay', 'heatmap'] as const).map(view => { + const isActive = timelineView === view; + return ( + + ); + })} +
{comparisons.length > 0 && (
@@ -587,17 +813,34 @@ export function QueryDiffTimelineList({ isDark={isDark} /> - {comparisons.map(comparison => ( - - ))} + ) : ( + comparisons.map(comparison => ( + + )) + )}
)}
diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts index a358518f..b3e221e8 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { SingleTimelineResponse } from '@quent/utils'; import type { DiffTimelineResponse } from '@quent/client'; import { DIFF_NEGATIVE_COLOR, DIFF_POSITIVE_COLOR } from './QueryDiffColors'; -import { buildDiffTimelineData } from './QueryDiffTimeline.utils'; +import { buildDiffHeatmapRowData, buildDiffTimelineData } from './QueryDiffTimeline.utils'; function makeTimeline(values: Record): SingleTimelineResponse { const firstValues = Object.values(values)[0] ?? []; @@ -65,4 +65,28 @@ describe('buildDiffTimelineData', () => { expect(data.delta.series['Baseline higher']?.color).toBe(DIFF_NEGATIVE_COLOR); expect(data.delta.series['Comparison higher']?.color).toBe(DIFF_POSITIVE_COLOR); }); + + it('builds capped relative values for heatmap rows', () => { + const response: DiffTimelineResponse = { + timelines: [makeTimeline({ slots: [100, 0, 50] }), makeTimeline({ slots: [50, 25, 200] })], + delta: makeTimeline({ + 'Query A higher': [50, 0, 0], + 'Query B higher': [0, 25, 150], + }), + }; + + const timelineData = buildDiffTimelineData({ + timelineDiff: response, + theme: 'light', + queryColors: { baseline: '#0072B2', comparison: '#E69F00' }, + }); + + const row = buildDiffHeatmapRowData(timelineData); + + expect(row.baselineValues).toEqual([100, 0, 50]); + expect(row.comparisonValues).toEqual([50, 25, 200]); + expect(row.signedDeltaValues).toEqual([-50, 25, 150]); + expect(row.relativeValues).toEqual([-0.5, 1, 3]); + expect(row.colorValues).toEqual([-0.5, 1, 1]); + }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index 6a750026..542eaa04 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -27,6 +27,16 @@ export interface DiffTimelineData { delta: TimelineRowData; } +export interface DiffHeatmapRowData { + timestamps: number[]; + baselineValues: number[]; + comparisonValues: number[]; + signedDeltaValues: number[]; + relativeValues: number[]; + colorValues: number[]; + formatter: (value: number, decimals?: number) => string; +} + interface BuildDiffTimelineDataParams { timelineDiff: DiffTimelineResponse; theme: PaletteTheme; @@ -135,6 +145,76 @@ function recolorTimelineSeries(series: TimelineSeries, color: string): TimelineS ); } +function clampRelativeValue(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(-1, Math.min(1, value)); +} + +function sumSeriesAtIndex(series: TimelineSeries, index: number): number { + return Object.values(series) + .filter(entry => !entry.isOverlay) + .reduce((sum, entry) => sum + (entry.values[index] ?? 0), 0); +} + +function seriesValue(series: TimelineSeries, name: string, index: number): number { + return series[name]?.values[index] ?? 0; +} + +export function buildDiffHeatmapRowData(data: DiffTimelineData): DiffHeatmapRowData { + const binCount = Math.max( + data.delta.timestamps.length, + data.baseline.timestamps.length, + data.comparison.timestamps.length + ); + const formatter = getFirstFormatter(data.baseline.series, data.comparison.series); + + const baselineValues = new Array(binCount); + const comparisonValues = new Array(binCount); + const signedDeltaValues = new Array(binCount); + const relativeValues = new Array(binCount); + const colorValues = new Array(binCount); + const hasBackendDelta = + Boolean(data.delta.series[BASELINE_HIGHER_LABEL]) || + Boolean(data.delta.series[COMPARISON_HIGHER_LABEL]); + + for (let index = 0; index < binCount; index += 1) { + const baseline = sumSeriesAtIndex(data.baseline.series, index); + const comparison = sumSeriesAtIndex(data.comparison.series, index); + const signedDelta = hasBackendDelta + ? seriesValue(data.delta.series, COMPARISON_HIGHER_LABEL, index) - + seriesValue(data.delta.series, BASELINE_HIGHER_LABEL, index) + : comparison - baseline; + + const relative = + baseline !== 0 + ? signedDelta / Math.abs(baseline) + : comparison !== 0 + ? Math.sign(signedDelta) + : 0; + + baselineValues[index] = baseline; + comparisonValues[index] = comparison; + signedDeltaValues[index] = signedDelta; + relativeValues[index] = relative; + colorValues[index] = clampRelativeValue(relative); + } + + return { + timestamps: + data.delta.timestamps.length > 0 + ? data.delta.timestamps + : data.baseline.timestamps.length > 0 + ? data.baseline.timestamps + : data.comparison.timestamps, + baselineValues, + comparisonValues, + signedDeltaValues, + relativeValues, + colorValues, + formatter, + }; +} + export function buildDiffTimelineData({ timelineDiff, theme, diff --git a/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx b/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx new file mode 100644 index 00000000..cc91a39a --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx @@ -0,0 +1,517 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useMemo, useRef } from 'react'; +import type { ReactNode } from 'react'; +import ReactEChartsComponent from 'echarts-for-react'; +import type { EChartsInstance } from 'echarts-for-react'; +import type { Opts } from 'echarts-for-react/lib/types'; +import type { CustomSeriesOption } from 'echarts/charts'; +import { + CHART_GROUP, + connectChart, + echarts, + TIMELINE_MONO_FONT, + TIMELINE_SPACING, + useTimelineEchartsTheme, + type EChartsOption, +} from '@quent/components'; +import { useZoomRange } from '@quent/hooks'; +import { cn, formatDuration, withOpacity } from '@quent/utils'; + +export interface QueryDiffTimelineHeatmapRow { + id: string; + label: string; + detail?: ReactNode; + color: string; + timestamps: number[]; + baselineValues: number[]; + comparisonValues: number[]; + signedDeltaValues: number[]; + relativeValues: number[]; + colorValues: number[]; + formatter: (value: number, decimals?: number) => string; + disabledMessage?: string; +} + +interface QueryDiffTimelineHeatmapProps { + rows: QueryDiffTimelineHeatmapRow[]; + timestamps: number[]; + rowHeight: number; + durationSeconds: number; + isDark: boolean; + positiveColor: string; + negativeColor: string; +} + +type HeatmapCellValue = [ + xStartMs: number, + xEndMs: number, + rowIndex: number, + colorValue: number, + binIndex: number, +]; + +interface RectShape { + x: number; + y: number; + width: number; + height: number; +} + +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +interface AlignedHeatmapRow extends QueryDiffTimelineHeatmapRow { + baselineValues: number[]; + comparisonValues: number[]; + signedDeltaValues: number[]; + relativeValues: number[]; + colorValues: number[]; +} + +function formatRelativePercent(value: number): string { + const percent = value * 100; + const decimals = Math.abs(percent) < 10 && percent !== 0 ? 1 : 0; + return `${percent.toFixed(decimals)}%`; +} + +function escapeTooltipText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isHeatmapCellValue(value: unknown): value is HeatmapCellValue { + return Array.isArray(value) && value.length >= 5 && value.every(item => typeof item === 'number'); +} + +function clampUnit(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(-1, Math.min(1, value)); +} + +function parseHexColor(color: string): RgbaColor { + const normalized = color.trim(); + if (!normalized.startsWith('#')) return { r: 128, g: 128, b: 128, a: 1 }; + + const hex = normalized.slice(1); + if (hex.length !== 6 && hex.length !== 8) return { r: 128, g: 128, b: 128, a: 1 }; + + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, + }; +} + +function mixColor(a: RgbaColor, b: RgbaColor, t: number): RgbaColor { + const clamped = Math.max(0, Math.min(1, t)); + return { + r: Math.round(a.r + (b.r - a.r) * clamped), + g: Math.round(a.g + (b.g - a.g) * clamped), + b: Math.round(a.b + (b.b - a.b) * clamped), + a: a.a + (b.a - a.a) * clamped, + }; +} + +function colorToCss({ r, g, b, a }: RgbaColor): string { + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function getCellColor({ + value, + negativeColor, + neutralColor, + positiveColor, +}: { + value: number; + negativeColor: string; + neutralColor: string; + positiveColor: string; +}): string { + const clamped = clampUnit(value); + const negative = parseHexColor(negativeColor); + const neutral = parseHexColor(neutralColor); + const positive = parseHexColor(positiveColor); + return colorToCss( + clamped < 0 ? mixColor(negative, neutral, clamped + 1) : mixColor(neutral, positive, clamped) + ); +} + +function clipRectByRect(rect: RectShape, bounds: RectShape): RectShape | null { + const x = Math.max(rect.x, bounds.x); + const y = Math.max(rect.y, bounds.y); + const x2 = Math.min(rect.x + rect.width, bounds.x + bounds.width); + const y2 = Math.min(rect.y + rect.height, bounds.y + bounds.height); + if (x2 <= x || y2 <= y) return null; + return { x, y, width: x2 - x, height: y2 - y }; +} + +function findBinIndexAtTime(sourceTimestamps: number[], timestamp: number): number { + if (sourceTimestamps.length <= 1) return 0; + + let lo = 0; + let hi = sourceTimestamps.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if ((sourceTimestamps[mid] ?? 0) <= timestamp) lo = mid + 1; + else hi = mid - 1; + } + + return Math.max(0, Math.min(sourceTimestamps.length - 1, hi)); +} + +function timestampsEqual(a: number[], b: number[]): boolean { + return ( + a.length === b.length && a.every((value, index) => Math.abs(value - (b[index] ?? 0)) < 0.001) + ); +} + +function inferSourceEnd(sourceTimestamps: number[]): number { + if (sourceTimestamps.length <= 1) return Number.POSITIVE_INFINITY; + const last = sourceTimestamps[sourceTimestamps.length - 1] ?? 0; + const previous = sourceTimestamps[sourceTimestamps.length - 2] ?? last; + return last + Math.max(0, last - previous); +} + +function alignValuesToTimestamps({ + values, + sourceTimestamps, + targetTimestamps, +}: { + values: number[]; + sourceTimestamps: number[]; + targetTimestamps: number[]; +}): number[] { + if (values.length === 0 || targetTimestamps.length === 0) return []; + if ( + values.length === targetTimestamps.length && + timestampsEqual(sourceTimestamps, targetTimestamps) + ) { + return values.slice(0, targetTimestamps.length); + } + + const sourceStart = sourceTimestamps[0] ?? 0; + const sourceEnd = inferSourceEnd(sourceTimestamps); + + return targetTimestamps.map(timestamp => { + if (timestamp < sourceStart || timestamp >= sourceEnd) return 0; + return values[findBinIndexAtTime(sourceTimestamps, timestamp)] ?? 0; + }); +} + +function alignRowToTimestamps( + row: QueryDiffTimelineHeatmapRow, + targetTimestamps: number[] +): AlignedHeatmapRow { + const sourceTimestamps = row.timestamps.length > 0 ? row.timestamps : targetTimestamps; + return { + ...row, + baselineValues: alignValuesToTimestamps({ + values: row.baselineValues, + sourceTimestamps, + targetTimestamps, + }), + comparisonValues: alignValuesToTimestamps({ + values: row.comparisonValues, + sourceTimestamps, + targetTimestamps, + }), + signedDeltaValues: alignValuesToTimestamps({ + values: row.signedDeltaValues, + sourceTimestamps, + targetTimestamps, + }), + relativeValues: alignValuesToTimestamps({ + values: row.relativeValues, + sourceTimestamps, + targetTimestamps, + }), + colorValues: alignValuesToTimestamps({ + values: row.colorValues, + sourceTimestamps, + targetTimestamps, + }), + }; +} + +export function QueryDiffTimelineHeatmap({ + rows, + timestamps, + rowHeight, + durationSeconds, + isDark, + positiveColor, + negativeColor, +}: QueryDiffTimelineHeatmapProps) { + const { themeName, axisLabelColor, labelBackgroundColor } = useTimelineEchartsTheme(isDark); + const neutralColor = withOpacity(axisLabelColor, isDark ? 0.2 : 0.16); + const chartHeight = Math.max(rowHeight, rows.length * rowHeight); + const xAxisMin = timestamps[0] ?? 0; + const xAxisMax = Math.max( + xAxisMin + durationSeconds * 1_000, + timestamps[timestamps.length - 1] ?? xAxisMin + ); + const yCategories = useMemo(() => rows.map(row => row.id), [rows]); + const zoomRange = useZoomRange(); + const zoomRangeRef = useRef(zoomRange); + const durationSecondsRef = useRef(durationSeconds); + zoomRangeRef.current = zoomRange; + durationSecondsRef.current = durationSeconds; + + const alignedRows = useMemo( + () => rows.map(row => alignRowToTimestamps(row, timestamps)), + [rows, timestamps] + ); + + const heatmapData = useMemo( + () => + alignedRows.flatMap((row, rowIndex) => + row.disabledMessage + ? [] + : timestamps.flatMap((timestamp, binIndex) => { + const xEndMs = timestamps[binIndex + 1] ?? xAxisMax; + const value = row.colorValues[binIndex] ?? 0; + return Number.isFinite(value) + ? ([[timestamp, xEndMs, rowIndex, value, binIndex]] as HeatmapCellValue[]) + : []; + }) + ), + [alignedRows, timestamps, xAxisMax] + ); + + type RenderItem = NonNullable; + const renderItem: RenderItem = useCallback( + (params, api) => { + const xStartMs = api.value(0) as number; + const xEndMs = api.value(1) as number; + const rowIndex = api.value(2) as number; + const colorValue = api.value(3) as number; + if (xEndMs <= xStartMs) return null; + + const startPoint = api.coord([xStartMs, rowIndex]); + const endPoint = api.coord([xEndMs, rowIndex]); + const axisBandSize = api.size?.([0, 1]) as number[] | undefined; + const bandHeight = Math.max(1, axisBandSize?.[1] ?? rowHeight); + const rectShape = { + x: startPoint[0], + y: startPoint[1] - bandHeight / 2, + width: Math.max(1, endPoint[0] - startPoint[0]), + height: bandHeight, + }; + const coord = params.coordSys as { x?: number; y?: number; width?: number; height?: number }; + const clipBounds = + typeof coord.width === 'number' && typeof coord.height === 'number' + ? { + x: coord.x ?? 0, + y: coord.y ?? 0, + width: coord.width, + height: coord.height, + } + : null; + const clippedShape = clipBounds ? clipRectByRect(rectShape, clipBounds) : rectShape; + if (!clippedShape) return null; + + return { + type: 'rect' as const, + shape: clippedShape, + style: { + fill: getCellColor({ value: colorValue, negativeColor, neutralColor, positiveColor }), + lineWidth: 0, + }, + emphasis: { + style: { + stroke: axisLabelColor, + lineWidth: 1, + }, + }, + }; + }, + [axisLabelColor, negativeColor, neutralColor, positiveColor, rowHeight] + ); + + const option: EChartsOption = useMemo( + () => + ({ + animation: false, + tooltip: { + trigger: 'item', + confine: true, + backgroundColor: labelBackgroundColor, + borderColor: axisLabelColor, + textStyle: { + color: axisLabelColor, + fontFamily: TIMELINE_MONO_FONT, + fontSize: 11, + }, + formatter: (params: { data?: unknown }) => { + if (!isHeatmapCellValue(params.data)) return ''; + + const [, , rowIndex, , binIndex] = params.data; + const row = alignedRows[rowIndex]; + if (!row) return ''; + + const relative = row.relativeValues[binIndex] ?? 0; + const signedDelta = row.signedDeltaValues[binIndex] ?? 0; + const baseline = row.baselineValues[binIndex] ?? 0; + const comparison = row.comparisonValues[binIndex] ?? 0; + const timestamp = timestamps[binIndex] ?? 0; + const deltaLabel = + signedDelta > 0 + ? 'Comparison higher' + : signedDelta < 0 + ? 'Comparison lower' + : 'No change'; + + return [ + `${escapeTooltipText(row.label)}`, + `${escapeTooltipText(deltaLabel)} (${formatRelativePercent(relative)})`, + `Baseline: ${escapeTooltipText(row.formatter(baseline, 2))}`, + `Comparison: ${escapeTooltipText(row.formatter(comparison, 2))}`, + `Delta: ${escapeTooltipText(row.formatter(signedDelta, 2))}`, + `Time: ${escapeTooltipText(formatDuration(timestamp))}`, + ].join('
'); + }, + }, + grid: { + ...TIMELINE_SPACING, + top: 0, + bottom: 0, + containLabel: false, + }, + xAxis: { + type: 'time', + show: false, + min: xAxisMin, + max: xAxisMax, + boundaryGap: false, + axisPointer: { + show: true, + type: 'line', + label: { show: false }, + }, + }, + yAxis: { + type: 'category', + data: yCategories, + inverse: true, + show: false, + boundaryGap: true, + }, + dataZoom: [ + { + type: 'slider', + show: false, + realtime: true, + filterMode: 'none', + xAxisIndex: [0], + }, + { + type: 'inside', + zoomLock: true, + zoomOnMouseWheel: false, + moveOnMouseWheel: false, + throttle: 30, + filterMode: 'none', + xAxisIndex: [0], + }, + { + type: 'inside', + zoomOnMouseWheel: 'shift', + moveOnMouseMove: false, + moveOnMouseWheel: false, + throttle: 30, + filterMode: 'none', + xAxisIndex: [0], + }, + ], + series: [ + { + type: 'custom', + data: heatmapData, + renderItem: renderItem as never, + coordinateSystem: 'cartesian2d', + cursor: 'default', + }, + ], + }) as EChartsOption, + [ + alignedRows, + axisLabelColor, + heatmapData, + labelBackgroundColor, + renderItem, + timestamps, + xAxisMax, + xAxisMin, + yCategories, + ] + ); + + const handleChartReady = useCallback((instance: EChartsInstance) => { + const dur = durationSecondsRef.current; + const range = zoomRangeRef.current; + const zoomPct = + dur > 0 ? { start: (range.start / dur) * 100, end: (range.end / dur) * 100 } : null; + connectChart(instance, CHART_GROUP, false, zoomPct); + }, []); + + const opts = useMemo(() => ({ renderer: 'canvas' }) as Opts, []); + + return ( +
+
+ {rows.map((row, index) => ( +
0 && 'border-t border-border' + )} + style={{ height: rowHeight }} + > + + + {row.label} + + {row.disabledMessage ? ( + + {row.disabledMessage} + + ) : ( + row.detail && ( + {row.detail} + ) + )} +
+ ))} +
+
+ +
+
+ ); +} diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index 4748e37b..07f2f6ae 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -248,6 +248,14 @@ describe('Diff routes', () => { await user.click(await screen.findByRole('tab', { name: 'Timelines' })); expect(await screen.findByText('Timeline Delta')).toBeInTheDocument(); + const overlayButton = screen.getByRole('button', { name: 'Overlay' }); + const heatmapButton = screen.getByRole('button', { name: 'Heatmap' }); + expect(overlayButton).toHaveAttribute('aria-pressed', 'true'); + await user.click(heatmapButton); + expect(heatmapButton).toHaveAttribute('aria-pressed', 'true'); + expect(overlayButton).toHaveAttribute('aria-pressed', 'false'); + await user.click(overlayButton); + expect(overlayButton).toHaveAttribute('aria-pressed', 'true'); expect(screen.queryByText('Total Run Time')).not.toBeInTheDocument(); }); From 1399e3e9dd913caa6b55904c11d22707b56f3856 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 21 May 2026 12:33:38 -0600 Subject: [PATCH 30/33] Line up timelines, spiff up --- .../src/stat-card/StatisticCard.tsx | 2 +- .../query-diff/QueryDiffTimeline.tsx | 3 +- .../QueryDiffTimeline.utils.test.ts | 44 ++++++++- .../query-diff/QueryDiffTimeline.utils.ts | 90 ++++++++++++++++++- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx index dc732e65..32a8e2b2 100644 --- a/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx +++ b/ui/packages/@quent/components/src/stat-card/StatisticCard.tsx @@ -316,7 +316,7 @@ export function StatisticMiniBarChart({ {visibleRows.map(row => (
): SingleTimelineResponse { +function makeTimeline( + values: Record, + span: { start: number; end: number } = { + start: 0, + end: Object.values(values)[0]?.length ?? 0, + } +): SingleTimelineResponse { const firstValues = Object.values(values)[0] ?? []; const config = { - span: { start: 0, end: firstValues.length }, - bin_duration: 1, + span, + bin_duration: firstValues.length > 0 ? (span.end - span.start) / firstValues.length : 0, num_bins: BigInt(firstValues.length), }; @@ -66,6 +72,38 @@ describe('buildDiffTimelineData', () => { expect(data.delta.series['Comparison higher']?.color).toBe(DIFF_POSITIVE_COLOR); }); + it('aligns delta overlays to the comparison timeline bins', () => { + const response: DiffTimelineResponse = { + timelines: [ + makeTimeline({ slots: [100, 100, 100, 100] }, { start: 0, end: 4 }), + makeTimeline({ slots: [90, 90, 120, 120] }, { start: 0, end: 4 }), + ], + delta: makeTimeline( + { + 'Query A higher': [10, 0], + 'Query B higher': [0, 20], + }, + { start: 0, end: 4 } + ), + }; + + const data = buildDiffTimelineData({ + timelineDiff: response, + theme: 'light', + queryColors: { baseline: '#0072B2', comparison: '#E69F00' }, + }); + + expect(data.comparisonWithDelta.timestamps).toEqual(data.comparison.timestamps); + expect(data.comparisonWithDelta.series['Delta: Baseline higher']).toMatchObject({ + values: [-10, -10, 0, 0], + binDuration: 1, + }); + expect(data.comparisonWithDelta.series['Delta: Comparison higher']).toMatchObject({ + values: [0, 0, 20, 20], + binDuration: 1, + }); + }); + it('builds capped relative values for heatmap rows', () => { const response: DiffTimelineResponse = { timelines: [makeTimeline({ slots: [100, 0, 50] }), makeTimeline({ slots: [50, 25, 200] })], diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index 542eaa04..2303bc19 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -54,6 +54,80 @@ function getFirstFormatter(seriesA: TimelineSeries, seriesB: TimelineSeries) { ); } +function getFirstBinDuration(series: TimelineSeries): number | null { + return Object.values(series).find(entry => entry.values.length > 0)?.binDuration ?? null; +} + +function inferBinDurationSeconds(timestamps: number[]): number | null { + if (timestamps.length <= 1) return null; + const first = timestamps[0] ?? 0; + const second = timestamps[1] ?? first; + const durationMs = second - first; + return durationMs > 0 ? durationMs / 1_000 : null; +} + +function timestampsEqual(a: number[], b: number[]): boolean { + return ( + a.length === b.length && a.every((value, index) => Math.abs(value - (b[index] ?? 0)) < 0.001) + ); +} + +function inferTimelineEnd(timestamps: number[], binDurationSeconds: number | null): number { + const last = timestamps[timestamps.length - 1]; + if (last == null) return Number.POSITIVE_INFINITY; + + if (binDurationSeconds != null && binDurationSeconds > 0) { + return last + binDurationSeconds * 1_000; + } + + if (timestamps.length <= 1) return Number.POSITIVE_INFINITY; + + const previous = timestamps[timestamps.length - 2] ?? last; + return last + Math.max(0, last - previous); +} + +function findBinIndexAtTime(sourceTimestamps: number[], timestamp: number): number { + if (sourceTimestamps.length <= 1) return 0; + + let lo = 0; + let hi = sourceTimestamps.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if ((sourceTimestamps[mid] ?? 0) <= timestamp) lo = mid + 1; + else hi = mid - 1; + } + + return Math.max(0, Math.min(sourceTimestamps.length - 1, hi)); +} + +function alignValuesToTimestamps({ + values, + sourceTimestamps, + targetTimestamps, + sourceBinDuration, +}: { + values: number[]; + sourceTimestamps: number[]; + targetTimestamps: number[]; + sourceBinDuration: number | null; +}): number[] { + if (values.length === 0 || targetTimestamps.length === 0) return []; + if ( + values.length === targetTimestamps.length && + timestampsEqual(sourceTimestamps, targetTimestamps) + ) { + return values.slice(0, targetTimestamps.length); + } + + const sourceStart = sourceTimestamps[0] ?? 0; + const sourceEnd = inferTimelineEnd(sourceTimestamps, sourceBinDuration); + + return targetTimestamps.map(timestamp => { + if (timestamp < sourceStart || timestamp >= sourceEnd) return 0; + return values[findBinIndexAtTime(sourceTimestamps, timestamp)] ?? 0; + }); +} + function formatDeltaSeries({ delta, baseline, @@ -107,6 +181,13 @@ function buildSignedDeltaOverlaySeries({ const formatter = getFirstFormatter(baseline.series, comparison.series); const positiveColor = getDiffPositiveColor(theme); const negativeColor = getDiffNegativeColor(theme); + const targetTimestamps = + comparison.timestamps.length > 0 ? comparison.timestamps : delta.timestamps; + const targetBinDuration = + getFirstBinDuration(comparison.series) ?? + inferBinDurationSeconds(targetTimestamps) ?? + getFirstBinDuration(delta.series) ?? + 0; return Object.fromEntries( Object.entries(delta.series).flatMap(([name, entry]) => { @@ -116,16 +197,23 @@ function buildSignedDeltaOverlaySeries({ const displayName = baselineHigher ? BASELINE_HIGHER_LABEL : COMPARISON_HIGHER_LABEL; const sign = baselineHigher ? -1 : 1; + const alignedValues = alignValuesToTimestamps({ + values: entry.values, + sourceTimestamps: delta.timestamps, + targetTimestamps, + sourceBinDuration: entry.binDuration, + }); return [ [ `Delta: ${displayName}`, { ...entry, + binDuration: targetBinDuration, color: baselineHigher ? negativeColor : positiveColor, formatter, isOverlay: true, renderType: 'bar' as const, - values: entry.values.map(value => (value === 0 ? 0 : sign * Math.abs(value))), + values: alignedValues.map(value => (value === 0 ? 0 : sign * Math.abs(value))), }, ], ]; From 24811f693006b76ad1832cfe1ed2e3e988f670f8 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Fri, 22 May 2026 09:22:06 -0600 Subject: [PATCH 31/33] Red/Blue scale for color blind safety --- .../query-diff/QueryDiffColors.test.ts | 39 ++++++++++++++-- .../components/query-diff/QueryDiffColors.ts | 46 ++++++++++++++----- .../query-diff/QueryDiffTimeline.tsx | 6 +++ .../query-diff/QueryDiffTimelineHeatmap.tsx | 43 +++++++++-------- 4 files changed, 102 insertions(+), 32 deletions(-) diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts index f86b94b8..1770065f 100644 --- a/ui/src/components/query-diff/QueryDiffColors.test.ts +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -4,17 +4,50 @@ import { afterEach, describe, expect, it } from 'vitest'; import { resetColorAssignments } from '@quent/utils'; import { + DIFF_DIVERGING_COLORS, + DIFF_DIVERGING_COLORS_DARK, DIFF_NEGATIVE_COLOR, DIFF_POSITIVE_COLOR, + getDiffDivergingColors, + getDiffNegativeColor, + getDiffPositiveColor, getQueryDiffQueryColors, } from './QueryDiffColors'; describe('QueryDiffColors', () => { afterEach(() => resetColorAssignments()); - it('uses Tol red for positive values and Tol green for negative values', () => { - expect(DIFF_POSITIVE_COLOR).toBe('#CC6677'); - expect(DIFF_NEGATIVE_COLOR).toBe('#44AA99'); + it('uses Tol BuRd for diverging diff values', () => { + expect(DIFF_DIVERGING_COLORS).toEqual([ + '#2166AC', + '#4393C3', + '#92C5DE', + '#D1E5F0', + '#F7F7F7', + '#FDDBC7', + '#F4A582', + '#D6604D', + '#B2182B', + ]); + expect(DIFF_POSITIVE_COLOR).toBe('#B2182B'); + expect(DIFF_NEGATIVE_COLOR).toBe('#2166AC'); + }); + + it('uses a dark BuRd variant with the card color at the center', () => { + expect(DIFF_DIVERGING_COLORS_DARK).toEqual([ + '#92C5DE', + '#4393C3', + '#2166AC', + '#0B2F4A', + '#020817', + '#4A1218', + '#B2182B', + '#D6604D', + '#F4A582', + ]); + expect(getDiffDivergingColors('dark')[4]).toBe('#020817'); + expect(getDiffPositiveColor('dark')).toBe('#F4A582'); + expect(getDiffNegativeColor('dark')).toBe('#92C5DE'); }); it('assigns distinct palette colors to the compared queries', () => { diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index d761f8c5..4e2eb79f 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -1,24 +1,48 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - getColorByIndex, - getOperationTypeColor, - getPalette, - type PaletteTheme, -} from '@quent/utils'; - -const TOL_GREEN_INDEX = 0; -const TOL_RED_INDEX = 1; +import { getColorByIndex, getOperationTypeColor, type PaletteTheme } from '@quent/utils'; + +export const DIFF_DIVERGING_COLORS_LIGHT = [ + '#2166AC', + '#4393C3', + '#92C5DE', + '#D1E5F0', + '#F7F7F7', + '#FDDBC7', + '#F4A582', + '#D6604D', + '#B2182B', +] as const; + +export const DIFF_DIVERGING_COLORS_DARK = [ + '#92C5DE', + '#4393C3', + '#2166AC', + '#0B2F4A', + '#020817', + '#4A1218', + '#B2182B', + '#D6604D', + '#F4A582', +] as const; + +export const DIFF_DIVERGING_COLORS = DIFF_DIVERGING_COLORS_LIGHT; + const BASELINE_QUERY_COLOR_INDEX = 5; const COMPARISON_QUERY_COLOR_INDICES = [4, 6, 2, 3, 7, 8, 9, 10]; +export function getDiffDivergingColors(theme: PaletteTheme): readonly string[] { + return theme === 'dark' ? DIFF_DIVERGING_COLORS_DARK : DIFF_DIVERGING_COLORS_LIGHT; +} + export function getDiffPositiveColor(theme: PaletteTheme): string { - return getPalette('extended', theme)[TOL_RED_INDEX]!; + const colors = getDiffDivergingColors(theme); + return colors[colors.length - 1]!; } export function getDiffNegativeColor(theme: PaletteTheme): string { - return getPalette('extended', theme)[TOL_GREEN_INDEX]!; + return getDiffDivergingColors(theme)[0]!; } export const DIFF_POSITIVE_COLOR = getDiffPositiveColor('light'); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 2efb8751..0398dd89 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -43,6 +43,7 @@ import { } from '@quent/utils'; import { THEME_DARK, useTheme } from '@/contexts/ThemeContext'; import { + getDiffDivergingColors, getDiffNegativeColor, getDiffPositiveColor, getQueryDiffQueryColors, @@ -419,6 +420,7 @@ function QueryDiffTimelineHeatmapRows({ fallbackTimestamps, isDark, paletteTheme, + colorScheme, positiveColor, negativeColor, }: { @@ -432,6 +434,7 @@ function QueryDiffTimelineHeatmapRows({ fallbackTimestamps: number[]; isDark: boolean; paletteTheme: PaletteTheme; + colorScheme: readonly string[]; positiveColor: string; negativeColor: string; }) { @@ -572,6 +575,7 @@ function QueryDiffTimelineHeatmapRows({ rowHeight={HEATMAP_ROW_HEIGHT} durationSeconds={durationSeconds} isDark={isDark} + colorScheme={colorScheme} positiveColor={positiveColor} negativeColor={negativeColor} /> @@ -617,6 +621,7 @@ export function QueryDiffTimelineList({ ); const diffPositiveColor = getDiffPositiveColor(paletteTheme); const diffNegativeColor = getDiffNegativeColor(paletteTheme); + const diffColorScheme = getDiffDivergingColors(paletteTheme); useEffect(() => { if (sharedResourceTypes.length === 0) { @@ -826,6 +831,7 @@ export function QueryDiffTimelineList({ fallbackTimestamps={baselineTimelineData.timestamps} isDark={isDark} paletteTheme={paletteTheme} + colorScheme={diffColorScheme} positiveColor={diffPositiveColor} negativeColor={diffNegativeColor} /> diff --git a/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx b/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx index cc91a39a..901489ee 100644 --- a/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx +++ b/ui/src/components/query-diff/QueryDiffTimelineHeatmap.tsx @@ -17,7 +17,7 @@ import { type EChartsOption, } from '@quent/components'; import { useZoomRange } from '@quent/hooks'; -import { cn, formatDuration, withOpacity } from '@quent/utils'; +import { cn, formatDuration } from '@quent/utils'; export interface QueryDiffTimelineHeatmapRow { id: string; @@ -40,6 +40,7 @@ interface QueryDiffTimelineHeatmapProps { rowHeight: number; durationSeconds: number; isDark: boolean; + colorScheme: readonly string[]; positiveColor: string; negativeColor: string; } @@ -129,22 +130,21 @@ function colorToCss({ r, g, b, a }: RgbaColor): string { function getCellColor({ value, - negativeColor, - neutralColor, - positiveColor, + colorScheme, }: { value: number; - negativeColor: string; - neutralColor: string; - positiveColor: string; + colorScheme: readonly string[]; }): string { + if (colorScheme.length === 0) return 'rgba(128, 128, 128, 1)'; + if (colorScheme.length === 1) return colorToCss(parseHexColor(colorScheme[0]!)); + const clamped = clampUnit(value); - const negative = parseHexColor(negativeColor); - const neutral = parseHexColor(neutralColor); - const positive = parseHexColor(positiveColor); - return colorToCss( - clamped < 0 ? mixColor(negative, neutral, clamped + 1) : mixColor(neutral, positive, clamped) - ); + const scaled = ((clamped + 1) / 2) * (colorScheme.length - 1); + const lowerIndex = Math.floor(scaled); + const upperIndex = Math.ceil(scaled); + const lower = parseHexColor(colorScheme[lowerIndex]!); + const upper = parseHexColor(colorScheme[upperIndex]!); + return colorToCss(mixColor(lower, upper, scaled - lowerIndex)); } function clipRectByRect(rect: RectShape, bounds: RectShape): RectShape | null { @@ -250,11 +250,11 @@ export function QueryDiffTimelineHeatmap({ rowHeight, durationSeconds, isDark, + colorScheme, positiveColor, negativeColor, }: QueryDiffTimelineHeatmapProps) { const { themeName, axisLabelColor, labelBackgroundColor } = useTimelineEchartsTheme(isDark); - const neutralColor = withOpacity(axisLabelColor, isDark ? 0.2 : 0.16); const chartHeight = Math.max(rowHeight, rows.length * rowHeight); const xAxisMin = timestamps[0] ?? 0; const xAxisMax = Math.max( @@ -325,7 +325,7 @@ export function QueryDiffTimelineHeatmap({ type: 'rect' as const, shape: clippedShape, style: { - fill: getCellColor({ value: colorValue, negativeColor, neutralColor, positiveColor }), + fill: getCellColor({ value: colorValue, colorScheme }), lineWidth: 0, }, emphasis: { @@ -336,7 +336,7 @@ export function QueryDiffTimelineHeatmap({ }, }; }, - [axisLabelColor, negativeColor, neutralColor, positiveColor, rowHeight] + [axisLabelColor, colorScheme, rowHeight] ); const option: EChartsOption = useMemo( @@ -371,13 +371,18 @@ export function QueryDiffTimelineHeatmap({ : signedDelta < 0 ? 'Comparison lower' : 'No change'; + const deltaColor = + signedDelta > 0 ? positiveColor : signedDelta < 0 ? negativeColor : axisLabelColor; + const deltaStyle = `color:${deltaColor};font-weight:600`; + const relativeText = escapeTooltipText(formatRelativePercent(relative)); + const deltaText = escapeTooltipText(row.formatter(signedDelta, 2)); return [ `${escapeTooltipText(row.label)}`, - `${escapeTooltipText(deltaLabel)} (${formatRelativePercent(relative)})`, + `${escapeTooltipText(deltaLabel)} (${relativeText})`, `Baseline: ${escapeTooltipText(row.formatter(baseline, 2))}`, `Comparison: ${escapeTooltipText(row.formatter(comparison, 2))}`, - `Delta: ${escapeTooltipText(row.formatter(signedDelta, 2))}`, + `Delta: ${deltaText}`, `Time: ${escapeTooltipText(formatDuration(timestamp))}`, ].join('
'); }, @@ -449,6 +454,8 @@ export function QueryDiffTimelineHeatmap({ axisLabelColor, heatmapData, labelBackgroundColor, + negativeColor, + positiveColor, renderItem, timestamps, xAxisMax, From 1c92b75688f9aeb72c387b3b7785e2f2eaecf7f8 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Fri, 22 May 2026 09:52:52 -0600 Subject: [PATCH 32/33] line chart experiments --- .../components/src/timeline/Timeline.tsx | 7 +- .../@quent/components/src/timeline/types.ts | 1 + .../components/query-diff/QueryDiffColors.ts | 2 +- .../query-diff/QueryDiffTimeline.tsx | 55 +- .../QueryDiffTimeline.utils.test.ts | 3 + .../query-diff/QueryDiffTimeline.utils.ts | 18 +- .../query-diff/QueryDiffTimelineLine.tsx | 581 ++++++++++++++++++ ui/src/routes/diff.test.tsx | 4 + 8 files changed, 646 insertions(+), 25 deletions(-) create mode 100644 ui/src/components/query-diff/QueryDiffTimelineLine.tsx diff --git a/ui/packages/@quent/components/src/timeline/Timeline.tsx b/ui/packages/@quent/components/src/timeline/Timeline.tsx index 205811c1..9e3a7e17 100644 --- a/ui/packages/@quent/components/src/timeline/Timeline.tsx +++ b/ui/packages/@quent/components/src/timeline/Timeline.tsx @@ -77,6 +77,7 @@ export function Timeline({ // single neutral gray so the operator overlay reads as the figure and // everything else recedes as a monotone background. const renderColor = isDimmed ? rollupTimelineColor : seriesData.color; + const renderOpacity = seriesData.opacity ?? (isDimmed ? DIMMED_OPACITY : 1); if (seriesData.renderType === 'bar') { return { @@ -87,7 +88,7 @@ export function Timeline({ barWidth: '100%', cursor: 'default', data: seriesData.values.map((value, index) => [timestamps[index], value]), - itemStyle: { color: renderColor }, + itemStyle: { color: renderColor, opacity: renderOpacity }, z: isOverlay ? 6 : 3, sampling: 'lttb', emphasis: { @@ -110,10 +111,10 @@ export function Timeline({ cursor: 'default', data: seriesData.values.map((value, index) => [timestamps[index], value]), lineStyle: { width: 0 }, - itemStyle: { color: renderColor }, + itemStyle: { color: renderColor, opacity: renderOpacity }, areaStyle: { color: renderColor, - opacity: isDimmed ? DIMMED_OPACITY : 1, + opacity: renderOpacity, }, z: isOverlay ? 5 : 2, sampling: 'lttb', diff --git a/ui/packages/@quent/components/src/timeline/types.ts b/ui/packages/@quent/components/src/timeline/types.ts index 0a384c83..a885bc44 100644 --- a/ui/packages/@quent/components/src/timeline/types.ts +++ b/ui/packages/@quent/components/src/timeline/types.ts @@ -6,6 +6,7 @@ export type TimelineSeriesEntry = { formatter: (value: number, decimals?: number) => string; values: number[]; color: string; + opacity?: number; renderType?: 'area' | 'bar'; isOverlay?: boolean; /** When true, this series is dimmed to make overlay series stand out. */ diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index 4e2eb79f..41ad17c2 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -29,7 +29,7 @@ export const DIFF_DIVERGING_COLORS_DARK = [ export const DIFF_DIVERGING_COLORS = DIFF_DIVERGING_COLORS_LIGHT; -const BASELINE_QUERY_COLOR_INDEX = 5; +const BASELINE_QUERY_COLOR_INDEX = 8; const COMPARISON_QUERY_COLOR_INDICES = [4, 6, 2, 3, 7, 8, 9, 10]; export function getDiffDivergingColors(theme: PaletteTheme): readonly string[] { diff --git a/ui/src/components/query-diff/QueryDiffTimeline.tsx b/ui/src/components/query-diff/QueryDiffTimeline.tsx index 0398dd89..5f2db884 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.tsx +++ b/ui/src/components/query-diff/QueryDiffTimeline.tsx @@ -52,6 +52,7 @@ import { QueryDiffTimelineHeatmap, type QueryDiffTimelineHeatmapRow, } from './QueryDiffTimelineHeatmap'; +import { QueryDiffTimelineLine } from './QueryDiffTimelineLine'; import { buildDiffHeatmapRowData, buildDiffTimelineData } from './QueryDiffTimeline.utils'; interface QueryDiffTimelineProps { @@ -83,12 +84,13 @@ interface TimelineTarget { resourceTypes: string[]; } -const TIMELINE_ROW_HEIGHT = 55; +const TIMELINE_ROW_HEIGHT = 85; const HEATMAP_ROW_HEIGHT = Math.round((TIMELINE_ROW_HEIGHT * 2) / 3); const TIMELINE_START = 0n; const COMPACT_SELECT_TRIGGER_CLASS = 'h-7 min-w-36 rounded px-2 py-1 text-xs'; const COMPACT_SELECT_ITEM_CLASS = 'py-1 pl-7 pr-2 text-xs'; -type QueryDiffTimelineView = 'overlay' | 'heatmap'; +type QueryDiffTimelineView = 'overlay' | 'heatmap' | 'line'; +type QueryDiffCompactTimelineView = Exclude; function getTimelineTarget(bundle: QueryBundle): TimelineTarget | null { if (!('ResourceGroup' in bundle.resource_tree)) return null; @@ -410,6 +412,7 @@ function QueryDiffTimelinePairRows({ } function QueryDiffTimelineHeatmapRows({ + view, baselineEngineId, baselineQueryId, baselineBundle, @@ -424,6 +427,7 @@ function QueryDiffTimelineHeatmapRows({ positiveColor, negativeColor, }: { + view: QueryDiffCompactTimelineView; baselineEngineId: string; baselineQueryId: string; baselineBundle: QueryBundle; @@ -494,7 +498,7 @@ function QueryDiffTimelineHeatmapRows({ if (activeRequestCount > 0 && isLoading) { return (
- Loading timeline heatmap... + Loading timeline {view}...
); } @@ -502,7 +506,7 @@ function QueryDiffTimelineHeatmapRows({ if (hasError) { return (
- Failed to load timeline heatmap + Failed to load timeline {view}
); } @@ -568,7 +572,7 @@ function QueryDiffTimelineHeatmapRows({ ...buildDiffHeatmapRowData(timelineData), }; }); - return ( + return view === 'heatmap' ? ( + ) : ( + ); } @@ -708,7 +722,7 @@ export function QueryDiffTimelineList({ aria-label="Timeline chart type" className="inline-flex h-7 shrink-0 items-center gap-0 rounded border border-border bg-background p-0.5" > - {(['overlay', 'heatmap'] as const).map(view => { + {(['overlay', 'heatmap', 'line'] as const).map(view => { const isActive = timelineView === view; return ( ); })} @@ -819,8 +833,21 @@ export function QueryDiffTimelineList({ isDark={isDark} /> - {timelineView === 'heatmap' ? ( + {timelineView === 'overlay' ? ( + comparisons.map(comparison => ( + + )) + ) : ( - ) : ( - comparisons.map(comparison => ( - - )) )}
)} diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts index f5d8c8bf..bb035781 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -54,18 +54,21 @@ describe('buildDiffTimelineData', () => { expect(data.delta.series['Baseline higher']?.values).toEqual([2, 0]); expect(data.delta.series['Comparison higher']?.values).toEqual([0, 3]); expect(data.comparisonWithDelta.series.slots?.values).toEqual([0, 0]); + expect(data.comparisonWithDelta.series.slots?.opacity).toBe(0.5); expect(data.comparisonWithDelta.series['Delta: Baseline higher']).toMatchObject({ values: [-2, 0], color: DIFF_NEGATIVE_COLOR, isOverlay: true, renderType: 'bar', }); + expect(data.comparisonWithDelta.series['Delta: Baseline higher']?.opacity).toBeUndefined(); expect(data.comparisonWithDelta.series['Delta: Comparison higher']).toMatchObject({ values: [0, 3], color: DIFF_POSITIVE_COLOR, isOverlay: true, renderType: 'bar', }); + expect(data.comparisonWithDelta.series['Delta: Comparison higher']?.opacity).toBeUndefined(); expect(data.baseline.series.slots?.color).toBe('#0072B2'); expect(data.comparison.series.slots?.color).toBe('#E69F00'); expect(data.delta.series['Baseline higher']?.color).toBe(DIFF_NEGATIVE_COLOR); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index 2303bc19..cbeb5776 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -14,6 +14,7 @@ const QUERY_A_HIGHER_LABEL = 'Query A higher'; const QUERY_B_HIGHER_LABEL = 'Query B higher'; const BASELINE_HIGHER_LABEL = 'Baseline higher'; const COMPARISON_HIGHER_LABEL = 'Comparison higher'; +const COMPARISON_WITH_DELTA_OPACITY = 0.5; interface TimelineRowData { timestamps: number[]; @@ -233,6 +234,18 @@ function recolorTimelineSeries(series: TimelineSeries, color: string): TimelineS ); } +function setTimelineSeriesOpacity(series: TimelineSeries, opacity: number): TimelineSeries { + return Object.fromEntries( + Object.entries(series).map(([name, entry]) => [ + name, + { + ...entry, + opacity, + }, + ]) + ); +} + function clampRelativeValue(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(-1, Math.min(1, value)); @@ -349,7 +362,10 @@ export function buildDiffTimelineData({ comparisonWithDelta: { timestamps: comparison.timestamps, series: { - ...recolorTimelineSeries(comparison.series, queryColors.comparison), + ...setTimelineSeriesOpacity( + recolorTimelineSeries(comparison.series, queryColors.comparison), + COMPARISON_WITH_DELTA_OPACITY + ), ...buildSignedDeltaOverlaySeries({ delta, baseline, comparison, theme }), }, }, diff --git a/ui/src/components/query-diff/QueryDiffTimelineLine.tsx b/ui/src/components/query-diff/QueryDiffTimelineLine.tsx new file mode 100644 index 00000000..2a9fbb68 --- /dev/null +++ b/ui/src/components/query-diff/QueryDiffTimelineLine.tsx @@ -0,0 +1,581 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useMemo, useRef } from 'react'; +import ReactEChartsComponent from 'echarts-for-react'; +import type { EChartsInstance } from 'echarts-for-react'; +import type { Opts } from 'echarts-for-react/lib/types'; +import type { CustomSeriesOption, LineSeriesOption } from 'echarts/charts'; +import { + CHART_GROUP, + connectChart, + echarts, + TIMELINE_MONO_FONT, + TIMELINE_SPACING, + useTimelineEchartsTheme, + type EChartsOption, +} from '@quent/components'; +import { useZoomRange } from '@quent/hooks'; +import { cn, formatDuration, withOpacity } from '@quent/utils'; +import type { QueryDiffTimelineHeatmapRow } from './QueryDiffTimelineHeatmap'; + +interface QueryDiffTimelineLineProps { + rows: QueryDiffTimelineHeatmapRow[]; + timestamps: number[]; + rowHeight: number; + durationSeconds: number; + isDark: boolean; + positiveColor: string; + negativeColor: string; +} + +type LinePointValue = [ + timestampMs: number, + yValue: number | null, + rowIndex: number, + signedDelta: number, + relative: number, + baseline: number, + comparison: number, + binIndex: number, +]; + +type LineTooltipCellValue = [xStartMs: number, xEndMs: number, rowIndex: number, binIndex: number]; + +interface RectShape { + x: number; + y: number; + width: number; + height: number; +} + +interface AlignedLineRow extends QueryDiffTimelineHeatmapRow { + baselineValues: number[]; + comparisonValues: number[]; + signedDeltaValues: number[]; + relativeValues: number[]; + colorValues: number[]; +} + +const ROW_HALF_BAND_HEIGHT = 0.5; + +function formatRelativePercent(value: number): string { + const percent = value * 100; + const decimals = Math.abs(percent) < 10 && percent !== 0 ? 1 : 0; + return `${percent.toFixed(decimals)}%`; +} + +function escapeTooltipText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isLinePointValue(value: unknown): value is LinePointValue { + return ( + Array.isArray(value) && + value.length >= 8 && + typeof value[0] === 'number' && + (typeof value[1] === 'number' || value[1] === null) && + value.slice(2).every(item => typeof item === 'number') + ); +} + +function isLineTooltipCellValue(value: unknown): value is LineTooltipCellValue { + return Array.isArray(value) && value.length >= 4 && value.every(item => typeof item === 'number'); +} + +function clampUnit(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(-1, Math.min(1, value)); +} + +function maxAbsFinite(values: number[]): number { + return values.reduce( + (max, value) => (Number.isFinite(value) ? Math.max(max, Math.abs(value)) : max), + 0 + ); +} + +function findBinIndexAtTime(sourceTimestamps: number[], timestamp: number): number { + if (sourceTimestamps.length <= 1) return 0; + + let lo = 0; + let hi = sourceTimestamps.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if ((sourceTimestamps[mid] ?? 0) <= timestamp) lo = mid + 1; + else hi = mid - 1; + } + + return Math.max(0, Math.min(sourceTimestamps.length - 1, hi)); +} + +function timestampsEqual(a: number[], b: number[]): boolean { + return ( + a.length === b.length && a.every((value, index) => Math.abs(value - (b[index] ?? 0)) < 0.001) + ); +} + +function inferSourceEnd(sourceTimestamps: number[]): number { + if (sourceTimestamps.length <= 1) return Number.POSITIVE_INFINITY; + const last = sourceTimestamps[sourceTimestamps.length - 1] ?? 0; + const previous = sourceTimestamps[sourceTimestamps.length - 2] ?? last; + return last + Math.max(0, last - previous); +} + +function alignValuesToTimestamps({ + values, + sourceTimestamps, + targetTimestamps, +}: { + values: number[]; + sourceTimestamps: number[]; + targetTimestamps: number[]; +}): number[] { + if (values.length === 0 || targetTimestamps.length === 0) return []; + if ( + values.length === targetTimestamps.length && + timestampsEqual(sourceTimestamps, targetTimestamps) + ) { + return values.slice(0, targetTimestamps.length); + } + + const sourceStart = sourceTimestamps[0] ?? 0; + const sourceEnd = inferSourceEnd(sourceTimestamps); + + return targetTimestamps.map(timestamp => { + if (timestamp < sourceStart || timestamp >= sourceEnd) return 0; + return values[findBinIndexAtTime(sourceTimestamps, timestamp)] ?? 0; + }); +} + +function alignRowToTimestamps( + row: QueryDiffTimelineHeatmapRow, + targetTimestamps: number[] +): AlignedLineRow { + const sourceTimestamps = row.timestamps.length > 0 ? row.timestamps : targetTimestamps; + return { + ...row, + baselineValues: alignValuesToTimestamps({ + values: row.baselineValues, + sourceTimestamps, + targetTimestamps, + }), + comparisonValues: alignValuesToTimestamps({ + values: row.comparisonValues, + sourceTimestamps, + targetTimestamps, + }), + signedDeltaValues: alignValuesToTimestamps({ + values: row.signedDeltaValues, + sourceTimestamps, + targetTimestamps, + }), + relativeValues: alignValuesToTimestamps({ + values: row.relativeValues, + sourceTimestamps, + targetTimestamps, + }), + colorValues: alignValuesToTimestamps({ + values: row.colorValues, + sourceTimestamps, + targetTimestamps, + }), + }; +} + +function clipRectByRect(rect: RectShape, bounds: RectShape): RectShape | null { + const x = Math.max(rect.x, bounds.x); + const y = Math.max(rect.y, bounds.y); + const x2 = Math.min(rect.x + rect.width, bounds.x + bounds.width); + const y2 = Math.min(rect.y + rect.height, bounds.y + bounds.height); + if (x2 <= x || y2 <= y) return null; + return { x, y, width: x2 - x, height: y2 - y }; +} + +function linePoint( + row: AlignedLineRow, + rowIndex: number, + timestamp: number, + binIndex: number, + maxAbsSignedDelta: number, + sign: 'positive' | 'negative' +): LinePointValue { + const signedDelta = row.signedDeltaValues[binIndex] ?? 0; + const normalized = maxAbsSignedDelta > 0 ? clampUnit(signedDelta / maxAbsSignedDelta) : 0; + const isVisible = sign === 'positive' ? signedDelta >= 0 : signedDelta <= 0; + const yValue = isVisible ? rowIndex - normalized * ROW_HALF_BAND_HEIGHT : null; + return [ + timestamp, + yValue, + rowIndex, + signedDelta, + row.relativeValues[binIndex] ?? 0, + row.baselineValues[binIndex] ?? 0, + row.comparisonValues[binIndex] ?? 0, + binIndex, + ]; +} + +export function QueryDiffTimelineLine({ + rows, + timestamps, + rowHeight, + durationSeconds, + isDark, + positiveColor, + negativeColor, +}: QueryDiffTimelineLineProps) { + const { themeName, axisLabelColor, labelBackgroundColor } = useTimelineEchartsTheme(isDark); + const chartHeight = Math.max(rowHeight, rows.length * rowHeight); + const xAxisMin = timestamps[0] ?? 0; + const xAxisMax = Math.max( + xAxisMin + durationSeconds * 1_000, + timestamps[timestamps.length - 1] ?? xAxisMin + ); + const zoomRange = useZoomRange(); + const zoomRangeRef = useRef(zoomRange); + const durationSecondsRef = useRef(durationSeconds); + zoomRangeRef.current = zoomRange; + durationSecondsRef.current = durationSeconds; + + const alignedRows = useMemo( + () => rows.map(row => alignRowToTimestamps(row, timestamps)), + [rows, timestamps] + ); + + const seriesOptions = useMemo(() => { + const zeroLineColor = withOpacity(axisLabelColor, isDark ? 0.34 : 0.28); + return alignedRows.flatMap((row, rowIndex): LineSeriesOption[] => { + if (row.disabledMessage) return []; + const maxAbsSignedDelta = maxAbsFinite(row.signedDeltaValues) || 1; + const zeroLine: LineSeriesOption = { + name: `${row.id}-zero`, + type: 'line', + data: [ + [xAxisMin, rowIndex], + [xAxisMax, rowIndex], + ], + symbol: 'none', + lineStyle: { color: zeroLineColor, width: 1 }, + silent: true, + tooltip: { show: false }, + animation: false, + z: 1, + }; + const commonLineOptions = { + type: 'line' as const, + showSymbol: false, + symbol: 'none', + connectNulls: false, + animation: false, + cursor: 'default', + silent: true, + emphasis: { + disabled: true, + focus: 'none' as const, + }, + z: 3, + }; + + return [ + zeroLine, + { + ...commonLineOptions, + name: `${row.id}-positive`, + data: timestamps.map((timestamp, binIndex) => + linePoint(row, rowIndex, timestamp, binIndex, maxAbsSignedDelta, 'positive') + ), + lineStyle: { color: positiveColor, width: 1.5 }, + itemStyle: { color: positiveColor }, + }, + { + ...commonLineOptions, + name: `${row.id}-negative`, + data: timestamps.map((timestamp, binIndex) => + linePoint(row, rowIndex, timestamp, binIndex, maxAbsSignedDelta, 'negative') + ), + lineStyle: { color: negativeColor, width: 1.5 }, + itemStyle: { color: negativeColor }, + }, + ]; + }); + }, [ + alignedRows, + axisLabelColor, + isDark, + negativeColor, + positiveColor, + timestamps, + xAxisMax, + xAxisMin, + ]); + + const tooltipData = useMemo( + () => + alignedRows.flatMap((row, rowIndex) => + row.disabledMessage + ? [] + : timestamps.map((timestamp, binIndex) => { + const xEndMs = timestamps[binIndex + 1] ?? xAxisMax; + return [timestamp, xEndMs, rowIndex, binIndex] as LineTooltipCellValue; + }) + ), + [alignedRows, timestamps, xAxisMax] + ); + + type RenderItem = NonNullable; + const renderTooltipCell: RenderItem = useCallback( + (params, api) => { + const xStartMs = api.value(0) as number; + const xEndMs = api.value(1) as number; + const rowIndex = api.value(2) as number; + if (xEndMs <= xStartMs) return null; + + const startPoint = api.coord([xStartMs, rowIndex]); + const endPoint = api.coord([xEndMs, rowIndex]); + const axisBandSize = api.size?.([0, 1]) as number[] | undefined; + const bandHeight = Math.max(1, axisBandSize?.[1] ?? rowHeight); + const rectShape = { + x: startPoint[0], + y: startPoint[1] - bandHeight / 2, + width: Math.max(1, endPoint[0] - startPoint[0]), + height: bandHeight, + }; + const coord = params.coordSys as { x?: number; y?: number; width?: number; height?: number }; + const clipBounds = + typeof coord.width === 'number' && typeof coord.height === 'number' + ? { + x: coord.x ?? 0, + y: coord.y ?? 0, + width: coord.width, + height: coord.height, + } + : null; + const clippedShape = clipBounds ? clipRectByRect(rectShape, clipBounds) : rectShape; + if (!clippedShape) return null; + + return { + type: 'rect' as const, + shape: clippedShape, + style: { + fill: 'rgba(0, 0, 0, 0.001)', + lineWidth: 0, + }, + }; + }, + [rowHeight] + ); + + const option: EChartsOption = useMemo( + () => + ({ + animation: false, + tooltip: { + trigger: 'item', + confine: true, + backgroundColor: labelBackgroundColor, + borderColor: axisLabelColor, + textStyle: { + color: axisLabelColor, + fontFamily: TIMELINE_MONO_FONT, + fontSize: 11, + }, + formatter: (params: { data?: unknown }) => { + if (isLinePointValue(params.data)) { + const [timestamp, , rowIndex, signedDelta, relative, baseline, comparison] = + params.data; + const row = alignedRows[rowIndex]; + if (!row) return ''; + + const deltaLabel = + signedDelta > 0 + ? 'Comparison higher' + : signedDelta < 0 + ? 'Comparison lower' + : 'No change'; + const deltaColor = + signedDelta > 0 ? positiveColor : signedDelta < 0 ? negativeColor : axisLabelColor; + const deltaStyle = `color:${deltaColor};font-weight:600`; + + return [ + `${escapeTooltipText(row.label)}`, + `${escapeTooltipText(deltaLabel)} (${escapeTooltipText(formatRelativePercent(relative))})`, + `Baseline: ${escapeTooltipText(row.formatter(baseline, 2))}`, + `Comparison: ${escapeTooltipText(row.formatter(comparison, 2))}`, + `Delta: ${escapeTooltipText(row.formatter(signedDelta, 2))}`, + `Time: ${escapeTooltipText(formatDuration(timestamp))}`, + ].join('
'); + } + + if (!isLineTooltipCellValue(params.data)) return ''; + + const [timestamp, , rowIndex, binIndex] = params.data; + const row = alignedRows[rowIndex]; + if (!row) return ''; + + const relative = row.relativeValues[binIndex] ?? 0; + const signedDelta = row.signedDeltaValues[binIndex] ?? 0; + const baseline = row.baselineValues[binIndex] ?? 0; + const comparison = row.comparisonValues[binIndex] ?? 0; + const deltaLabel = + signedDelta > 0 + ? 'Comparison higher' + : signedDelta < 0 + ? 'Comparison lower' + : 'No change'; + const deltaColor = + signedDelta > 0 ? positiveColor : signedDelta < 0 ? negativeColor : axisLabelColor; + const deltaStyle = `color:${deltaColor};font-weight:600`; + + return [ + `${escapeTooltipText(row.label)}`, + `${escapeTooltipText(deltaLabel)} (${escapeTooltipText(formatRelativePercent(relative))})`, + `Baseline: ${escapeTooltipText(row.formatter(baseline, 2))}`, + `Comparison: ${escapeTooltipText(row.formatter(comparison, 2))}`, + `Delta: ${escapeTooltipText(row.formatter(signedDelta, 2))}`, + `Time: ${escapeTooltipText(formatDuration(timestamp))}`, + ].join('
'); + }, + }, + grid: { + ...TIMELINE_SPACING, + top: 0, + bottom: 0, + containLabel: false, + }, + xAxis: { + type: 'time', + show: false, + min: xAxisMin, + max: xAxisMax, + boundaryGap: false, + axisPointer: { + show: true, + type: 'line', + label: { show: false }, + }, + }, + yAxis: { + type: 'value', + show: false, + inverse: true, + min: -0.5, + max: Math.max(0.5, rows.length - 0.5), + }, + dataZoom: [ + { + type: 'slider', + show: false, + realtime: true, + filterMode: 'none', + xAxisIndex: [0], + }, + { + type: 'inside', + zoomLock: true, + zoomOnMouseWheel: false, + moveOnMouseWheel: false, + throttle: 30, + filterMode: 'none', + xAxisIndex: [0], + }, + { + type: 'inside', + zoomOnMouseWheel: 'shift', + moveOnMouseMove: false, + moveOnMouseWheel: false, + throttle: 30, + filterMode: 'none', + xAxisIndex: [0], + }, + ], + series: [ + ...seriesOptions, + { + type: 'custom', + data: tooltipData, + renderItem: renderTooltipCell as never, + coordinateSystem: 'cartesian2d', + cursor: 'default', + z: 10, + }, + ], + }) as EChartsOption, + [ + alignedRows, + axisLabelColor, + labelBackgroundColor, + negativeColor, + positiveColor, + renderTooltipCell, + rows.length, + seriesOptions, + tooltipData, + xAxisMax, + xAxisMin, + ] + ); + + const handleChartReady = useCallback((instance: EChartsInstance) => { + const dur = durationSecondsRef.current; + const range = zoomRangeRef.current; + const zoomPct = + dur > 0 ? { start: (range.start / dur) * 100, end: (range.end / dur) * 100 } : null; + connectChart(instance, CHART_GROUP, false, zoomPct); + }, []); + + const opts = useMemo(() => ({ renderer: 'canvas' }) as Opts, []); + + return ( +
+
+ {rows.map((row, index) => ( +
0 && 'border-t border-border' + )} + style={{ height: rowHeight }} + > + + + {row.label} + + {row.disabledMessage ? ( + + {row.disabledMessage} + + ) : ( + row.detail && ( + {row.detail} + ) + )} +
+ ))} +
+
+ +
+
+ ); +} diff --git a/ui/src/routes/diff.test.tsx b/ui/src/routes/diff.test.tsx index 07f2f6ae..b2ce611e 100644 --- a/ui/src/routes/diff.test.tsx +++ b/ui/src/routes/diff.test.tsx @@ -250,10 +250,14 @@ describe('Diff routes', () => { expect(await screen.findByText('Timeline Delta')).toBeInTheDocument(); const overlayButton = screen.getByRole('button', { name: 'Overlay' }); const heatmapButton = screen.getByRole('button', { name: 'Heatmap' }); + const lineButton = screen.getByRole('button', { name: 'Line' }); expect(overlayButton).toHaveAttribute('aria-pressed', 'true'); await user.click(heatmapButton); expect(heatmapButton).toHaveAttribute('aria-pressed', 'true'); expect(overlayButton).toHaveAttribute('aria-pressed', 'false'); + await user.click(lineButton); + expect(lineButton).toHaveAttribute('aria-pressed', 'true'); + expect(heatmapButton).toHaveAttribute('aria-pressed', 'false'); await user.click(overlayButton); expect(overlayButton).toHaveAttribute('aria-pressed', 'true'); expect(screen.queryByText('Total Run Time')).not.toBeInTheDocument(); From 43aa030fc9625a70dbeed22dd6cfd383e8eb97a5 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Fri, 22 May 2026 10:29:58 -0600 Subject: [PATCH 33/33] Some other variations on color and line charts --- ui/src/components/query-diff/QueryDiffColors.test.ts | 12 ++++++------ ui/src/components/query-diff/QueryDiffColors.ts | 12 ++++++------ .../query-diff/QueryDiffTimeline.utils.test.ts | 6 ++++-- .../components/query-diff/QueryDiffTimeline.utils.ts | 7 ++++++- .../components/query-diff/QueryDiffTimelineLine.tsx | 11 +++++++++++ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/ui/src/components/query-diff/QueryDiffColors.test.ts b/ui/src/components/query-diff/QueryDiffColors.test.ts index 1770065f..1ce85688 100644 --- a/ui/src/components/query-diff/QueryDiffColors.test.ts +++ b/ui/src/components/query-diff/QueryDiffColors.test.ts @@ -36,13 +36,13 @@ describe('QueryDiffColors', () => { it('uses a dark BuRd variant with the card color at the center', () => { expect(DIFF_DIVERGING_COLORS_DARK).toEqual([ '#92C5DE', - '#4393C3', - '#2166AC', - '#0B2F4A', + '#638BA3', + '#446689', + '#1B2D3A', '#020817', - '#4A1218', - '#B2182B', - '#D6604D', + '#3C2023', + '#8B3F48', + '#B4796F', '#F4A582', ]); expect(getDiffDivergingColors('dark')[4]).toBe('#020817'); diff --git a/ui/src/components/query-diff/QueryDiffColors.ts b/ui/src/components/query-diff/QueryDiffColors.ts index 41ad17c2..044d7dde 100644 --- a/ui/src/components/query-diff/QueryDiffColors.ts +++ b/ui/src/components/query-diff/QueryDiffColors.ts @@ -17,13 +17,13 @@ export const DIFF_DIVERGING_COLORS_LIGHT = [ export const DIFF_DIVERGING_COLORS_DARK = [ '#92C5DE', - '#4393C3', - '#2166AC', - '#0B2F4A', + '#638BA3', + '#446689', + '#1B2D3A', '#020817', - '#4A1218', - '#B2182B', - '#D6604D', + '#3C2023', + '#8B3F48', + '#B4796F', '#F4A582', ] as const; diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts index bb035781..f27d946c 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.test.ts @@ -107,7 +107,7 @@ describe('buildDiffTimelineData', () => { }); }); - it('builds capped relative values for heatmap rows', () => { + it('builds log-scaled color values for heatmap rows', () => { const response: DiffTimelineResponse = { timelines: [makeTimeline({ slots: [100, 0, 50] }), makeTimeline({ slots: [50, 25, 200] })], delta: makeTimeline({ @@ -128,6 +128,8 @@ describe('buildDiffTimelineData', () => { expect(row.comparisonValues).toEqual([50, 25, 200]); expect(row.signedDeltaValues).toEqual([-50, 25, 150]); expect(row.relativeValues).toEqual([-0.5, 1, 3]); - expect(row.colorValues).toEqual([-0.5, 1, 1]); + expect(row.colorValues[0]).toBeCloseTo(-Math.log1p(0.5) / Math.LN2); + expect(row.colorValues[1]).toBe(1); + expect(row.colorValues[2]).toBe(1); }); }); diff --git a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts index cbeb5776..243d6de2 100644 --- a/ui/src/components/query-diff/QueryDiffTimeline.utils.ts +++ b/ui/src/components/query-diff/QueryDiffTimeline.utils.ts @@ -251,6 +251,11 @@ function clampRelativeValue(value: number): number { return Math.max(-1, Math.min(1, value)); } +function logScaleRelativeColorValue(value: number): number { + const clamped = clampRelativeValue(value); + return Math.sign(clamped) * (Math.log1p(Math.abs(clamped)) / Math.LN2); +} + function sumSeriesAtIndex(series: TimelineSeries, index: number): number { return Object.values(series) .filter(entry => !entry.isOverlay) @@ -297,7 +302,7 @@ export function buildDiffHeatmapRowData(data: DiffTimelineData): DiffHeatmapRowD comparisonValues[index] = comparison; signedDeltaValues[index] = signedDelta; relativeValues[index] = relative; - colorValues[index] = clampRelativeValue(relative); + colorValues[index] = logScaleRelativeColorValue(relative); } return { diff --git a/ui/src/components/query-diff/QueryDiffTimelineLine.tsx b/ui/src/components/query-diff/QueryDiffTimelineLine.tsx index 2a9fbb68..6a68fbc7 100644 --- a/ui/src/components/query-diff/QueryDiffTimelineLine.tsx +++ b/ui/src/components/query-diff/QueryDiffTimelineLine.tsx @@ -58,6 +58,7 @@ interface AlignedLineRow extends QueryDiffTimelineHeatmapRow { } const ROW_HALF_BAND_HEIGHT = 0.5; +const AREA_FILL_OPACITY = 0.42; function formatRelativePercent(value: number): string { const percent = value * 100; @@ -292,6 +293,11 @@ export function QueryDiffTimelineLine({ ), lineStyle: { color: positiveColor, width: 1.5 }, itemStyle: { color: positiveColor }, + areaStyle: { + color: positiveColor, + opacity: AREA_FILL_OPACITY, + origin: rowIndex, + }, }, { ...commonLineOptions, @@ -301,6 +307,11 @@ export function QueryDiffTimelineLine({ ), lineStyle: { color: negativeColor, width: 1.5 }, itemStyle: { color: negativeColor }, + areaStyle: { + color: negativeColor, + opacity: AREA_FILL_OPACITY, + origin: rowIndex, + }, }, ]; });