diff --git a/domains/query_engine/analyzer/src/plain/legacy/operator.rs b/domains/query_engine/analyzer/src/plain/legacy/operator.rs index ee20bc15b..618a41155 100644 --- a/domains/query_engine/analyzer/src/plain/legacy/operator.rs +++ b/domains/query_engine/analyzer/src/plain/legacy/operator.rs @@ -71,7 +71,15 @@ impl OperatorEntity for Operator { custom_statistics: s .custom_attributes .iter() - .map(|DynamicAttribute { key, value }| (key.clone(), value.clone())) + .map(|DynamicAttribute { key, value }| { + ( + key.clone(), + ui::OperatorStatistic { + value: value.clone(), + quantity: None, + }, + ) + }) .collect(), }); diff --git a/domains/query_engine/ui/src/lib.rs b/domains/query_engine/ui/src/lib.rs index 8264db86e..e73556c0a 100644 --- a/domains/query_engine/ui/src/lib.rs +++ b/domains/query_engine/ui/src/lib.rs @@ -164,10 +164,19 @@ pub struct Plan { pub edges: Vec, } +#[derive(TS, Debug, Serialize)] +pub struct OperatorStatistic { + /// The value of this statistic. + pub value: Option, + /// The key of the [`QuantitySpec`] in [`QueryBundle::quantity_specs`] used + /// to display this statistic. + pub quantity: Option, +} + #[derive(TS, Debug, Serialize)] pub struct OperatorStatistics { - /// Custom statistics - pub custom_statistics: HashMap>, + /// Custom statistics. + pub custom_statistics: HashMap, } #[derive(TS, Debug, Serialize)] @@ -298,7 +307,7 @@ pub struct QueryBundle { /// A list of unique operator type names. pub unique_operator_names: Vec, - /// Quantity specifications for capacity display, keyed by capacity name. + /// Quantity specifications for displaying values, keyed by quantity name. pub quantity_specs: HashMap, /// The number of nanoseconds passed since the Unix epoch at which the diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index e001a747d..123dfdaa9 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use quent_dynamic_attributes::DynamicValue; use quent_events::Event; pub use quent_query_engine_analyzer::QueryEngineModel; use quent_query_engine_analyzer::{ @@ -67,11 +68,77 @@ pub mod view; const MEASURE_TASKS: &str = "tasks"; /// Data-flow measure summing memory bytes held in each (state, location) cell. const MEASURE_BYTES: &str = "bytes"; +const QUANTITY_BYTES: &str = "bytes"; +const QUANTITY_SECONDS: &str = "seconds"; +const BYTE_OPERATOR_STATISTICS: &[&str] = &[ + "average_partition_size_bytes", + "avg_key_length_bytes", + "bloom_filter_size_bytes", + "build_side_bytes", + "bytes_read", + "bytes_written", + "hash_table_size_bytes", + "input_bytes", + "network_bytes_sent", + "output_bytes", + "peak_memory_bytes", + "per_file_bytes_read", + "probe_side_bytes", + "spill_bytes", +]; +const SECOND_OPERATOR_STATISTICS: &[&str] = &[ + "build_time_ns", + "cpu_time_ns", + "decompress_time_ns", + "flush_time_ns", + "hash_time_ns", + "io_wait_ns", + "merge_time_ns", + "network_time_ns", + "partition_time_ns", + "predicate_filter_time_ns", + "probe_time_ns", + "serialization_time_ns", + "wall_time_ns", +]; /// Data-flow dimension key for states that hold no memory resource. const DIMENSION_NONE: &str = "none"; /// Type name of stdlib memory resources as recorded by the model. const MEMORY_TYPE_NAME: &str = "memory"; +fn operator_statistic_quantity(name: &str) -> Option<&'static str> { + BYTE_OPERATOR_STATISTICS + .contains(&name) + .then_some(QUANTITY_BYTES) +} + +fn scale_operator_statistic(name: &str, value: &Option) -> Option { + if !SECOND_OPERATOR_STATISTICS.contains(&name) { + return None; + } + match value { + Some(DynamicValue::U64(nanoseconds)) => { + let seconds = *nanoseconds as f64 / 1_000_000_000.0; + Some(DynamicValue::F64(seconds)) + } + _ => None, + } +} + +fn scaled_operator_statistic_name(name: String) -> String { + name.strip_suffix("_ns").unwrap_or(&name).to_owned() +} + +fn quantity_specs() -> StdHashMap { + [ + ("capacity_bytes".into(), QuantitySpec::bytes()), + (QUANTITY_BYTES.into(), QuantitySpec::bytes()), + (QUANTITY_SECONDS.into(), QuantitySpec::seconds()), + ("unit".into(), QuantitySpec::unit()), + ] + .into() +} + pub struct SimulatorUiAnalyzer { pub model: SimulatorModel, } @@ -205,7 +272,33 @@ impl UiAnalyzer for SimulatorUiAnalyzer { let query = query.to_ui()?; let workers = view.workers().map(|w| (w.id(), w.to_ui(epoch))).collect(); let plans = view.plans().map(|p| (p.id(), p.to_ui())).collect(); - let operators = view.operators().map(|o| (o.id(), o.to_ui(epoch))).collect(); + let operators = view + .operators() + .map(|operator| { + let mut ui_operator = operator.to_ui(epoch); + if let Some(statistics) = &mut ui_operator.statistics { + statistics.custom_statistics = + std::mem::take(&mut statistics.custom_statistics) + .into_iter() + .map(|(name, mut statistic)| { + let name = if let Some(value) = + scale_operator_statistic(&name, &statistic.value) + { + statistic.value = Some(value); + statistic.quantity = Some(QUANTITY_SECONDS.to_owned()); + scaled_operator_statistic_name(name) + } else { + statistic.quantity = + operator_statistic_quantity(&name).map(str::to_owned); + name + }; + (name, statistic) + }) + .collect(); + } + (operator.id(), ui_operator) + }) + .collect(); let ports = view.ports().map(|p| (p.id(), p.to_ui(epoch))).collect(); let unique_operator_names = view .operators() @@ -278,11 +371,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { plan_tree, resource_tree, unique_operator_names, - quantity_specs: [ - ("capacity_bytes".into(), QuantitySpec::bytes()), - ("unit".into(), QuantitySpec::unit()), - ] - .into(), + quantity_specs: quantity_specs(), start_time_unix_ns, duration_s, }) diff --git a/ui/packages/@quent/components/src/dag/DAGChart.tsx b/ui/packages/@quent/components/src/dag/DAGChart.tsx index d2cafa7f7..1d4f5806c 100644 --- a/ui/packages/@quent/components/src/dag/DAGChart.tsx +++ b/ui/packages/@quent/components/src/dag/DAGChart.tsx @@ -48,8 +48,12 @@ 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'; // Edge geometry constants const EDGE_STROKE_WIDTH_DEFAULT = 1.5; @@ -338,6 +342,7 @@ const FlowLayout = ({ isDark, baseColor: operatorColorMap.get(node.type.toLowerCase()), flowBarVisible, + quantitySpecs: data.quantitySpecs, }, style: { width: NODE_LAYOUT_WIDTH, diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx index ff53ebca5..318264e95 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -11,10 +11,16 @@ import { } from '@quent/hooks'; import { DataText } from '../ui/data-text'; import { thinScrollbarClass } from '../ui/thin-scroll'; -import { inferFieldFormatter, isNumericValue } from '@quent/utils'; +import { formatStatWithQuantity, 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?: { [key: string]: QuantitySpec | undefined }; +}) => { const selectedNodeData = useSelectedNodeData(); const dataFlowEnabled = useDataFlowEnabled(); const dataFlowMeta = useDataFlowMeta(); @@ -78,7 +84,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 +101,13 @@ export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => {
{key.replace(/_/g, ' ')}: - {isNumericValue(value) ? inferFieldFormatter(key)(value) : String(value)} + {typeof value === 'number' + ? formatStatWithQuantity( + value, + key, + quantity && quantitySpecs ? quantitySpecs[quantity] : undefined + ) + : String(value)}
)} diff --git a/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts b/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts index 09357709c..f5ed8a71f 100644 --- a/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts +++ b/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts @@ -66,7 +66,17 @@ function makeTagged(variant: string, value: unknown) { function makeOperator(custom_statistics: Record | undefined) { return { - statistics: custom_statistics !== undefined ? { custom_statistics } : undefined, + statistics: + custom_statistics !== undefined + ? { + custom_statistics: Object.fromEntries( + Object.entries(custom_statistics).map(([key, value]) => [ + key, + { value, quantity: null }, + ]) + ), + } + : undefined, }; } @@ -90,6 +100,17 @@ describe('parseCustomStatistics', () => { expect(result).toEqual([{ key: 'rows', value: 42 }]); }); + it('preserves a quantity key', () => { + const op = { + statistics: { + custom_statistics: { + bytes: { value: makeTagged('UInt64', 1024), quantity: 'bytes' }, + }, + }, + }; + expect(parseCustomStatistics(op)).toEqual([{ key: 'bytes', value: 1024, quantity: 'bytes' }]); + }); + it('unwraps a string tagged value', () => { const op = makeOperator({ label: makeTagged('String', 'hello') }); expect(parseCustomStatistics(op)).toEqual([{ key: 'label', value: 'hello' }]); diff --git a/ui/packages/@quent/components/src/lib/queryBundle.utils.ts b/ui/packages/@quent/components/src/lib/queryBundle.utils.ts index c394b2868..66caae381 100644 --- a/ui/packages/@quent/components/src/lib/queryBundle.utils.ts +++ b/ui/packages/@quent/components/src/lib/queryBundle.utils.ts @@ -27,16 +27,20 @@ export function entityRefToEntitiesKey(entityRef: EntityRefKey): keyof QueryEnti return ENTITY_REF_TO_ENTITIES_KEY[entityRef]; } -export function parseCustomStatistics(rawNode: unknown): Array<{ key: string; value: StatValue }> { +export function parseCustomStatistics( + rawNode: unknown +): Array<{ key: string; value: StatValue; quantity?: string }> { 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, - })); + return Object.entries(statistics).map(([key, statistic]) => { + const { value, quantity } = statistic; + return { + key, + value: value ? unwrapTaggedValue(value) : null, + ...(quantity !== null ? { quantity } : {}), + }; + }); } export function parsePortStatistics(rawPort: unknown): Array<{ key: string; value: StatValue }> { diff --git a/ui/packages/@quent/components/src/operator-timeline/types.ts b/ui/packages/@quent/components/src/operator-timeline/types.ts index 240b388d5..bfe293e74 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 17fa3636b..566d3c966 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -181,6 +181,7 @@ function DataCell({ row, stat }: DataCellProps) { onMouseEnter: () => interaction.setHoveredStat(derived.buildHoveredStatInfo(stat)), onMouseLeave: () => interaction.setHoveredStat(null), }; + const fmt = display.formatNumericValue; if (!display.isAggregating) { const val = row.values.get(stat) ?? null; return ( @@ -189,7 +190,7 @@ function DataCell({ row, stat }: DataCellProps) { style={{ backgroundColor: bg, boxShadow: cellHighlight }} {...statCellProps} > - {formatStatValue(val, stat)} + {typeof val === 'number' && fmt ? fmt(val, stat) : formatStatValue(val, stat)} ); } @@ -212,7 +213,9 @@ function DataCell({ row, stat }: DataCellProps) { style={{ backgroundColor: bg, boxShadow: cellHighlight }} {...statCellProps} > - {formatNumericStat(displayVal, stat)} + {fmt && typeof displayVal === 'number' + ? fmt(displayVal, stat) + : formatNumericStat(displayVal, stat)} ); } @@ -247,6 +250,8 @@ interface PivotedStatTableProps { /** Optional controlled sort state, forwarded to the underlying GroupedDataTable. */ sorting?: SortingState; onSortingChange?: OnChangeFn; + /** Optional formatter for numeric stat values; falls back to inferFieldFormatter when absent. */ + formatNumericValue?: (value: number, statName: string) => string; } export function PivotedStatTable({ @@ -267,6 +272,7 @@ export function PivotedStatTable({ onReorderStat, sorting, onSortingChange, + formatNumericValue, }: PivotedStatTableProps) { const [nodePalette] = useNodeColorPalette(); const rowRefs = useRef>(new Map()); @@ -518,8 +524,9 @@ export function PivotedStatTable({ aggMode, colorPalette: nodePalette, darkMode: isDark, + formatNumericValue, }), - [isAggregating, aggMode, nodePalette, isDark] + [isAggregating, aggMode, nodePalette, isDark, formatNumericValue] ); 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 7049085f6..763beeca8 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -86,6 +86,8 @@ export interface PivotTableDisplayConfig { aggMode: AggMode; colorPalette: ContinuousPaletteName; darkMode: boolean; + /** Optional formatter for numeric stat values; falls back to inferFieldFormatter when absent. */ + formatNumericValue?: (value: number, statName: string) => string; } // --- PivotedStatTable types --- diff --git a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx index b33bd4909..b99cd24f3 100644 --- a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx +++ b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx @@ -25,8 +25,8 @@ import { useEffectiveHoveredStat, useSetHighlightedNodeIds, } from '@quent/hooks'; +import { formatStatWithQuantity, type QuantitySpec } from '@quent/utils'; import { parseCustomStatistics } from '../lib/queryBundle.utils'; -import { inferFieldFormatter, isNumericValue } from '@quent/utils'; import { DataText } from '../ui/data-text'; import { NodeFlowBar } from './NodeFlowBar'; @@ -53,6 +53,7 @@ export interface QueryPlanNodeData extends Record { * relayouts exactly once. */ flowBarVisible?: boolean; + quantitySpecs?: { [key: string]: QuantitySpec | undefined }; } const nodeVariants = cva( @@ -108,6 +109,7 @@ export const QueryPlanNode = memo(({ data }: { data: QueryPlanNodeData }) => { const operatorId = data.metadata?.rawNode?.id ?? ''; const isHighlighted = highlightState.ids !== null && highlightState.ids.has(operatorId); const statistics = parseCustomStatistics(data.metadata?.rawNode); + const { quantitySpecs } = data; const [nodeLabelField] = useSelectedNodeLabelField(); const { fieldColor, isDimmed, isSelected, colorField } = useNodeColoring(operatorId, isDark); const [isHoveredLocal, setIsHoveredLocal] = useState(false); @@ -118,14 +120,19 @@ 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 - : isNumericValue(colorFieldValue) - ? inferFieldFormatter(colorField!)(colorFieldValue) + : typeof colorFieldValue === 'number' + ? formatStatWithQuantity( + colorFieldValue, + colorField!, + colorFieldStat?.quantity && quantitySpecs + ? quantitySpecs[colorFieldStat.quantity] + : undefined + ) : String(colorFieldValue); const baseColor = data.baseColor ?? getOperationTypeColor(data.operationType); diff --git a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts index 6932b1f94..25dc85410 100644 --- a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts +++ b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts @@ -14,12 +14,15 @@ import { /** Build a DAGNode whose rawNode carries the given custom_statistics map. */ function makeNode(id: string, stats: Record = {}): DAGNode { + const customStatistics = Object.fromEntries( + Object.entries(stats).map(([key, value]) => [key, { value, quantity: null }]) + ); return { id, label: id, type: 'operator', metadata: { - rawNode: { statistics: { custom_statistics: stats } }, + rawNode: { statistics: { custom_statistics: customStatistics } }, }, }; } 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 20033d55a..302d228b2 100644 --- a/ui/packages/@quent/components/src/services/query-plan/types.ts +++ b/ui/packages/@quent/components/src/services/query-plan/types.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { TreeDataItem } from '../../ui/tree-view'; +import type { DAGNode, DAGEdge, QuantitySpec } from '@quent/utils'; export interface QueryPlanDataItem extends TreeDataItem { queryId?: string; @@ -24,9 +25,10 @@ export type { } from '@quent/utils'; export interface DAGData { - nodes: import('@quent/utils').DAGNode[]; - edges: import('@quent/utils').DAGEdge[]; + nodes: DAGNode[]; + edges: DAGEdge[]; queryData: QueryPlanDataItem[]; + quantitySpecs?: { [key in string]?: 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 90eb8b723..91199ea51 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 b6b8d1cfc..8c637a026 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -387,3 +387,16 @@ 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. + * Falls back to the name-based `inferFieldFormatter` heuristic when no spec is provided. + */ +export function formatStatWithQuantity( + value: number, + key: string, + quantitySpec: QuantitySpec | undefined +): string { + if (quantitySpec) return formatQuantity(value, quantitySpec, 'Occupancy'); + return inferFieldFormatter(key)(value); +} diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 97b391fdc..0d210c06c 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, isNumericValue, } from './formatters'; diff --git a/ui/packages/@quent/utils/src/types/index.ts b/ui/packages/@quent/utils/src/types/index.ts index e7f0d0d87..d7b5f964d 100644 --- a/ui/packages/@quent/utils/src/types/index.ts +++ b/ui/packages/@quent/utils/src/types/index.ts @@ -27,6 +27,7 @@ export type { FsmUsage } from '../../../../../generated/ts-bindings/FsmUsage'; export type { DynamicList } from '../../../../../generated/ts-bindings/DynamicList'; export type { Operator } from '../../../../../generated/ts-bindings/Operator'; export type { OperatorFilter } from '../../../../../generated/ts-bindings/OperatorFilter'; +export type { OperatorStatistic } from '../../../../../generated/ts-bindings/OperatorStatistic'; export type { OperatorStatistics } from '../../../../../generated/ts-bindings/OperatorStatistics'; export type { Plan } from '../../../../../generated/ts-bindings/Plan'; export type { PlanTree } from '../../../../../generated/ts-bindings/PlanTree'; diff --git a/ui/src/components/QueryPlan.tsx b/ui/src/components/QueryPlan.tsx index ebdaa623e..a8492d469 100644 --- a/ui/src/components/QueryPlan.tsx +++ b/ui/src/components/QueryPlan.tsx @@ -47,7 +47,6 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st const planId = useSelectedPlanId(); const setPlanId = useSetSelectedPlanId(); const setHoveredWorkerId = useSetHoveredWorkerId(); - const { data: queryBundle, isLoading: queryBundleLoading, @@ -242,7 +241,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 c00a1a066..520195ff9 100644 --- a/ui/src/components/operator-table/OperatorTable.tsx +++ b/ui/src/components/operator-table/OperatorTable.tsx @@ -8,7 +8,7 @@ import { PivotTableToolbar, getSchemaStatNames, } from '@quent/components'; -import { getOperationTypeColor } from '@quent/utils'; +import { getOperationTypeColor, formatStatWithQuantity } from '@quent/utils'; import type { PivotedRow, PivotedStatTableSchema, @@ -97,7 +97,7 @@ export function OperatorTable({ queryBundle }: OperatorTableProps) { const [hoveredStat, setHoveredStat] = useHoveredStat(); const { theme } = useTheme(); const isDark = theme === THEME_DARK; - const { entities } = queryBundle; + const { entities, quantity_specs: quantitySpecs } = queryBundle; const dagHoveredOperatorId = highlightState.source === 'dag' ? highlightState.primaryOperatorId : null; @@ -129,6 +129,25 @@ export function OperatorTable({ queryBundle }: OperatorTableProps) { [entities, includedPlanIds] ); + const statQuantityNames = useMemo(() => { + const result: Record = {}; + for (const row of allRows) { + for (const [statKey, quantityName] of Object.entries(row.statQuantities)) { + if (!(statKey in result)) result[statKey] = quantityName; + } + } + return result; + }, [allRows]); + + const formatNumericValue = useCallback( + (value: number, statName: string) => { + const quantityName = statQuantityNames[statName]; + const spec = quantityName ? quantitySpecs?.[quantityName] : undefined; + return formatStatWithQuantity(value, statName, spec); + }, + [statQuantityNames, quantitySpecs] + ); + // 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 +378,7 @@ export function OperatorTable({ queryBundle }: OperatorTableProps) { virtualization={VIRTUALIZATION_CONFIG} sorting={sorting} onSortingChange={setSorting} + formatNumericValue={formatNumericValue} /> diff --git a/ui/src/components/operator-table/types.ts b/ui/src/components/operator-table/types.ts index c770697cb..c7a895d12 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 9374b8495..2d5c2f4d4 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 ed06650ee..f8359f7e8 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, };