diff --git a/ui/packages/@quent/components/src/dag/DAGChart.tsx b/ui/packages/@quent/components/src/dag/DAGChart.tsx index 8e57ba05a..98d1540e6 100644 --- a/ui/packages/@quent/components/src/dag/DAGChart.tsx +++ b/ui/packages/@quent/components/src/dag/DAGChart.tsx @@ -48,8 +48,13 @@ import type { DAGData } from '../services/query-plan/types'; import { QueryPlanNode, type QueryPlanNodeData } from '../query-plan/QueryPlanNode'; import { DAGLegend } from './DAGLegend'; import { parseCustomStatistics } from '../lib/queryBundle.utils'; -import { continuousColor, getOperationTypeColor, buildOperatorColorMap } from '@quent/utils'; -import { inferFieldFormatter } from '@quent/utils'; +import { + continuousColor, + getOperationTypeColor, + buildOperatorColorMap, + inferFieldFormatter, +} from '@quent/utils'; +import type { QuantitySpec } from '@quent/utils'; // Edge geometry constants const EDGE_STROKE_WIDTH_DEFAULT = 1.5; @@ -317,6 +322,21 @@ const FlowLayout = ({ [data.nodes] ); + // Build a stat-key → QuantitySpec map for quantity-aware legend formatting. + const statQuantitySpecs = useMemo((): Record => { + if (!data.quantitySpecs) return {}; + const result: Record = {}; + for (const node of data.nodes) { + for (const stat of parseCustomStatistics(node.metadata?.rawNode)) { + if (stat.quantity && !(stat.key in result)) { + const spec = data.quantitySpecs[stat.quantity]; + if (spec) result[stat.key] = spec; + } + } + } + return result; + }, [data.nodes, data.quantitySpecs]); + // Convert DAGData to ReactFlow format const convertToReactFlow = useCallback(() => { // Determine which nodes have incoming/outgoing edges @@ -338,6 +358,7 @@ const FlowLayout = ({ isDark, baseColor: operatorColorMap.get(node.type.toLowerCase()), flowBarVisible, + quantitySpecs: data.quantitySpecs, }, style: { width: NODE_LAYOUT_WIDTH, @@ -455,7 +476,7 @@ const FlowLayout = ({ defaultEdgeOptions={{ type: 'smoothstep' }} > - + { - const fmt = inferFieldFormatter(field); +const ContinuousLegend = ({ + field, + min, + max, + palette, + isDark, + quantitySpec, +}: ContinuousLegendProps) => { + const fmt = quantitySpec + ? (v: number) => formatQuantity(v, quantitySpec, 'Occupancy') + : inferFieldFormatter(field); return (
@@ -135,11 +146,13 @@ function NodeLegendContent({ field, palette, isDark, + statQuantitySpecs, }: { coloring: NodeColoring; field: string | null; palette: ContinuousPaletteName; isDark: boolean; + statQuantitySpecs?: Record; }) { if (!coloring || !field) return null; if (coloring.type === 'continuous') { @@ -150,6 +163,7 @@ function NodeLegendContent({ max={coloring.max} palette={palette} isDark={isDark} + quantitySpec={statQuantitySpecs?.[field]} /> ); } @@ -161,11 +175,13 @@ function EdgeLegendContent({ field, palette, isDark, + statQuantitySpecs, }: { coloring: EdgeColoring; field: string | null; palette: ContinuousPaletteName; isDark: boolean; + statQuantitySpecs?: Record; }) { if (!coloring || !field) return null; if (coloring.type === 'continuous') { @@ -176,6 +192,7 @@ function EdgeLegendContent({ max={coloring.max} palette={palette} isDark={isDark} + quantitySpec={statQuantitySpecs?.[field]} /> ); } @@ -185,10 +202,12 @@ function EdgeLegendContent({ interface DAGLegendProps { /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; + /** Pre-resolved stat-key → QuantitySpec map for quantity-aware legend formatting. */ + statQuantitySpecs?: Record; } /** Panel overlay showing node/edge coloring legends within the ReactFlow canvas. */ -export const DAGLegend = ({ isDark }: DAGLegendProps) => { +export const DAGLegend = ({ isDark, statQuantitySpecs }: DAGLegendProps) => { const nodeColoring = useNodeColoringValue(); const edgeColoring = useEdgeColoring(); const [nodePalette] = useNodeColorPalette(); @@ -247,6 +266,7 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { field={nodeField} palette={nodePalette} isDark={isDark} + statQuantitySpecs={statQuantitySpecs} /> {hasNode && hasEdge &&
} { field={edgeField} palette={edgePalette} isDark={isDark} + statQuantitySpecs={statQuantitySpecs} /> {(hasNode || hasEdge) && hasDataFlow &&
} {hasDataFlow && ( diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx index 408cc73c8..2cece4d31 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -11,10 +11,17 @@ import { } from '@quent/hooks'; import { DataText } from '../ui/data-text'; import { thinScrollbarClass } from '../ui/thin-scroll'; -import { inferFieldFormatter } from '@quent/utils'; +import { formatStatWithQuantity } from '@quent/utils'; +import type { QuantitySpec } from '@quent/utils'; import { DataFlowMatrix } from './DataFlowMatrix'; -export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => { +export const DAGNodeInfoPanel = ({ + isDark = false, + quantitySpecs, +}: { + isDark?: boolean; + quantitySpecs?: Record; +}) => { const selectedNodeData = useSelectedNodeData(); const dataFlowEnabled = useDataFlowEnabled(); const dataFlowMeta = useDataFlowMeta(); @@ -78,7 +85,7 @@ export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => { {selectedNodeData.nodeId}
- {selectedNodeData.statistics?.map(({ key, value }) => ( + {selectedNodeData.statistics?.map(({ key, value, quantity }) => (
{Array.isArray(value) ? (
@@ -95,7 +102,9 @@ export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => {
{key.replace(/_/g, ' ')}: - {typeof value === 'number' ? inferFieldFormatter(key)(value) : String(value)} + {typeof value === 'number' + ? formatStatWithQuantity(value, key, quantity, quantitySpecs) + : String(value)}
)} diff --git a/ui/packages/@quent/components/src/operator-timeline/types.ts b/ui/packages/@quent/components/src/operator-timeline/types.ts index ae50d5ce5..779af1921 100644 --- a/ui/packages/@quent/components/src/operator-timeline/types.ts +++ b/ui/packages/@quent/components/src/operator-timeline/types.ts @@ -20,5 +20,5 @@ export type OperatorActiveSpanEntry = { /** Plan ID this operator belongs to. */ planId: string; /** Pre-computed custom statistics for the operator popup. */ - statistics: Array<{ key: string; value: StatValue }>; + statistics: Array<{ key: string; value: StatValue; quantity?: string }>; }; diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index c2c4da1e1..8fc3fc533 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -180,6 +180,7 @@ function DataCell({ row, stat }: DataCellProps) { onMouseEnter: () => interaction.setHoveredStat(derived.buildHoveredStatInfo(stat)), onMouseLeave: () => interaction.setHoveredStat(null), }; + const quantitySpec = display.statQuantitySpecs?.[stat]; if (!display.isAggregating) { const val = row.values.get(stat) ?? null; return ( @@ -188,7 +189,7 @@ function DataCell({ row, stat }: DataCellProps) { style={{ backgroundColor: bg, boxShadow: cellHighlight }} {...statCellProps} > - {formatStatValue(val, stat)} + {formatStatValue(val, stat, quantitySpec)} ); } @@ -211,7 +212,7 @@ function DataCell({ row, stat }: DataCellProps) { style={{ backgroundColor: bg, boxShadow: cellHighlight }} {...statCellProps} > - {formatNumericStat(displayVal, stat)} + {formatNumericStat(displayVal, stat, quantitySpec)} ); } @@ -246,6 +247,8 @@ interface PivotedStatTableProps { /** Optional controlled sort state, forwarded to the underlying GroupedDataTable. */ sorting?: SortingState; onSortingChange?: OnChangeFn; + /** Per-stat QuantitySpec for quantity-aware formatting, keyed by stat name. */ + statQuantitySpecs?: Record; } export function PivotedStatTable({ @@ -266,6 +269,7 @@ export function PivotedStatTable({ onReorderStat, sorting, onSortingChange, + statQuantitySpecs, }: PivotedStatTableProps) { const [nodePalette] = useNodeColorPalette(); const rowRefs = useRef>(new Map()); @@ -515,8 +519,9 @@ export function PivotedStatTable({ aggMode, colorPalette: nodePalette, darkMode: isDark, + statQuantitySpecs, }), - [isAggregating, aggMode, nodePalette, isDark] + [isAggregating, aggMode, nodePalette, isDark, statQuantitySpecs] ); const dndContextValue = useMemo( () => ({ diff --git a/ui/packages/@quent/components/src/pivot-table/types.ts b/ui/packages/@quent/components/src/pivot-table/types.ts index bdac9a9c5..a7ad05ce2 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { StatValue, ContinuousPaletteName } from '@quent/utils'; +import type { StatValue, ContinuousPaletteName, QuantitySpec } from '@quent/utils'; import type { AggMode, HoveredStatInfo } from '@quent/hooks'; // Re-exports of pivot-table-related types that originate in @quent/hooks but @@ -86,6 +86,8 @@ export interface PivotTableDisplayConfig { aggMode: AggMode; colorPalette: ContinuousPaletteName; darkMode: boolean; + /** Per-stat QuantitySpec for quantity-aware formatting, keyed by stat name. */ + statQuantitySpecs?: Record; } // --- PivotedStatTable types --- diff --git a/ui/packages/@quent/components/src/pivot-table/utils.ts b/ui/packages/@quent/components/src/pivot-table/utils.ts index f7411c7b4..7bfc57060 100644 --- a/ui/packages/@quent/components/src/pivot-table/utils.ts +++ b/ui/packages/@quent/components/src/pivot-table/utils.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { inferFieldFormatter } from '@quent/utils'; -import type { StatValue, ContinuousPaletteName } from '@quent/utils'; +import { inferFieldFormatter, formatQuantity } from '@quent/utils'; +import type { StatValue, ContinuousPaletteName, QuantitySpec } from '@quent/utils'; import { continuousColor } from '@quent/utils'; import type { StatGroupExpandedRow, @@ -23,8 +23,13 @@ export interface GroupIndexDef { getLabel: (row: StatGroupExpandedRow) => string; } -export function formatNumericStat(n: number | null, statName: string): string { +export function formatNumericStat( + n: number | null, + statName: string, + quantitySpec?: QuantitySpec +): string { if (n === null) return '-'; + if (quantitySpec) return formatQuantity(n, quantitySpec, 'Occupancy'); return inferFieldFormatter(statName)(n); } @@ -41,9 +46,13 @@ export function itemHasId(items: Iterable, target: ReadonlySet): return false; } -export function formatStatValue(value: StatValue, statName: string): string { +export function formatStatValue( + value: StatValue, + statName: string, + quantitySpec?: QuantitySpec +): string { if (value === null || value === undefined) return '-'; - if (typeof value === 'number') return formatNumericStat(value, statName); + if (typeof value === 'number') return formatNumericStat(value, statName, quantitySpec); if (typeof value === 'boolean') return value ? 'true' : 'false'; if (Array.isArray(value)) return value.join(', '); return String(value); diff --git a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx index 4eb5330ae..8ec15157d 100644 --- a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx +++ b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx @@ -26,7 +26,7 @@ import { useSetHighlightedNodeIds, } from '@quent/hooks'; import { parseCustomStatistics } from '../lib/queryBundle.utils'; -import { inferFieldFormatter } from '@quent/utils'; +import { formatStatWithQuantity } from '@quent/utils'; import { DataText } from '../ui/data-text'; import { NodeFlowBar } from './NodeFlowBar'; @@ -53,6 +53,8 @@ export interface QueryPlanNodeData extends Record { * relayouts exactly once. */ flowBarVisible?: boolean; + /** Quantity specs from the QueryBundle, forwarded for quantity-aware stat formatting. */ + quantitySpecs?: { [key: string]: import('@quent/utils').QuantitySpec | undefined }; } const nodeVariants = cva( @@ -118,14 +120,18 @@ export const QueryPlanNode = memo(({ data }: { data: QueryPlanNodeData }) => { return data.label; }, [nodeLabelField, data]); - const colorFieldValue = colorField - ? (statistics.find(s => s.key === colorField)?.value ?? null) - : null; + const colorFieldStat = colorField ? statistics.find(s => s.key === colorField) : null; + const colorFieldValue = colorFieldStat?.value ?? null; const formattedColorFieldValue = colorFieldValue === null ? null : typeof colorFieldValue === 'number' - ? inferFieldFormatter(colorField!)(colorFieldValue) + ? formatStatWithQuantity( + colorFieldValue, + colorField!, + colorFieldStat?.quantity, + data.quantitySpecs + ) : String(colorFieldValue); const baseColor = data.baseColor ?? getOperationTypeColor(data.operationType); diff --git a/ui/packages/@quent/components/src/services/query-plan/types.ts b/ui/packages/@quent/components/src/services/query-plan/types.ts index f36cc4f26..be73f7b83 100644 --- a/ui/packages/@quent/components/src/services/query-plan/types.ts +++ b/ui/packages/@quent/components/src/services/query-plan/types.ts @@ -27,6 +27,7 @@ export interface DAGData { nodes: import('@quent/utils').DAGNode[]; edges: import('@quent/utils').DAGEdge[]; queryData: QueryPlanDataItem[]; + quantitySpecs?: { [key in string]?: import('@quent/utils').QuantitySpec }; } export interface QueryPlanNodeData extends Record { diff --git a/ui/packages/@quent/hooks/src/atoms/dagControls.ts b/ui/packages/@quent/hooks/src/atoms/dagControls.ts index c63267644..58ff287dd 100644 --- a/ui/packages/@quent/hooks/src/atoms/dagControls.ts +++ b/ui/packages/@quent/hooks/src/atoms/dagControls.ts @@ -40,7 +40,7 @@ export interface InspectedNodeData { nodeId: string; label: string; operationType: string; - statistics: Array<{ key: string; value: StatValue }>; + statistics: Array<{ key: string; value: StatValue; quantity?: string }>; } /** Data for the currently selected/pinned node (persists in the panel after click) */ diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts index fbfc04319..8c1edd1e9 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -359,3 +359,24 @@ export function formatQuantity( const symbol = kind === 'Rate' ? `${spec.symbol}/s` : spec.symbol; return formatWithPrefix(value, symbol, prefixSystem, decimals); } + +/** + * Format a numeric statistic value, using a QuantitySpec when one is available. + * + * When `quantity` is non-null and `specs` contains a matching entry, formats via + * `formatQuantity` (Occupancy kind) rather than the name-based `inferFieldFormatter` + * heuristic. This is necessary because the backend may rescale values (e.g. ns → s) + * before sending them, making the name suffix heuristic incorrect. + */ +export function formatStatWithQuantity( + value: number, + key: string, + quantity: string | undefined, + specs: { [key: string]: QuantitySpec | undefined } | undefined +): string { + if (quantity && specs) { + const spec = specs[quantity]; + if (spec) return formatQuantity(value, spec, 'Occupancy'); + } + return inferFieldFormatter(key)(value); +} diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 32a2c0f06..e089f5d01 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -44,6 +44,7 @@ export { formatAttributeValue, unwrapTaggedValue, inferFieldFormatter, + formatStatWithQuantity, } from './formatters'; // Rust-generated TypeScript types diff --git a/ui/src/components/QueryPlan.tsx b/ui/src/components/QueryPlan.tsx index 11d0be1a4..5832e90b3 100644 --- a/ui/src/components/QueryPlan.tsx +++ b/ui/src/components/QueryPlan.tsx @@ -214,7 +214,7 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st
- +
diff --git a/ui/src/components/operator-table/OperatorTable.tsx b/ui/src/components/operator-table/OperatorTable.tsx index 864dfffe4..e40c99047 100644 --- a/ui/src/components/operator-table/OperatorTable.tsx +++ b/ui/src/components/operator-table/OperatorTable.tsx @@ -129,6 +129,20 @@ export function OperatorTable({ queryBundle }: OperatorTableProps) { [entities, includedPlanIds] ); + const statQuantitySpecs = useMemo(() => { + const quantitySpecs = queryBundle.quantity_specs; + const result: Record = {}; + for (const row of allRows) { + for (const [statKey, quantityName] of Object.entries(row.statQuantities)) { + if (!(statKey in result)) { + const spec = quantitySpecs[quantityName]; + if (spec) result[statKey] = spec; + } + } + } + return result; + }, [allRows, queryBundle.quantity_specs]); + // When the DAG has a selection, narrow the table to just the matching // operator rows. If the selection is non-empty but matches nothing in the // current sibling-plan scope (e.g. a stage node was selected), fall back to @@ -359,6 +373,7 @@ export function OperatorTable({ queryBundle }: OperatorTableProps) { virtualization={VIRTUALIZATION_CONFIG} sorting={sorting} onSortingChange={setSorting} + statQuantitySpecs={statQuantitySpecs} />
diff --git a/ui/src/components/operator-table/types.ts b/ui/src/components/operator-table/types.ts index cdd0d0ab8..7eeea1ff6 100644 --- a/ui/src/components/operator-table/types.ts +++ b/ui/src/components/operator-table/types.ts @@ -15,4 +15,6 @@ export interface OperatorTableRow { itemName: string; itemId: string; stats: Record; + /** Maps stat key → quantity name (key into QueryBundle.quantity_specs) for stats that have one. */ + statQuantities: Record; } diff --git a/ui/src/components/operator-table/utils.ts b/ui/src/components/operator-table/utils.ts index f3a5b8af5..5448be133 100644 --- a/ui/src/components/operator-table/utils.ts +++ b/ui/src/components/operator-table/utils.ts @@ -71,8 +71,10 @@ export function buildOperatorRows( const stats: Record = { duration_s: duration !== null ? Number(duration.toFixed(6)) : null, }; + const statQuantities: Record = {}; for (const stat of parseCustomStatistics(op)) { stats[stat.key] = stat.value; + if (stat.quantity) statQuantities[stat.key] = stat.quantity; } rows.push({ partitionId, @@ -86,6 +88,7 @@ export function buildOperatorRows( itemName, itemId: op.id, stats, + statQuantities, }); } } diff --git a/ui/src/hooks/useQueryPlanVisualization.ts b/ui/src/hooks/useQueryPlanVisualization.ts index a11b2fcc5..baef414e8 100644 --- a/ui/src/hooks/useQueryPlanVisualization.ts +++ b/ui/src/hooks/useQueryPlanVisualization.ts @@ -37,7 +37,7 @@ export const useQueryPlanVisualization = ( try { const dag = getPlanDAG(queryBundle, planId); return { - dagData: { ...dag, queryData: treeData }, + dagData: { ...dag, queryData: treeData, quantitySpecs: queryBundle.quantity_specs }, treeData, error: null, };