From 84e9116ceb78929d2c8aea52863b13df30e58f04 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 10:44:45 -0600 Subject: [PATCH 01/20] feat(entities): add timeline detail drawer Open entity state, capacity, and attribute details directly from resource timeline selections. --- .../src/fsm-chart/FsmCapacityChart.tsx | 150 ++++++++ .../@quent/components/src/fsm-chart/index.ts | 5 + .../components/src/gantt-chart/GanttChart.tsx | 22 +- ui/packages/@quent/components/src/index.ts | 4 + .../src/long-entities/LongEntitiesGantt.tsx | 32 +- ui/packages/@quent/utils/src/formatters.ts | 11 + ui/packages/@quent/utils/src/index.ts | 2 + ui/src/components/EntityDetailDrawer.tsx | 61 ++++ ui/src/components/LongEntitiesRow.tsx | 23 +- ui/src/components/QueryResourceTree.tsx | 41 ++- .../entities-table/EntityDetailPanel.tsx | 320 ++++++++++++++++++ 11 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx create mode 100644 ui/packages/@quent/components/src/fsm-chart/index.ts create mode 100644 ui/src/components/EntityDetailDrawer.tsx create mode 100644 ui/src/components/entities-table/EntityDetailPanel.tsx diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx new file mode 100644 index 000000000..1e9a90de9 --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import EChartsReactCore from 'echarts-for-react/lib/core'; +import type { FsmTransition } from '@quent/utils'; +import { bigintToChartNumber, formatBytes, isBytesStat } from '@quent/utils'; +import { echarts } from '../lib/echarts'; +import { useChartResize } from '../lib/useChartResize'; +import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; + +const CHART_HEIGHT = 90; + +interface CapacitySeries { + label: string; + // Full-length array aligned to transitions — null where no reading exists + data: Array; + // Original bigint values for lossless tooltip formatting + rawData: Array; +} + +export interface FsmCapacityChartProps { + transitions: FsmTransition[]; + isDark: boolean; + resourceLabel: (id: string) => string; +} + +export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapacityChartProps) { + const { themeName } = useTimelineEchartsTheme(isDark); + const { handleChartReady } = useChartResize(); + + const { series, stateLabels } = useMemo(() => { + const n = transitions.length; + const stateLabels = transitions.map((t, i) => `${i + 1}. ${t.name}`); + + // Build per-resource full-length arrays (null = no reading at that state) + const dataMap = new Map>(); + const rawMap = new Map>(); + const labelMap = new Map(); + + transitions.forEach((t, i) => { + t.usages.forEach(usage => { + const resourceName = resourceLabel(usage.resource); + usage.capacities.forEach(([name, cap]) => { + if (cap == null || !isBytesStat(name)) return; + const key = `${usage.resource} ${name}`; + if (!dataMap.has(key)) { + dataMap.set(key, Array(n).fill(null)); + rawMap.set(key, Array(n).fill(null)); + labelMap.set(key, name === 'capacity_bytes' ? resourceName : `${resourceName} ${name}`); + } + dataMap.get(key)![i] = bigintToChartNumber(cap); + rawMap.get(key)![i] = cap; + }); + }); + }); + + // Only show resources with readings in at least 2 states + const series: CapacitySeries[] = [...dataMap.entries()] + .filter(([, data]) => data.filter(v => v !== null).length >= 2) + .map(([key, data]) => ({ + label: labelMap.get(key) ?? key, + data, + rawData: rawMap.get(key) ?? Array(n).fill(null), + })); + + return { series, stateLabels }; + }, [transitions, resourceLabel]); + + const option = useMemo( + () => ({ + animation: false, + grid: { left: 52, right: 8, top: 8, bottom: 36 }, + xAxis: { + type: 'category' as const, + data: stateLabels, + boundaryGap: false, + axisLabel: { + show: true, + fontSize: 9, + interval: 0, + // Show only the state number to save space; full name is in the tooltip + formatter: (_val: string, idx: number) => String(idx + 1), + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + axisLabel: { + show: true, + fontSize: 9, + formatter: (v: number) => formatBytes(v, 0), + }, + splitLine: { show: true, lineStyle: { opacity: 0.25 } }, + minInterval: 1, + }, + tooltip: { + trigger: 'axis' as const, + formatter: ( + params: Array<{ + seriesName: string; + value: number | null; + dataIndex: number; + seriesIndex: number; + }> + ) => { + const idx = params[0]?.dataIndex ?? 0; + const stateName = transitions[idx]?.name ?? ''; + const lines = params + .filter(p => p.value != null) + .map(p => { + const raw = series[p.seriesIndex]?.rawData[idx]; + return `${p.seriesName}: ${formatBytes(raw ?? p.value!)}`; + }); + if (lines.length === 0) return ''; + return [`${idx + 1}. ${stateName}`, ...lines].join('
'); + }, + }, + series: series.map(s => ({ + type: 'line' as const, + name: s.label, + data: s.data, + connectNulls: false, + step: 'end' as const, + symbol: 'circle', + symbolSize: 5, + lineStyle: { width: 1.5 }, + })), + }), + [series, stateLabels, transitions] + ); + + if (series.length === 0) return null; + + return ( +
+ +
+ ); +} diff --git a/ui/packages/@quent/components/src/fsm-chart/index.ts b/ui/packages/@quent/components/src/fsm-chart/index.ts new file mode 100644 index 000000000..656719665 --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { FsmCapacityChart } from './FsmCapacityChart'; +export type { FsmCapacityChartProps } from './FsmCapacityChart'; diff --git a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx index 6e2828f79..139d2da90 100644 --- a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx +++ b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx @@ -47,6 +47,8 @@ export interface GanttChartProps { onEvents?: EChartsEvents; gridSpacing?: GanttGridSpacing; renderTooltip?: (hover: GanttHover | null) => ReactNode; + /** Called when the user clicks the chart background (not a series item). */ + onBackgroundClick?: () => void; } export function GanttChart({ @@ -63,6 +65,7 @@ export function GanttChart({ onEvents, gridSpacing, renderTooltip, + onBackgroundClick, }: GanttChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const [hover, setHover] = useState(null); @@ -115,15 +118,32 @@ export function GanttChart({ wrapperRef.current ?? undefined ); const detachHover = renderTooltip ? observeGanttHover(instance, setHover) : undefined; + + // zrender fires click for ALL clicks; target is null when background is clicked + type ZrEvent = { target: unknown }; + const zr = ( + instance as unknown as { + getZr: () => { + on: (e: string, h: (ev: ZrEvent) => void) => void; + off: (e: string, h: (ev: ZrEvent) => void) => void; + }; + } + ).getZr?.(); + const handleZrClick = (e: ZrEvent) => { + if (!e.target) onBackgroundClick?.(); + }; + zr?.on('click', handleZrClick); + const cleanup = () => { unregisterAxisPointerSync(instance); detachWheelNavigation(); detachHover?.(); + zr?.off('click', handleZrClick); if (chartCleanupRef.current === cleanup) chartCleanupRef.current = null; }; chartCleanupRef.current = cleanup; }, - [attachWheelNavigation, renderTooltip] + [attachWheelNavigation, renderTooltip, onBackgroundClick] ); const { handleChartReady, instanceRef } = useChartConnect({ diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 88fb1b965..06d5b8060 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -242,6 +242,10 @@ export { } from './pivot-table/utils'; export type { GroupIndexDef, RowWithGroupKeys } from './pivot-table/utils'; +// ─── FSM chart components ───────────────────────────────────────────────────── +export { FsmCapacityChart } from './fsm-chart/FsmCapacityChart'; +export type { FsmCapacityChartProps } from './fsm-chart/FsmCapacityChart'; + // ─── Long-entities components ───────────────────────────────────────────────── export { LongEntitiesGantt, diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 958f4df67..9d13c5623 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -41,6 +41,11 @@ export interface LongEntitiesGanttProps { height?: number; /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; + onEntityClick?: (entry: LongEntityEntry) => void; + /** When set, dims all entity bars except the one with this entity ID. */ + selectedEntityId?: string; + /** Called when the user clicks the chart background (not an entity bar). */ + onBackgroundClick?: () => void; } export function LongEntitiesGantt({ @@ -48,6 +53,9 @@ export function LongEntitiesGantt({ durationSeconds, height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, + onEntityClick, + selectedEntityId, + onBackgroundClick, }: LongEntitiesGanttProps) { const { textColor } = useTimelineEchartsTheme(isDark); const zoomRange = useZoomRange(); @@ -121,6 +129,10 @@ export function LongEntitiesGantt({ const clippedShape = clipBound ? clipRectByRect(rectShape, clipBound) : rectShape; if (!clippedShape) return null; + const hasSelection = selectedEntityId != null; + const isSelected = hasSelection && entry.entityId === selectedEntityId; + const opacity = hasSelection && !isSelected ? 0.3 : 1; + const color = segment.color; const isFirst = datum!.segmentIndex === 0; const isLast = datum!.segmentIndex === entry.segments.length - 1; @@ -140,6 +152,7 @@ export function LongEntitiesGantt({ fill: withOpacity(color, MARK_AREA_FILL_OPACITY), stroke: withOpacity(color, MARK_AREA_BORDER_OPACITY), lineWidth: 1, + opacity, }, }; @@ -158,6 +171,7 @@ export function LongEntitiesGantt({ fill: textColor, overflow: 'truncate' as const, width: Math.max(0, clippedShape.width - 6), + opacity, }, }, ] @@ -165,9 +179,22 @@ export function LongEntitiesGantt({ return { type: 'group' as const, children: [rect, ...labelChildren] }; }, - [entries, customSeriesData, textColor] + [entries, customSeriesData, textColor, selectedEntityId] ); + const onEvents = useMemo(() => { + if (!onEntityClick) return undefined; + return { + click: (params: { dataIndex: number; seriesName?: string }) => { + if (params.seriesName !== SERIES_NAME) return; + const datum = customSeriesData[params.dataIndex]; + if (!datum) return; + const entry = entries[datum.entryIndex]; + if (entry) onEntityClick(entry); + }, + }; + }, [onEntityClick, customSeriesData, entries]); + return ( ); } diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts index 8c637a026..c3348d064 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -259,6 +259,17 @@ export function formatBytes(value: number | bigint, decimals = 1): string { return formatWithPrefix(value, 'B', 'Iec', decimals); } +/** + * Convert a bigint to a JS number safe for use as a chart data point. + * Values within Number.MAX_SAFE_INTEGER are converted exactly. Larger values + * are scaled to the nearest KiB to stay within safe integer range (preserving + * precision up to ~9 EiB). + */ +export function bigintToChartNumber(n: bigint): number { + if (n <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(n); + return Number(n >> 10n) * 1024; +} + /** Bytes-like statistic names (pivot tables, DAG field labels). */ export function isBytesStat(name: string): boolean { return ( diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 0d210c06c..14be4228f 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -46,6 +46,8 @@ export { inferFieldFormatter, formatStatWithQuantity, isNumericValue, + isBytesStat, + bigintToChartNumber, } from './formatters'; // Rust-generated TypeScript types diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx new file mode 100644 index 000000000..ddea41ba2 --- /dev/null +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import { Button } from '@quent/components'; +import type { FiniteStateMachine } from '@quent/utils'; +import { EntityDetailPanel } from './entities-table/EntityDetailPanel'; + +interface EntityDetailDrawerProps { + fsm: FiniteStateMachine | null; + resourceLabel: (id: string) => string; + operatorLabel: (id: string) => string; + onClose: () => void; + stateColorFn?: (name: string) => string; +} + +export function EntityDetailDrawer({ + fsm, + resourceLabel, + operatorLabel, + onClose, + stateColorFn, +}: EntityDetailDrawerProps) { + useEffect(() => { + if (!fsm) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [fsm, onClose]); + + return createPortal( +
+
+ Entity details + +
+
+ +
+
, + document.body + ); +} diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index afa714d5f..ac2f18d35 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useInfiniteEntityList } from '@quent/client'; import { useDebouncedZoomRange, useSelectedNodeIds } from '@quent/hooks'; -import type { FsmTypeDecl } from '@quent/utils'; +import type { FsmTypeDecl, FiniteStateMachine } from '@quent/utils'; import { Button, LONG_ENTITIES_TIMELINE_HEIGHT, @@ -12,6 +12,7 @@ import { Skeleton, buildLongEntityEntries, getLongEntitiesThreshold, + type LongEntityEntry, } from '@quent/components'; const ENTITIES_PER_PAGE = 100; @@ -26,6 +27,9 @@ type LongEntitiesRowProps = { isDark: boolean; /** Defaults to all states; resource scope keeps states used on this row's resource. */ fsmStateScope?: 'all' | 'resource'; + onEntitySelect?: (fsm: FiniteStateMachine) => void; + selectedEntityId?: string; + onBackgroundClick?: () => void; }; /** @@ -41,6 +45,9 @@ export function LongEntitiesRow({ fsmTypes, isDark, fsmStateScope = 'resource', + onEntitySelect, + selectedEntityId, + onBackgroundClick, }: LongEntitiesRowProps) { const selectedNodeIds = useSelectedNodeIds(); const debouncedZoomRange = useDebouncedZoomRange(); @@ -77,6 +84,15 @@ export function LongEntitiesRow({ ); const totalEntities = data?.pages[data.pages.length - 1]?.total ?? entities.length; + const handleEntityClick = useCallback( + (entry: LongEntityEntry) => { + if (!onEntitySelect) return; + const fsm = entities.find(e => e.id === entry.entityId); + if (fsm) onEntitySelect(fsm); + }, + [entities, onEntitySelect] + ); + if (!data && isFetching) { return (
{hasNextPage && !isPlaceholderData && ( diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 5baed8754..415b8738c 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Column, TreeTable } from '@quent/components'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useAtom } from 'jotai'; import { useHighlightedItemIds, useBulkTimelines, useHydrateTimelineAtoms } from '@quent/hooks'; @@ -46,6 +46,9 @@ import { resourceIdFromLongEntitiesRowId, } from '@quent/components'; import { LongEntitiesRow } from '@/components/LongEntitiesRow'; +import { EntityDetailDrawer } from '@/components/EntityDetailDrawer'; +import type { FiniteStateMachine } from '@quent/utils'; +import { createFsmTypeColorFn } from '@quent/utils'; function getRootResourceGroupId(resourceTree: ResourceTree): string | null { if (!('ResourceGroup' in resourceTree)) return null; @@ -133,6 +136,29 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr const [selectedTypes, setSelectedTypes] = useAtom(selectedTypesAtom); const [selectedFsmTypes, setSelectedFsmTypes] = useAtom(selectedFsmTypesAtom); + const [drawerFsm, setDrawerFsm] = useState(null); + const closeDrawer = useCallback(() => setDrawerFsm(null), []); + + const stateColorFn = useMemo( + () => createFsmTypeColorFn(entities.fsm_types, isDark ? 'dark' : 'light'), + [entities.fsm_types, isDark] + ); + + const resourceLabel = useCallback( + (id: string) => { + const r = entities.resources[id]; + return r ? `${r.instance_name} (${r.type_name})` : id; + }, + [entities.resources] + ); + const operatorLabel = useCallback( + (id: string) => { + const op = entities.operators[id]; + return op ? (op.instance_name ?? op.operator_type_name ?? id) : id; + }, + [entities.operators] + ); + const startTime = queryBundle.start_time_unix_ns; const durationSeconds = queryBundle.duration_s; const startTimeMs = useMemo(() => nanosToMs(startTime), [startTime]); @@ -329,6 +355,9 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr durationSeconds={durationSeconds} fsmTypes={entities.fsm_types} isDark={isDark} + onEntitySelect={setDrawerFsm} + selectedEntityId={drawerFsm?.id} + onBackgroundClick={closeDrawer} /> ); } @@ -364,6 +393,9 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr queryBundle, handleZoomChange, operatorEntriesByWorker, + setDrawerFsm, + drawerFsm, + closeDrawer, ]); return ( @@ -383,6 +415,13 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr rowHeight={DEFAULT_TIMELINE_HEIGHT} />
+ ); } diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx new file mode 100644 index 000000000..ef32ac0f3 --- /dev/null +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { thinScrollbarClass, FsmCapacityChart, PointerTooltipPortal } from '@quent/components'; +import type { PointerPosition } from '@quent/components'; +import { + formatAttributeValue, + formatDuration, + formatBytes, + getColorForKey, + isBytesStat, + unwrapTaggedValue, +} from '@quent/utils'; +import type { DynamicAttribute, FiniteStateMachine } from '@quent/utils'; +import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; + +interface EntityDetailPanelProps { + fsm: FiniteStateMachine | null; + resourceLabel: (id: string) => string; + operatorLabel: (id: string) => string; + stateColorFn?: (name: string) => string; +} + +export function EntityDetailPanel({ + fsm, + resourceLabel, + operatorLabel, + stateColorFn, +}: EntityDetailPanelProps) { + const { theme } = useTheme(); + const paletteTheme = theme === THEME_DARK ? ('dark' as const) : ('light' as const); + const [copied, setCopied] = useState(false); + const [barTooltip, setBarTooltip] = useState<{ name: string; pct: number } | null>(null); + const [barPointer, setBarPointer] = useState(null); + + if (!fsm) { + return ( +
+ Select an entity to view its states. +
+ ); + } + + const firstTs = fsm.transitions[0]?.timestamp ?? 0; + const lastTs = fsm.transitions[fsm.transitions.length - 1]?.timestamp ?? firstTs; + const totalSpanMs = (lastTs - firstTs) * 1000; + + // Precompute per-transition durations (null for the final state) + const durations = fsm.transitions.map((t, i) => { + const next = fsm.transitions[i + 1]; + return next ? (next.timestamp - t.timestamp) * 1000 : null; + }); + + // Aggregate total time per state name (insertion order = first appearance) + const stateTimeMs = new Map(); + fsm.transitions.forEach((t, i) => { + const d = durations[i]; + if (d != null) { + stateTimeMs.set(t.name, (stateTimeMs.get(t.name) ?? 0) + d); + } + }); + + // Find the state that consumed the most time + let dominantState: { name: string; pct: number; color: string } | null = null; + if (totalSpanMs > 0 && stateTimeMs.size > 0) { + let maxMs = 0; + let maxName = ''; + stateTimeMs.forEach((ms, name) => { + if (ms > maxMs) { + maxMs = ms; + maxName = name; + } + }); + dominantState = { + name: maxName, + pct: (maxMs / totalSpanMs) * 100, + color: stateColorFn ? stateColorFn(maxName) : getColorForKey(maxName, paletteTheme), + }; + } + + // Find data volume from derived attributes (last bytes-stat with a numeric value) + let dataVolume: string | null = null; + for (let i = fsm.transitions.length - 1; i >= 0; i--) { + for (const attr of fsm.transitions[i]!.derived_attributes) { + if (isBytesStat(attr.key) && attr.value != null) { + const raw = unwrapTaggedValue(attr.value); + if (typeof raw === 'number' || typeof raw === 'bigint') { + dataVolume = formatBytes(raw); + break; + } + } + } + if (dataVolume) break; + } + + function copyId() { + void navigator.clipboard.writeText(fsm!.id); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+ {/* Compact header: name + type badge on one line, UUID + copy on second */} +
+
+ {fsm.instance_name} + + {fsm.type_name} + +
+
+ + {fsm.id} + + +
+
+ + {/* Summary strip */} +
+
+ Total span + {formatDuration(totalSpanMs)} +
+ {dominantState && ( +
+ Dominant state + + {dominantState.name} · {dominantState.pct.toFixed(1)}% + +
+ )} + {dataVolume && ( +
+ Data volume + {dataVolume} +
+ )} + {totalSpanMs > 0 && stateTimeMs.size > 0 && ( +
+ {[...stateTimeMs.entries()].map(([name, ms]) => { + const color = stateColorFn ? stateColorFn(name) : getColorForKey(name, paletteTheme); + const pct = (ms / totalSpanMs) * 100; + return ( +
{ + setBarTooltip({ name, pct }); + setBarPointer({ clientX: e.clientX, clientY: e.clientY }); + }} + onMouseMove={e => setBarPointer({ clientX: e.clientX, clientY: e.clientY })} + onMouseLeave={() => { + setBarTooltip(null); + setBarPointer(null); + }} + onFocus={e => { + const rect = e.currentTarget.getBoundingClientRect(); + setBarTooltip({ name, pct }); + setBarPointer({ clientX: rect.left + rect.width / 2, clientY: rect.top }); + }} + onBlur={() => { + setBarTooltip(null); + setBarPointer(null); + }} + /> + ); + })} +
+ )} + + {barTooltip && ( +
+ {barTooltip.name} + {barTooltip.pct.toFixed(1)}% +
+ )} +
+
+ + + +
    + {fsm.transitions.map((transition, index) => { + const durationMs = durations[index] ?? null; + const isBottleneck = + durationMs != null && totalSpanMs > 0 && durationMs / totalSpanMs > 0.5; + const stateColor = stateColorFn + ? stateColorFn(transition.name) + : getColorForKey(transition.name, paletteTheme); + const pct = + durationMs != null && totalSpanMs > 0 + ? Math.min(100, (durationMs / totalSpanMs) * 100) + : null; + + return ( +
  1. + {/* State name + duration (prominent) + absolute timestamp (secondary) */} +
    + + {index + 1}. {transition.name} + +
    + {durationMs != null && ( + + {formatDuration(durationMs)} + + )} + + @{transition.timestamp.toFixed(3)}s + +
    +
    + + {/* Proportional duration bar */} + {pct != null && ( +
    +
    +
    + )} + + {transition.usages.length > 0 && ( +
      + {transition.usages.map((usage, usageIndex) => ( +
    • + {resourceLabel(usage.resource)} + {usage.capacities.map(([name, capacity], capacityIndex) => ( + + {name} + {capacity != null + ? `=${isBytesStat(name) ? formatBytes(capacity) : String(capacity)}` + : ''} + + ))} +
    • + ))} +
    + )} + {transition.attributes.length > 0 && ( + + )} + {transition.derived_attributes.length > 0 && ( + + )} +
  2. + ); + })} +
+
+ ); +} + +function AttributeRows({ + attributes, + derived, + operatorLabel, +}: { + attributes: DynamicAttribute[]; + derived?: boolean; + operatorLabel: (id: string) => string; +}) { + return ( +
    + {attributes.map((attribute, index) => { + const { label, value } = resolveAttributeDisplay(attribute, operatorLabel); + return ( +
  • + {label} + {value} +
  • + ); + })} +
+ ); +} + +function resolveAttributeDisplay( + attribute: DynamicAttribute, + operatorLabel: (id: string) => string +): { label: string; value: string } { + if (attribute.key === 'operator_id') { + const raw = unwrapTaggedValue(attribute.value); + if (typeof raw === 'string') { + return { label: 'operator', value: operatorLabel(raw) }; + } + } + return { label: attribute.key, value: formatAttributeValue(attribute.key, attribute.value) }; +} From d5deca5cbc9fef87057e7f7eb9ffa19b7c44cc82 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 12:53:18 -0600 Subject: [PATCH 02/20] refactor: use shadcn ui drawer --- ui/packages/@quent/components/package.json | 3 +- ui/packages/@quent/components/src/index.ts | 12 ++ .../@quent/components/src/ui/drawer.tsx | 114 +++++++++++++++++ ui/pnpm-lock.yaml | 116 ++++++++++++++++++ ui/src/components/EntityDetailDrawer.test.tsx | 48 ++++++++ ui/src/components/EntityDetailDrawer.tsx | 82 +++++++------ 6 files changed, 338 insertions(+), 37 deletions(-) create mode 100644 ui/packages/@quent/components/src/ui/drawer.tsx create mode 100644 ui/src/components/EntityDetailDrawer.test.tsx diff --git a/ui/packages/@quent/components/package.json b/ui/packages/@quent/components/package.json index 4410dd95b..14a322dd6 100644 --- a/ui/packages/@quent/components/package.json +++ b/ui/packages/@quent/components/package.json @@ -22,7 +22,8 @@ "@quent/client": "workspace:*", "@quent/hooks": "workspace:*", "@quent/utils": "workspace:*", - "d3-dag": "^1.2.1" + "d3-dag": "^1.2.1", + "vaul": "^1.1.2" }, "peerDependencies": { "@tanstack/react-query": "^5.0.0", diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 06d5b8060..65986bcc9 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -30,6 +30,18 @@ export { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from './ui/dropdown-menu'; +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} from './ui/drawer'; export { HoverCard, HoverCardTrigger, HoverCardContent } from './ui/hover-card'; export { Input } from './ui/input'; export { diff --git a/ui/packages/@quent/components/src/ui/drawer.tsx b/ui/packages/@quent/components/src/ui/drawer.tsx new file mode 100644 index 000000000..7b0a89def --- /dev/null +++ b/ui/packages/@quent/components/src/ui/drawer.tsx @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from 'react'; +import { Drawer as DrawerPrimitive } from 'vaul'; +import { cn } from '@quent/utils'; + +function Drawer(props: React.ComponentProps) { + return ; +} + +function DrawerTrigger(props: React.ComponentProps) { + return ; +} + +function DrawerPortal(props: React.ComponentProps) { + return ; +} + +function DrawerClose(props: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index f8ec93edf..bdb00fa3e 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: d3-dag: specifier: ^1.2.1 version: 1.2.1 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) devDependencies: '@tanstack/react-query': specifier: 'catalog:' @@ -1163,6 +1166,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -1260,6 +1276,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.13': resolution: {integrity: sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==} peerDependencies: @@ -1273,6 +1298,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -1404,6 +1442,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -4097,6 +4148,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + verkit@0.1.2: resolution: {integrity: sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg==} engines: {node: '>=18.12.0'} @@ -5069,6 +5126,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: react: 19.2.7 @@ -5153,6 +5233,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-focus-scope@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) @@ -5164,6 +5250,17 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -5330,6 +5427,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -7786,6 +7893,15 @@ snapshots: dependencies: react: 19.2.7 + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + verkit@0.1.2: {} vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0): diff --git a/ui/src/components/EntityDetailDrawer.test.tsx b/ui/src/components/EntityDetailDrawer.test.tsx new file mode 100644 index 000000000..061f98a01 --- /dev/null +++ b/ui/src/components/EntityDetailDrawer.test.tsx @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { EntityDetailDrawer } from './EntityDetailDrawer'; + +vi.mock('./entities-table/EntityDetailPanel', () => ({ + EntityDetailPanel: () =>
Entity detail content
, +})); + +const fsm = { + id: 'entity-1', + type_name: 'Task', + instance_name: 'Task 1', + transitions: [], +}; + +describe('EntityDetailDrawer', () => { + it('is non-modal and closes when the background is clicked', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + + render( + <> + + id} + operatorLabel={id => id} + onClose={onClose} + /> + + ); + + expect(screen.getByRole('dialog', { name: 'Entity details' })).not.toHaveAttribute( + 'aria-modal', + 'true' + ); + expect(document.querySelector('[data-slot="drawer-overlay"]')).not.toBeInTheDocument(); + + await waitFor(() => expect(document.body).toHaveStyle({ pointerEvents: 'auto' })); + await user.click(screen.getByText('Background action')); + + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx index ddea41ba2..51dd7f977 100644 --- a/ui/src/components/EntityDetailDrawer.tsx +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -1,10 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect } from 'react'; -import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; -import { Button } from '@quent/components'; +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerPortal, + DrawerTitle, +} from '@quent/components'; import type { FiniteStateMachine } from '@quent/utils'; import { EntityDetailPanel } from './entities-table/EntityDetailPanel'; @@ -23,39 +29,43 @@ export function EntityDetailDrawer({ onClose, stateColorFn, }: EntityDetailDrawerProps) { - useEffect(() => { - if (!fsm) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [fsm, onClose]); - - return createPortal( -
{ + if (!open) onClose(); + }} + direction="right" + modal={false} + noBodyStyles + shouldScaleBackground={false} > -
- Entity details - -
-
- -
-
, - document.body + + +
+ Entity details + + Details for the selected entity. + + + + +
+
+ +
+
+
+ ); } From a89e4d5b47551da6e3e4177c51fd062162c8dae4 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 15:22:06 -0600 Subject: [PATCH 03/20] refactor: components for different bits of data, more useful timestamp --- .../@quent/utils/src/formatters.test.ts | 16 +++ ui/packages/@quent/utils/src/formatters.ts | 15 ++- ui/src/components/EntityDetailDrawer.test.tsx | 3 + ui/src/components/EntityDetailDrawer.tsx | 5 +- ui/src/components/QueryResourceTree.tsx | 1 + .../entities-table/EntityDetailPanel.tsx | 105 +++--------------- .../entities-table/ResourceUsageList.test.tsx | 72 ++++++++++++ .../entities-table/ResourceUsageList.tsx | 60 ++++++++++ .../TransitionAttributes.test.tsx | 33 ++++++ .../entities-table/TransitionAttributes.tsx | 76 +++++++++++++ 10 files changed, 293 insertions(+), 93 deletions(-) create mode 100644 ui/src/components/entities-table/ResourceUsageList.test.tsx create mode 100644 ui/src/components/entities-table/ResourceUsageList.tsx create mode 100644 ui/src/components/entities-table/TransitionAttributes.test.tsx create mode 100644 ui/src/components/entities-table/TransitionAttributes.tsx diff --git a/ui/packages/@quent/utils/src/formatters.test.ts b/ui/packages/@quent/utils/src/formatters.test.ts index 8fe360796..1503a11d4 100644 --- a/ui/packages/@quent/utils/src/formatters.test.ts +++ b/ui/packages/@quent/utils/src/formatters.test.ts @@ -109,6 +109,22 @@ describe('formatDurationForWindow', () => { // windowMs=1000 → resolution=1ms, unitMs=1000 → ratio=0.001 → decimals=3 expect(formatDurationForWindow(2000, 1000)).toBe('2.000s'); }); + + it('adapts precision across entity-scale time ranges', () => { + expect(formatDurationForWindow(60_000, 120_000)).toBe('1.000min'); + expect(formatDurationForWindow(5_000, 10_000)).toBe('5.00s'); + expect(formatDurationForWindow(5, 10)).toBe('5.00ms'); + expect(formatDurationForWindow(0.005, 0.01)).toBe('5.00µs'); + expect(formatDurationForWindow(0.000005, 0.00001)).toBe('5.00ns'); + }); + + it('can preserve narrow-window precision for large elapsed timestamps', () => { + const start = formatDurationForWindow(60_000, 0.00001, 15); + const fiveNanosecondsLater = formatDurationForWindow(60_000.000005, 0.00001, 15); + + expect(start).toBe('1.0000000000000min'); + expect(fiveNanosecondsLater).toBe('1.0000000000833min'); + }); }); // --------------------------------------------------------------------------- diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts index c3348d064..de32a80a6 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -41,8 +41,13 @@ export function formatDuration(ms: number, decimals: number = 2): string { * produce distinct formatted strings. * @param ms - Duration in milliseconds * @param windowMs - Visible time window width in milliseconds + * @param maxDecimals - Maximum precision to display */ -export function formatDurationForWindow(ms: number, windowMs: number): string { +export function formatDurationForWindow( + ms: number, + windowMs: number, + maxDecimals: number = 6 +): string { const absMs = Math.abs(ms); const resolution = Math.abs(windowMs) / 1000; @@ -57,7 +62,9 @@ export function formatDurationForWindow(ms: number, windowMs: number): string { const resolutionInUnit = resolution / unitMs; const decimals = - resolutionInUnit > 0 ? Math.min(6, Math.max(0, Math.ceil(-Math.log10(resolutionInUnit)))) : 2; + resolutionInUnit > 0 + ? Math.min(maxDecimals, Math.max(0, Math.ceil(-Math.log10(resolutionInUnit)))) + : Math.min(2, maxDecimals); return formatDuration(ms, decimals); } @@ -389,7 +396,7 @@ export function inferFieldFormatter(fieldName: string): (value: number | bigint) * Selects the appropriate prefix system based on the capacity kind. */ export function formatQuantity( - value: number, + value: number | bigint, spec: QuantitySpec, kind: CapacityKind, decimals: number = 2 @@ -404,7 +411,7 @@ export function formatQuantity( * Falls back to the name-based `inferFieldFormatter` heuristic when no spec is provided. */ export function formatStatWithQuantity( - value: number, + value: number | bigint, key: string, quantitySpec: QuantitySpec | undefined ): string { diff --git a/ui/src/components/EntityDetailDrawer.test.tsx b/ui/src/components/EntityDetailDrawer.test.tsx index 061f98a01..e619256aa 100644 --- a/ui/src/components/EntityDetailDrawer.test.tsx +++ b/ui/src/components/EntityDetailDrawer.test.tsx @@ -4,6 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import type { EntityRef, QueryBundle } from '@quent/utils'; import { EntityDetailDrawer } from './EntityDetailDrawer'; vi.mock('./entities-table/EntityDetailPanel', () => ({ @@ -16,6 +17,7 @@ const fsm = { instance_name: 'Task 1', transitions: [], }; +const queryBundle = {} as QueryBundle; describe('EntityDetailDrawer', () => { it('is non-modal and closes when the background is clicked', async () => { @@ -30,6 +32,7 @@ describe('EntityDetailDrawer', () => { resourceLabel={id => id} operatorLabel={id => id} onClose={onClose} + queryBundle={queryBundle} /> ); diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx index 51dd7f977..f162e47f1 100644 --- a/ui/src/components/EntityDetailDrawer.tsx +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -11,7 +11,7 @@ import { DrawerPortal, DrawerTitle, } from '@quent/components'; -import type { FiniteStateMachine } from '@quent/utils'; +import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { EntityDetailPanel } from './entities-table/EntityDetailPanel'; interface EntityDetailDrawerProps { @@ -20,6 +20,7 @@ interface EntityDetailDrawerProps { operatorLabel: (id: string) => string; onClose: () => void; stateColorFn?: (name: string) => string; + queryBundle: QueryBundle; } export function EntityDetailDrawer({ @@ -28,6 +29,7 @@ export function EntityDetailDrawer({ operatorLabel, onClose, stateColorFn, + queryBundle, }: EntityDetailDrawerProps) { return (
diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 415b8738c..957fa3cf8 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -421,6 +421,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr operatorLabel={operatorLabel} onClose={closeDrawer} stateColorFn={stateColorFn} + queryBundle={queryBundle} />
); diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index ef32ac0f3..e3d078f9a 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -6,21 +6,24 @@ import { Check, Copy } from 'lucide-react'; import { thinScrollbarClass, FsmCapacityChart, PointerTooltipPortal } from '@quent/components'; import type { PointerPosition } from '@quent/components'; import { - formatAttributeValue, formatDuration, + formatDurationForWindow, formatBytes, getColorForKey, isBytesStat, unwrapTaggedValue, } from '@quent/utils'; -import type { DynamicAttribute, FiniteStateMachine } from '@quent/utils'; +import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; +import { ResourceUsageList } from './ResourceUsageList'; +import { TransitionAttributes } from './TransitionAttributes'; interface EntityDetailPanelProps { fsm: FiniteStateMachine | null; resourceLabel: (id: string) => string; operatorLabel: (id: string) => string; stateColorFn?: (name: string) => string; + queryBundle: QueryBundle; } export function EntityDetailPanel({ @@ -28,6 +31,7 @@ export function EntityDetailPanel({ resourceLabel, operatorLabel, stateColorFn, + queryBundle, }: EntityDetailPanelProps) { const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? ('dark' as const) : ('light' as const); @@ -80,21 +84,6 @@ export function EntityDetailPanel({ }; } - // Find data volume from derived attributes (last bytes-stat with a numeric value) - let dataVolume: string | null = null; - for (let i = fsm.transitions.length - 1; i >= 0; i--) { - for (const attr of fsm.transitions[i]!.derived_attributes) { - if (isBytesStat(attr.key) && attr.value != null) { - const raw = unwrapTaggedValue(attr.value); - if (typeof raw === 'number' || typeof raw === 'bigint') { - dataVolume = formatBytes(raw); - break; - } - } - } - if (dataVolume) break; - } - function copyId() { void navigator.clipboard.writeText(fsm!.id); setCopied(true); @@ -139,12 +128,6 @@ export function EntityDetailPanel({
)} - {dataVolume && ( -
- Data volume - {dataVolume} -
- )} {totalSpanMs > 0 && stateTimeMs.size > 0 && (
{[...stateTimeMs.entries()].map(([name, ms]) => { @@ -232,7 +215,7 @@ export function EntityDetailPanel({ )} - @{transition.timestamp.toFixed(3)}s + @{formatDurationForWindow(transition.timestamp * 1000, totalSpanMs, 15)}
@@ -247,33 +230,16 @@ export function EntityDetailPanel({ )} - {transition.usages.length > 0 && ( -
    - {transition.usages.map((usage, usageIndex) => ( -
  • - {resourceLabel(usage.resource)} - {usage.capacities.map(([name, capacity], capacityIndex) => ( - - {name} - {capacity != null - ? `=${isBytesStat(name) ? formatBytes(capacity) : String(capacity)}` - : ''} - - ))} -
  • - ))} -
- )} - {transition.attributes.length > 0 && ( - - )} - {transition.derived_attributes.length > 0 && ( - - )} + + ); })} @@ -281,40 +247,3 @@ export function EntityDetailPanel({ ); } - -function AttributeRows({ - attributes, - derived, - operatorLabel, -}: { - attributes: DynamicAttribute[]; - derived?: boolean; - operatorLabel: (id: string) => string; -}) { - return ( -
    - {attributes.map((attribute, index) => { - const { label, value } = resolveAttributeDisplay(attribute, operatorLabel); - return ( -
  • - {label} - {value} -
  • - ); - })} -
- ); -} - -function resolveAttributeDisplay( - attribute: DynamicAttribute, - operatorLabel: (id: string) => string -): { label: string; value: string } { - if (attribute.key === 'operator_id') { - const raw = unwrapTaggedValue(attribute.value); - if (typeof raw === 'string') { - return { label: 'operator', value: operatorLabel(raw) }; - } - } - return { label: attribute.key, value: formatAttributeValue(attribute.key, attribute.value) }; -} diff --git a/ui/src/components/entities-table/ResourceUsageList.test.tsx b/ui/src/components/entities-table/ResourceUsageList.test.tsx new file mode 100644 index 000000000..a5e742827 --- /dev/null +++ b/ui/src/components/entities-table/ResourceUsageList.test.tsx @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { EntityRef, QueryBundle } from '@quent/utils'; +import { ResourceUsageList } from './ResourceUsageList'; + +describe('ResourceUsageList', () => { + it('renders each resource in a separate box with its capacities below', () => { + const queryBundle = { + entities: { + resources: { + 'gpu-0': { + id: 'gpu-0', + instance_name: 'GPU 0', + type_name: 'Gpu', + parent_group_id: 'worker-0', + }, + }, + resource_types: { + Gpu: { + name: 'Gpu', + capacities: [{ name: 'memory', kind: 'Occupancy', quantity: 'bytes' }], + used_by: [], + }, + }, + }, + quantity_specs: { + bytes: { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', + }, + }, + } as unknown as QueryBundle; + + render( + (id === 'gpu-0' ? 'GPU 0' : 'CPU 0')} + queryBundle={queryBundle} + /> + ); + + const usageBoxes = screen.getAllByRole('listitem'); + expect(usageBoxes).toHaveLength(2); + + const gpuUsage = within(usageBoxes[0]!); + expect(gpuUsage.getByText('GPU 0')).toBeInTheDocument(); + expect(gpuUsage.getByText('memory')).toBeInTheDocument(); + expect(gpuUsage.getByText('2.00 KiB')).toBeInTheDocument(); + expect(gpuUsage.getByText('slots')).toBeInTheDocument(); + expect(gpuUsage.getByText('4')).toBeInTheDocument(); + expect(gpuUsage.getByText('unspecified')).toBeInTheDocument(); + expect(gpuUsage.getByText('—')).toBeInTheDocument(); + + expect(within(usageBoxes[1]!).getByText('CPU 0')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/entities-table/ResourceUsageList.tsx b/ui/src/components/entities-table/ResourceUsageList.tsx new file mode 100644 index 000000000..ed322f14c --- /dev/null +++ b/ui/src/components/entities-table/ResourceUsageList.tsx @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { formatQuantity, inferFieldFormatter } from '@quent/utils'; +import type { EntityRef, FsmUsage, QueryBundle } from '@quent/utils'; + +interface ResourceUsageListProps { + usages: FsmUsage[]; + resourceLabel: (id: string) => string; + queryBundle: QueryBundle; +} + +export function ResourceUsageList({ usages, resourceLabel, queryBundle }: ResourceUsageListProps) { + if (usages.length === 0) return null; + + return ( +
    + {usages.map((usage, usageIndex) => { + const resourceTypeName = queryBundle.entities.resources[usage.resource]?.type_name; + const resourceType = resourceTypeName + ? queryBundle.entities.resource_types[resourceTypeName] + : undefined; + + return ( +
  • +

    + {resourceLabel(usage.resource)} +

    + {usage.capacities.length > 0 && ( +
    + {usage.capacities.map(([name, capacity], capacityIndex) => { + const capacityDecl = resourceType?.capacities.find(item => item.name === name); + const quantitySpec = capacityDecl + ? queryBundle.quantity_specs[capacityDecl.quantity] + : undefined; + + return ( +
    +
    {name}
    +
    + {capacity == null + ? '—' + : capacityDecl && quantitySpec + ? formatQuantity(capacity, quantitySpec, capacityDecl.kind) + : inferFieldFormatter(name)(capacity)} +
    +
    + ); + })} +
    + )} +
  • + ); + })} +
+ ); +} diff --git a/ui/src/components/entities-table/TransitionAttributes.test.tsx b/ui/src/components/entities-table/TransitionAttributes.test.tsx new file mode 100644 index 000000000..2d2deca01 --- /dev/null +++ b/ui/src/components/entities-table/TransitionAttributes.test.tsx @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TransitionAttributes } from './TransitionAttributes'; + +describe('TransitionAttributes', () => { + it('groups recorded and derived attributes into separate boxes', () => { + const operatorLabel = vi.fn(() => 'Scan operator'); + + render( + + ); + + expect(screen.getByRole('heading', { name: 'Attributes' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Derived attributes' })).toBeInTheDocument(); + expect(screen.getByText('operator')).toBeInTheDocument(); + expect(screen.getByText('Scan operator')).toBeInTheDocument(); + expect(screen.getByText('attempt')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('output_bytes')).toBeInTheDocument(); + expect(screen.getByText('2.00 KiB')).toBeInTheDocument(); + expect(operatorLabel).toHaveBeenCalledWith('operator-1'); + }); +}); diff --git a/ui/src/components/entities-table/TransitionAttributes.tsx b/ui/src/components/entities-table/TransitionAttributes.tsx new file mode 100644 index 000000000..7d8efd601 --- /dev/null +++ b/ui/src/components/entities-table/TransitionAttributes.tsx @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Card } from '@quent/components'; +import { formatAttributeValue, unwrapTaggedValue } from '@quent/utils'; +import type { DynamicAttribute } from '@quent/utils'; + +interface TransitionAttributesProps { + attributes: DynamicAttribute[]; + derivedAttributes: DynamicAttribute[]; + operatorLabel: (id: string) => string; +} + +export function TransitionAttributes({ + attributes, + derivedAttributes, + operatorLabel, +}: TransitionAttributesProps) { + if (attributes.length === 0 && derivedAttributes.length === 0) return null; + + return ( +
+ + +
+ ); +} + +function AttributeGroup({ + title, + attributes, + operatorLabel, + derived, +}: { + title: string; + attributes: DynamicAttribute[]; + operatorLabel: (id: string) => string; + derived?: boolean; +}) { + if (attributes.length === 0) return null; + + return ( + +

{title}

+
+ {attributes.map((attribute, index) => { + const { label, value } = resolveAttributeDisplay(attribute, operatorLabel); + return ( +
+
{label}
+
{value}
+
+ ); + })} +
+
+ ); +} + +function resolveAttributeDisplay( + attribute: DynamicAttribute, + operatorLabel: (id: string) => string +): { label: string; value: string } { + if (attribute.key === 'operator_id') { + const raw = unwrapTaggedValue(attribute.value); + if (typeof raw === 'string') { + return { label: 'operator', value: operatorLabel(raw) }; + } + } + return { label: attribute.key, value: formatAttributeValue(attribute.key, attribute.value) }; +} From 087c2966bb206d08b1bb89ce6ba3c25211e81cbf Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 15:36:15 -0600 Subject: [PATCH 04/20] refactor: consolidate on dataflowbar --- ui/packages/@quent/components/src/index.ts | 2 + .../src/query-plan/DataFlowBar.test.tsx | 55 +++++++ .../components/src/query-plan/DataFlowBar.tsx | 128 +++++++++++++++ .../components/src/query-plan/NodeFlowBar.tsx | 151 +++++++----------- .../src/query-plan/SegmentValueLabel.tsx | 26 ++- ui/src/components/EntityDetailDrawer.tsx | 1 + .../entities-table/EntityDetailPanel.tsx | 73 +++------ 7 files changed, 288 insertions(+), 148 deletions(-) create mode 100644 ui/packages/@quent/components/src/query-plan/DataFlowBar.test.tsx create mode 100644 ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 65986bcc9..143f18ffe 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -195,6 +195,8 @@ export { DagPlayhead } from './dag/DagPlayhead'; // ─── Query-plan components ──────────────────────────────────────────────────── export { QueryPlanNode } from './query-plan/QueryPlanNode'; +export { DataFlowBar } from './query-plan/DataFlowBar'; +export type { DataFlowBarProps, DataFlowBarSegment } from './query-plan/DataFlowBar'; export { NodeFlowBar } from './query-plan/NodeFlowBar'; // ─── Resource-tree components ───────────────────────────────────────────────── diff --git a/ui/packages/@quent/components/src/query-plan/DataFlowBar.test.tsx b/ui/packages/@quent/components/src/query-plan/DataFlowBar.test.tsx new file mode 100644 index 000000000..77d2a58f2 --- /dev/null +++ b/ui/packages/@quent/components/src/query-plan/DataFlowBar.test.tsx @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DataFlowBar } from './DataFlowBar'; + +const segments = [ + { + id: 'running', + value: 2, + color: '#76b900', + label: '2', + tooltip: Running: 2, + ariaLabel: 'running: 20%', + }, +]; + +describe('DataFlowBar', () => { + it('configures height, fill scaling, labels, and tooltips', () => { + const { container, rerender } = render( + + ); + + const track = container.firstElementChild as HTMLElement; + const fill = track.firstElementChild as HTMLElement; + const segment = screen.getByRole('img', { name: 'running: 20%' }); + expect(track.style.height).toBe('8px'); + expect(fill.style.width).toBe('20%'); + expect(screen.queryByTestId('segment-label')).not.toBeInTheDocument(); + + fireEvent.mouseEnter(segment, { clientX: 10, clientY: 20 }); + expect(screen.queryByText('Running: 2')).not.toBeInTheDocument(); + + rerender( + + ); + + fireEvent.mouseEnter(screen.getByRole('img', { name: 'running: 20%' }), { + clientX: 10, + clientY: 20, + }); + expect(screen.getByTestId('segment-label')).toHaveTextContent('2'); + expect(screen.getByTestId('segment-label')).toHaveClass('font-mono'); + expect(screen.getByText('Running: 2')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx new file mode 100644 index 000000000..80ccb8e80 --- /dev/null +++ b/ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useState, type CSSProperties, type ReactNode } from 'react'; +import { cn } from '@quent/utils'; +import { PointerTooltipPortal, type PointerPosition } from '../ui/pointer-tooltip-portal'; +import { SegmentValueLabel } from './SegmentValueLabel'; + +export interface DataFlowBarSegment { + id: string; + value: number; + color: string; + label?: string; + labelClassName?: string; + autoLabelContrast?: boolean; + tooltip?: ReactNode; + ariaLabel?: string; + title?: string; +} + +export interface DataFlowBarProps { + segments: DataFlowBarSegment[]; + fillValue?: number; + maxValue?: number; + height?: number | string; + minimumFillPx?: number; + showLabels?: boolean; + showTooltips?: boolean; + transition?: string; + className?: string; + trackClassName?: string; + labelTestId?: string; + style?: CSSProperties; +} + +export function DataFlowBar({ + segments, + fillValue, + maxValue, + height = 12, + minimumFillPx = 0, + showLabels = true, + showTooltips = true, + transition, + className, + trackClassName, + labelTestId, + style, +}: DataFlowBarProps) { + const [tooltip, setTooltip] = useState<{ + content: ReactNode; + pointer: PointerPosition; + } | null>(null); + const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0); + const filledValue = fillValue ?? total; + const scaleMax = maxValue ?? filledValue; + const fillPercent = scaleMax > 0 ? Math.min(100, (filledValue / scaleMax) * 100) : 0; + const fillWidth = + filledValue > 0 && minimumFillPx > 0 + ? `max(${minimumFillPx}px, ${fillPercent}%)` + : `${fillPercent}%`; + + const showSegmentTooltip = (content: ReactNode, pointer: PointerPosition) => { + if (showTooltips) setTooltip({ content, pointer }); + }; + + return ( + <> +
+
+ {segments.map(segment => ( +
{ + if (segment.tooltip) { + showSegmentTooltip(segment.tooltip, { + clientX: event.clientX, + clientY: event.clientY, + }); + } + }} + onMouseMove={event => { + if (segment.tooltip) { + showSegmentTooltip(segment.tooltip, { + clientX: event.clientX, + clientY: event.clientY, + }); + } + }} + onMouseLeave={() => setTooltip(null)} + onFocus={event => { + if (!segment.tooltip) return; + const rect = event.currentTarget.getBoundingClientRect(); + showSegmentTooltip(segment.tooltip, { + clientX: rect.left + rect.width / 2, + clientY: rect.top, + }); + }} + onBlur={() => setTooltip(null)} + > + {showLabels && segment.label && ( + + )} +
+ ))} +
+
+ + {tooltip?.content} + + + ); +} diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index d589b3cb2..aaa81a3a4 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -13,7 +13,6 @@ import { fitDataFlowSegmentLabel, formatDataFlowValueCompact, } from '@quent/hooks'; -import { cn } from '@quent/utils'; import { NODE_LAYOUT_WIDTH, FLOW_BAR_TOP_MARGIN, @@ -21,7 +20,7 @@ import { FLOW_BAR_TRACK_GAP, FLOW_BAR_LABEL_HEIGHT, } from '../dag/layout'; -import { SegmentValueLabel } from './SegmentValueLabel'; +import { DataFlowBar } from './DataFlowBar'; const BAR_TRANSITION = 'width 120ms linear'; @@ -78,9 +77,6 @@ export const NodeFlowBar = memo( const operatorFrame = frame.perOperator.get(operatorId); const total = operatorFrame?.total ?? 0; const hasData = operatorFrame != null && total > 0 && frame.maxTotal > 0; - // Stable scale while scrubbing: filled width is relative to the max - // operator total across ALL bins of the window (frame.maxTotal). - const filledWidth = hasData ? `max(2px, ${(total / frame.maxTotal) * 100}%)` : '0px'; // One compact total per declared measure with data at this bin, in // declaration order — e.g. "3.2 | 45MB" (count | bytes). A pipe, not a @@ -92,6 +88,44 @@ export const NodeFlowBar = memo( .map(m => formatDataFlowValueCompact(operatorTotals[m.name]!, m.name, meta)) .join(' | ') : ''; + const stateSegments = hasData + ? meta.stateNames.flatMap((state, stateIndex) => { + const value = operatorFrame.byState[stateIndex] ?? 0; + if (value <= 0) return []; + const color = stateColor(state); + const label = fitDataFlowSegmentLabel( + value, + frame.maxTotal, + frame.measure, + meta, + FLOW_TRACK_PX, + { + value: operatorFrame.labelByState[stateIndex] ?? 0, + measure: frame.labelMeasure, + } + ); + return [{ id: state, value, color, label: label ?? undefined }]; + }) + : []; + const dimensionSegments = hasData + ? meta.decl.dimension_keys.flatMap((dimension, dimensionIndex) => { + const value = operatorFrame.byDimension[dimensionIndex] ?? 0; + if (value <= 0) return []; + const color = dimensionColor(dimension.key); + const label = fitDataFlowSegmentLabel( + value, + frame.maxTotal, + frame.measure, + meta, + FLOW_TRACK_PX, + { + value: operatorFrame.labelByDimension[dimensionIndex] ?? 0, + measure: frame.labelMeasure, + } + ); + return [{ id: dimension.key, value, color, label: label ?? undefined }]; + }) + : []; return (
-
-
- {hasData && - meta.stateNames.map((state, stateIndex) => { - const value = operatorFrame.byState[stateIndex] ?? 0; - if (value <= 0) return null; - const color = stateColor(state); - const label = fitDataFlowSegmentLabel( - value, - frame.maxTotal, - frame.measure, - meta, - FLOW_TRACK_PX, - { - value: operatorFrame.labelByState[stateIndex] ?? 0, - measure: frame.labelMeasure, - } - ); - return ( -
- {label != null && ( - - )} -
- ); - })} -
-
-
-
- {hasData && - meta.decl.dimension_keys.map((dimension, dimensionIndex) => { - const value = operatorFrame.byDimension[dimensionIndex] ?? 0; - if (value <= 0) return null; - const color = dimensionColor(dimension.key); - const label = fitDataFlowSegmentLabel( - value, - frame.maxTotal, - frame.measure, - meta, - FLOW_TRACK_PX, - { - value: operatorFrame.labelByDimension[dimensionIndex] ?? 0, - measure: frame.labelMeasure, - } - ); - return ( -
- {label != null && ( - - )} -
- ); - })} -
-
+ +
( - {label} - + ); diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx index f162e47f1..6dfee7f0c 100644 --- a/ui/src/components/EntityDetailDrawer.tsx +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -41,6 +41,7 @@ export function EntityDetailDrawer({ modal={false} noBodyStyles shouldScaleBackground={false} + handleOnly > (null); - const [barPointer, setBarPointer] = useState(null); if (!fsm) { return ( @@ -129,49 +119,30 @@ export function EntityDetailPanel({
)} {totalSpanMs > 0 && stateTimeMs.size > 0 && ( -
- {[...stateTimeMs.entries()].map(([name, ms]) => { + { const color = stateColorFn ? stateColorFn(name) : getColorForKey(name, paletteTheme); const pct = (ms / totalSpanMs) * 100; - return ( -
{ - setBarTooltip({ name, pct }); - setBarPointer({ clientX: e.clientX, clientY: e.clientY }); - }} - onMouseMove={e => setBarPointer({ clientX: e.clientX, clientY: e.clientY })} - onMouseLeave={() => { - setBarTooltip(null); - setBarPointer(null); - }} - onFocus={e => { - const rect = e.currentTarget.getBoundingClientRect(); - setBarTooltip({ name, pct }); - setBarPointer({ clientX: rect.left + rect.width / 2, clientY: rect.top }); - }} - onBlur={() => { - setBarTooltip(null); - setBarPointer(null); - }} - /> - ); + return { + id: name, + value: ms, + color, + ariaLabel: `${name}: ${pct.toFixed(1)}%`, + tooltip: ( +
+ {name} + {pct.toFixed(1)}% +
+ ), + }; })} -
+ /> )} - - {barTooltip && ( -
- {barTooltip.name} - {barTooltip.pct.toFixed(1)}% -
- )} -
Date: Thu, 13 Aug 2026 15:48:29 -0600 Subject: [PATCH 05/20] chore: use DataText for numbers/server data --- .../src/fsm-chart/FsmCapacityChart.tsx | 1 + .../entities-table/EntityDetailPanel.tsx | 34 +++++++++---------- .../entities-table/ResourceUsageList.tsx | 19 +++++++---- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index 1e9a90de9..42b24199f 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -87,6 +87,7 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa }, yAxis: { type: 'value' as const, + splitNumber: 3, axisLabel: { show: true, fontSize: 9, diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index 84eb0962f..58c2b6f1d 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Check, Copy } from 'lucide-react'; -import { DataFlowBar, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; +import { DataFlowBar, DataText, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; import { formatDuration, formatDurationForWindow, getColorForKey } from '@quent/utils'; import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; @@ -85,15 +85,15 @@ export function EntityDetailPanel({ {/* Compact header: name + type badge on one line, UUID + copy on second */}
- {fsm.instance_name} - + {fsm.instance_name} + {fsm.type_name} - +
- + {fsm.id} - +
); From 88aa2c635fccc141b8d6f10e1ce9f3ebba78f130 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 16:06:48 -0600 Subject: [PATCH 06/20] refactor: extract positioned tooltip, use in fsmcapacity and timelinetooltip --- .../src/fsm-chart/FsmCapacityChart.tsx | 80 +++++++++++++------ .../src/fsm-chart/FsmCapacityTooltip.tsx | 36 +++++++++ ui/packages/@quent/components/src/index.ts | 1 + .../src/timeline/TimelineTooltipPortal.tsx | 52 ++---------- .../src/ui/pointer-tooltip-portal.tsx | 49 ++---------- .../src/ui/positioned-tooltip.test.tsx | 20 +++++ .../components/src/ui/positioned-tooltip.tsx | 50 ++++++++++++ 7 files changed, 176 insertions(+), 112 deletions(-) create mode 100644 ui/packages/@quent/components/src/fsm-chart/FsmCapacityTooltip.tsx create mode 100644 ui/packages/@quent/components/src/ui/positioned-tooltip.test.tsx create mode 100644 ui/packages/@quent/components/src/ui/positioned-tooltip.tsx diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index 42b24199f..a5bf6b624 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -1,15 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useMemo } from 'react'; +import { useMemo, useState, type PointerEvent as ReactPointerEvent } from 'react'; import EChartsReactCore from 'echarts-for-react/lib/core'; import type { FsmTransition } from '@quent/utils'; import { bigintToChartNumber, formatBytes, isBytesStat } from '@quent/utils'; import { echarts } from '../lib/echarts'; import { useChartResize } from '../lib/useChartResize'; +import type { PointerPosition } from '../ui/pointer-tooltip-portal'; +import { PositionedTooltip } from '../ui/positioned-tooltip'; import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; +import { FsmCapacityTooltip } from './FsmCapacityTooltip'; const CHART_HEIGHT = 90; +const GRID = { left: 52, right: 8, top: 8, bottom: 36 }; interface CapacitySeries { label: string; @@ -28,6 +32,7 @@ export interface FsmCapacityChartProps { export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapacityChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const { handleChartReady } = useChartResize(); + const [hover, setHover] = useState<(PointerPosition & { dataIndex: number }) | null>(null); const { series, stateLabels } = useMemo(() => { const n = transitions.length; @@ -67,10 +72,42 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa return { series, stateLabels }; }, [transitions, resourceLabel]); + const reportHover = (event: ReactPointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const plotWidth = rect.width - GRID.left - GRID.right; + const outsidePlot = + x < GRID.left || x > rect.width - GRID.right || y < GRID.top || y > rect.height - GRID.bottom; + if (outsidePlot || plotWidth <= 0 || stateLabels.length === 0) { + setHover(null); + return; + } + + const ratio = (x - GRID.left) / plotWidth; + const dataIndex = stateLabels.length === 1 ? 0 : Math.round(ratio * (stateLabels.length - 1)); + setHover({ dataIndex, clientX: event.clientX, clientY: event.clientY }); + }; + + const tooltipItems = hover + ? series.flatMap((item, seriesIndex) => { + const value = item.data[hover.dataIndex]; + if (value == null) return []; + const raw = item.rawData[hover.dataIndex]; + return [ + { + id: `${item.label}-${seriesIndex}`, + label: item.label, + value: formatBytes(raw ?? value), + }, + ]; + }) + : []; + const option = useMemo( () => ({ animation: false, - grid: { left: 52, right: 8, top: 8, bottom: 36 }, + grid: GRID, xAxis: { type: 'category' as const, data: stateLabels, @@ -98,25 +135,8 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa }, tooltip: { trigger: 'axis' as const, - formatter: ( - params: Array<{ - seriesName: string; - value: number | null; - dataIndex: number; - seriesIndex: number; - }> - ) => { - const idx = params[0]?.dataIndex ?? 0; - const stateName = transitions[idx]?.name ?? ''; - const lines = params - .filter(p => p.value != null) - .map(p => { - const raw = series[p.seriesIndex]?.rawData[idx]; - return `${p.seriesName}: ${formatBytes(raw ?? p.value!)}`; - }); - if (lines.length === 0) return ''; - return [`${idx + 1}. ${stateName}`, ...lines].join('
'); - }, + showContent: false, + axisPointer: { type: 'line' as const, snap: true }, }, series: series.map(s => ({ type: 'line' as const, @@ -129,13 +149,18 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa lineStyle: { width: 1.5 }, })), }), - [series, stateLabels, transitions] + [series, stateLabels] ); if (series.length === 0) return null; return ( -
+
setHover(null)} + onPointerCancel={() => setHover(null)} + > + {hover && tooltipItems.length > 0 && ( + + + + )}
); } diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityTooltip.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityTooltip.tsx new file mode 100644 index 000000000..397bdbcaf --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityTooltip.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DataText } from '../ui/data-text'; + +export interface FsmCapacityTooltipItem { + id: string; + label: string; + value: string; +} + +export function FsmCapacityTooltip({ + stateIndex, + stateName, + items, +}: { + stateIndex: number; + stateName: string; + items: FsmCapacityTooltipItem[]; +}) { + return ( +
+ + {stateIndex + 1}. {stateName} + +
    + {items.map(item => ( +
  • + {item.label} + {item.value} +
  • + ))} +
+
+ ); +} diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 143f18ffe..8c4f3c7d7 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -58,6 +58,7 @@ export { export { Popover, PopoverTrigger, PopoverContent } from './ui/popover'; export { PointerTooltipPortal } from './ui/pointer-tooltip-portal'; export type { PointerPosition } from './ui/pointer-tooltip-portal'; +export { PositionedTooltip } from './ui/positioned-tooltip'; export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './ui/resizable'; export { ScrollArea, ScrollBar } from './ui/scroll-area'; export { diff --git a/ui/packages/@quent/components/src/timeline/TimelineTooltipPortal.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltipPortal.tsx index 257020a84..47fe7e5f6 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltipPortal.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltipPortal.tsx @@ -1,14 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { useMemo } from 'react'; import { useTimelineHover, useZoomRange } from '@quent/hooks'; import { TooltipContent } from './TimelineTooltip'; import type { TimelineMark, TimelineSeries } from './types'; - -const POINTER_OFFSET = 12; -const VIEWPORT_MARGIN = 4; +import { PositionedTooltip } from '../ui/positioned-tooltip'; /** * Pointer-driven tooltip rendered as a single body-level portal. @@ -40,7 +37,7 @@ export function TimelineTooltipPortal({ const dataIndex = Math.max(0, Math.min(timestamps.length - 1, hover.dataIndex)); return ( - Object.values(series)[0]?.formatter, [series]); - const hostRef = useRef(null); - // Defer-clamp to viewport: render once at the raw position, measure, then - // adjust if the box would overflow. Two-phase keeps us simple — confine: true - // was free with ECharts; here it's ~10 lines. - const [position, setPosition] = useState({ - left: clientX + POINTER_OFFSET, - top: clientY + POINTER_OFFSET, - }); - useLayoutEffect(() => { - const el = hostRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - let left = clientX + POINTER_OFFSET; - let top = clientY + POINTER_OFFSET; - if (left + rect.width + VIEWPORT_MARGIN > vw) { - left = Math.max(VIEWPORT_MARGIN, clientX - rect.width - POINTER_OFFSET); - } - if (top + rect.height + VIEWPORT_MARGIN > vh) { - top = Math.max(VIEWPORT_MARGIN, clientY - rect.height - POINTER_OFFSET); - } - setPosition({ left, top }); - }, [clientX, clientY, snappedTimestamp]); - - return createPortal( -
+ return ( + -
, - document.body +
); } diff --git a/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx b/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx index 8e0027aac..f998efba1 100644 --- a/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx +++ b/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx @@ -1,11 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; -import { createPortal } from 'react-dom'; - -const POINTER_OFFSET = 12; -const VIEWPORT_MARGIN = 4; +import type { ReactNode } from 'react'; +import { PositionedTooltip } from './positioned-tooltip'; export interface PointerPosition { clientX: number; @@ -20,45 +17,9 @@ export function PointerTooltipPortal({ children: ReactNode; }) { if (!hover) return null; - return {children}; -} - -function PositionedPointerTooltip({ - hover, - children, -}: { - hover: PointerPosition; - children: ReactNode; -}) { - const hostRef = useRef(null); - const [position, setPosition] = useState({ - left: hover.clientX + POINTER_OFFSET, - top: hover.clientY + POINTER_OFFSET, - }); - - useLayoutEffect(() => { - const element = hostRef.current; - if (!element) return; - const rect = element.getBoundingClientRect(); - let left = hover.clientX + POINTER_OFFSET; - let top = hover.clientY + POINTER_OFFSET; - if (left + rect.width + VIEWPORT_MARGIN > window.innerWidth) { - left = Math.max(VIEWPORT_MARGIN, hover.clientX - rect.width - POINTER_OFFSET); - } - if (top + rect.height + VIEWPORT_MARGIN > window.innerHeight) { - top = Math.max(VIEWPORT_MARGIN, hover.clientY - rect.height - POINTER_OFFSET); - } - setPosition({ left, top }); - }, [hover.clientX, hover.clientY, children]); - - return createPortal( -
+ return ( + {children} -
, - document.body + ); } diff --git a/ui/packages/@quent/components/src/ui/positioned-tooltip.test.tsx b/ui/packages/@quent/components/src/ui/positioned-tooltip.test.tsx new file mode 100644 index 000000000..48e08e070 --- /dev/null +++ b/ui/packages/@quent/components/src/ui/positioned-tooltip.test.tsx @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PositionedTooltip } from './positioned-tooltip'; + +describe('PositionedTooltip', () => { + it('portals content beside the pointer', () => { + render( + + Tooltip content + + ); + + const host = screen.getByText('Tooltip content').parentElement; + expect(host).toHaveStyle({ left: '112px', top: '62px' }); + expect(host).toHaveClass('pointer-events-none', 'fixed', 'z-[1000]'); + }); +}); diff --git a/ui/packages/@quent/components/src/ui/positioned-tooltip.tsx b/ui/packages/@quent/components/src/ui/positioned-tooltip.tsx new file mode 100644 index 000000000..6d1993c7a --- /dev/null +++ b/ui/packages/@quent/components/src/ui/positioned-tooltip.tsx @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +const POINTER_OFFSET = 12; +const VIEWPORT_MARGIN = 4; + +export function PositionedTooltip({ + clientX, + clientY, + children, +}: { + clientX: number; + clientY: number; + children: ReactNode; +}) { + const hostRef = useRef(null); + const [position, setPosition] = useState({ + left: clientX + POINTER_OFFSET, + top: clientY + POINTER_OFFSET, + }); + + useLayoutEffect(() => { + const element = hostRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + let left = clientX + POINTER_OFFSET; + let top = clientY + POINTER_OFFSET; + if (left + rect.width + VIEWPORT_MARGIN > window.innerWidth) { + left = Math.max(VIEWPORT_MARGIN, clientX - rect.width - POINTER_OFFSET); + } + if (top + rect.height + VIEWPORT_MARGIN > window.innerHeight) { + top = Math.max(VIEWPORT_MARGIN, clientY - rect.height - POINTER_OFFSET); + } + setPosition({ left, top }); + }, [clientX, clientY, children]); + + return createPortal( +
+ {children} +
, + document.body + ); +} From a0e7a0c93e99ddabd2d6cd903e3e8cd16ddbfd3a Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 13 Aug 2026 16:17:22 -0600 Subject: [PATCH 07/20] chore: simplify position tracking --- .../src/fsm-chart/FsmCapacityChart.tsx | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index a5bf6b624..89c8265ff 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useMemo, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { useMemo, useState } from 'react'; import EChartsReactCore from 'echarts-for-react/lib/core'; import type { FsmTransition } from '@quent/utils'; import { bigintToChartNumber, formatBytes, isBytesStat } from '@quent/utils'; @@ -23,6 +23,10 @@ interface CapacitySeries { rawData: Array; } +interface AxisPointerEvent { + axesInfo?: Array<{ axisDim?: string; value?: number }>; +} + export interface FsmCapacityChartProps { transitions: FsmTransition[]; isDark: boolean; @@ -32,7 +36,8 @@ export interface FsmCapacityChartProps { export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapacityChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const { handleChartReady } = useChartResize(); - const [hover, setHover] = useState<(PointerPosition & { dataIndex: number }) | null>(null); + const [pointer, setPointer] = useState(null); + const [dataIndex, setDataIndex] = useState(null); const { series, stateLabels } = useMemo(() => { const n = transitions.length; @@ -72,21 +77,19 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa return { series, stateLabels }; }, [transitions, resourceLabel]); - const reportHover = (event: ReactPointerEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - const plotWidth = rect.width - GRID.left - GRID.right; - const outsidePlot = - x < GRID.left || x > rect.width - GRID.right || y < GRID.top || y > rect.height - GRID.bottom; - if (outsidePlot || plotWidth <= 0 || stateLabels.length === 0) { - setHover(null); - return; - } - - const ratio = (x - GRID.left) / plotWidth; - const dataIndex = stateLabels.length === 1 ? 0 : Math.round(ratio * (stateLabels.length - 1)); - setHover({ dataIndex, clientX: event.clientX, clientY: event.clientY }); + const onEvents = useMemo( + () => ({ + updateAxisPointer: (event: AxisPointerEvent) => { + const value = event.axesInfo?.find(info => info.axisDim === 'x')?.value; + setDataIndex(typeof value === 'number' ? Math.round(value) : null); + }, + }), + [] + ); + const hover = pointer && dataIndex != null ? { ...pointer, dataIndex } : null; + const clearHover = () => { + setPointer(null); + setDataIndex(null); }; const tooltipItems = hover @@ -157,9 +160,9 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa return (
setHover(null)} - onPointerCancel={() => setHover(null)} + onPointerMove={event => setPointer({ clientX: event.clientX, clientY: event.clientY })} + onPointerLeave={clearHover} + onPointerCancel={clearHover} > Date: Wed, 19 Aug 2026 16:03:10 -0600 Subject: [PATCH 08/20] refactor: deselect entities if they are selected again --- ui/src/components/QueryResourceTree.test.tsx | 75 ++++++++++++++++---- ui/src/components/QueryResourceTree.tsx | 9 ++- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/ui/src/components/QueryResourceTree.test.tsx b/ui/src/components/QueryResourceTree.test.tsx index bdac4388b..c5666c257 100644 --- a/ui/src/components/QueryResourceTree.test.tsx +++ b/ui/src/components/QueryResourceTree.test.tsx @@ -9,7 +9,12 @@ import { Provider as JotaiProvider, createStore } from 'jotai'; import { QueryResourceTree } from './QueryResourceTree'; import { applyBulkTimelineResponse, timelineCacheKey } from '@quent/hooks'; import { timelineDataMapAtom } from '@quent/hooks/testing'; -import type { SingleTimelineResponse, QueryBundle, EntityRef } from '@quent/utils'; +import type { + SingleTimelineResponse, + QueryBundle, + EntityRef, + FiniteStateMachine, +} from '@quent/utils'; // --------------------------------------------------------------------------- // Mock heavy/visual dependencies so tests run without a real browser/canvas @@ -36,6 +41,12 @@ vi.mock('@/contexts/ThemeContext', () => ({ // Capture the timelineData prop passed to TimelineController on every render let capturedTimelineData: SingleTimelineResponse | null | undefined = undefined; +let capturedLongEntityProps: + | { + onEntitySelect?: (fsm: FiniteStateMachine) => void; + selectedEntityId?: string; + } + | undefined; // Mock @quent/components: keep all actual exports but override heavy/visual ones vi.mock('@quent/components', async importOriginal => { @@ -49,17 +60,34 @@ vi.mock('@quent/components', async importOriginal => { TreeTable: ({ columns, }: { - columns: Array<{ headerContent?: React.ReactNode; subHeaderContent?: React.ReactNode }>; - }) => ( - <> - {columns.map((col, i) => ( - - {col.headerContent} - {col.subHeaderContent} - - ))} - - ), + columns: Array<{ + headerContent?: React.ReactNode; + subHeaderContent?: React.ReactNode; + render?: (args: { item: unknown }) => React.ReactNode; + }>; + }) => { + const longEntityElement = columns[1]?.render?.({ + item: { + id: actual.longEntitiesRowId(RESOURCE_ID), + type: actual.LONG_ENTITIES_ROW_TYPE, + entity: {}, + }, + }); + if (React.isValidElement(longEntityElement)) { + capturedLongEntityProps = longEntityElement.props as typeof capturedLongEntityProps; + } + + return ( + <> + {columns.map((col, i) => ( + + {col.headerContent} + {col.subHeaderContent} + + ))} + + ); + }, ResourceColumn: () => null, UsageColumn: () => null, TimelineToolbar: () => null, @@ -121,9 +149,32 @@ const makeTimeline = (start: number, end: number): SingleTimelineResponse => describe('QueryResourceTree — TimelineController always shows full-range data', () => { beforeEach(() => { capturedTimelineData = undefined; + capturedLongEntityProps = undefined; vi.mocked(clientApi.fetchBulkTimelines).mockResolvedValue({ entries: {} } as never); }); + it('deselects an entity when it is selected again', () => { + vi.mocked(clientApi.fetchSingleTimeline).mockResolvedValue(makeTimeline(0, DURATION_S)); + const fsm = { + id: 'entity-1', + type_name: 'Task', + instance_name: 'Task 1', + transitions: [], + } as FiniteStateMachine; + + renderWithQuery( + + + + ); + + act(() => capturedLongEntityProps?.onEntitySelect?.(fsm)); + expect(capturedLongEntityProps?.selectedEntityId).toBe(fsm.id); + + act(() => capturedLongEntityProps?.onEntitySelect?.(fsm)); + expect(capturedLongEntityProps?.selectedEntityId).toBeUndefined(); + }); + it('passes full-range timeline data to TimelineController', async () => { const fullRange = makeTimeline(0, DURATION_S); vi.mocked(clientApi.fetchSingleTimeline).mockResolvedValue(fullRange); diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 957fa3cf8..f61a0ff67 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -137,6 +137,11 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr const [selectedFsmTypes, setSelectedFsmTypes] = useAtom(selectedFsmTypesAtom); const [drawerFsm, setDrawerFsm] = useState(null); + const toggleDrawerFsm = useCallback( + (fsm: FiniteStateMachine) => + setDrawerFsm(selectedFsm => (selectedFsm?.id === fsm.id ? null : fsm)), + [] + ); const closeDrawer = useCallback(() => setDrawerFsm(null), []); const stateColorFn = useMemo( @@ -355,7 +360,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr durationSeconds={durationSeconds} fsmTypes={entities.fsm_types} isDark={isDark} - onEntitySelect={setDrawerFsm} + onEntitySelect={toggleDrawerFsm} selectedEntityId={drawerFsm?.id} onBackgroundClick={closeDrawer} /> @@ -393,7 +398,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr queryBundle, handleZoomChange, operatorEntriesByWorker, - setDrawerFsm, + toggleDrawerFsm, drawerFsm, closeDrawer, ]); From b8864a3bb3da88fbb89e68b57ebfc6b760593dbf Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 20 Aug 2026 14:43:47 -0500 Subject: [PATCH 09/20] Pass percentage instead of ms so bar fills up full width --- ui/src/components/entities-table/EntityDetailPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index 58c2b6f1d..d1d8edbb4 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -130,7 +130,7 @@ export function EntityDetailPanel({ const pct = (ms / totalSpanMs) * 100; return { id: name, - value: ms, + value: pct, color, ariaLabel: `${name}: ${pct.toFixed(1)}%`, tooltip: ( From baf344d1ef102b45c6d77833bb90885f791b3c65 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 20 Aug 2026 15:42:56 -0500 Subject: [PATCH 10/20] generalize cap. chart component to select multiples caps, allow default definition --- .../src/fsm-chart/FsmCapacityChart.tsx | 284 ++++++++++++------ .../entities-table/EntityDetailPanel.tsx | 9 +- 2 files changed, 206 insertions(+), 87 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index 89c8265ff..7b0337453 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import EChartsReactCore from 'echarts-for-react/lib/core'; -import type { FsmTransition } from '@quent/utils'; -import { bigintToChartNumber, formatBytes, isBytesStat } from '@quent/utils'; +import type { CapacityDecl, FsmTransition, QuantitySpec } from '@quent/utils'; +import { bigintToChartNumber, formatBytes, formatQuantity } from '@quent/utils'; import { echarts } from '../lib/echarts'; import { useChartResize } from '../lib/useChartResize'; import type { PointerPosition } from '../ui/pointer-tooltip-portal'; @@ -15,12 +15,18 @@ import { FsmCapacityTooltip } from './FsmCapacityTooltip'; const CHART_HEIGHT = 90; const GRID = { left: 52, right: 8, top: 8, bottom: 36 }; -interface CapacitySeries { - label: string; - // Full-length array aligned to transitions — null where no reading exists +interface CapacityEntry { + name: string; + statLabel: string; data: Array; - // Original bigint values for lossless tooltip formatting rawData: Array; + formatter: (v: number | bigint) => string; +} + +interface ResourceSeries { + resourceId: string; + label: string; + capacities: CapacityEntry[]; } interface AxisPointerEvent { @@ -31,51 +37,114 @@ export interface FsmCapacityChartProps { transitions: FsmTransition[]; isDark: boolean; resourceLabel: (id: string) => string; + quantitySpecs: { [key in string]: QuantitySpec }; + getCapacityDecl: (resourceId: string, capacityName: string) => CapacityDecl | undefined; + defaultCapacityPredicate?: (name: string) => boolean; } -export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapacityChartProps) { +const SELECT_CLASS = + 'max-w-[140px] truncate rounded border border-border bg-background px-1 py-0.5 text-[10px] text-foreground focus:outline-none focus:ring-1 focus:ring-ring'; + +export function FsmCapacityChart({ + transitions, + isDark, + resourceLabel, + quantitySpecs, + getCapacityDecl, + defaultCapacityPredicate, +}: FsmCapacityChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const { handleChartReady } = useChartResize(); const [pointer, setPointer] = useState(null); const [dataIndex, setDataIndex] = useState(null); + const [selectedResourceId, setSelectedResourceId] = useState(null); + const [selectedCapacityName, setSelectedCapacityName] = useState(null); - const { series, stateLabels } = useMemo(() => { + const { resources, stateLabels } = useMemo(() => { const n = transitions.length; const stateLabels = transitions.map((t, i) => `${i + 1}. ${t.name}`); - // Build per-resource full-length arrays (null = no reading at that state) - const dataMap = new Map>(); - const rawMap = new Map>(); - const labelMap = new Map(); + // Accumulate data keyed by resourceId → capacityName + const resourceMap = new Map< + string, + { label: string; caps: Map; rawData: Array }> } + >(); transitions.forEach((t, i) => { t.usages.forEach(usage => { - const resourceName = resourceLabel(usage.resource); + if (!resourceMap.has(usage.resource)) { + resourceMap.set(usage.resource, { + label: resourceLabel(usage.resource), + caps: new Map(), + }); + } + const entry = resourceMap.get(usage.resource)!; usage.capacities.forEach(([name, cap]) => { - if (cap == null || !isBytesStat(name)) return; - const key = `${usage.resource} ${name}`; - if (!dataMap.has(key)) { - dataMap.set(key, Array(n).fill(null)); - rawMap.set(key, Array(n).fill(null)); - labelMap.set(key, name === 'capacity_bytes' ? resourceName : `${resourceName} ${name}`); + if (cap == null) return; + if (!entry.caps.has(name)) { + entry.caps.set(name, { + data: Array(n).fill(null), + rawData: Array(n).fill(null), + }); } - dataMap.get(key)![i] = bigintToChartNumber(cap); - rawMap.get(key)![i] = cap; + const capEntry = entry.caps.get(name)!; + capEntry.data[i] = bigintToChartNumber(cap); + capEntry.rawData[i] = cap; }); }); }); - // Only show resources with readings in at least 2 states - const series: CapacitySeries[] = [...dataMap.entries()] - .filter(([, data]) => data.filter(v => v !== null).length >= 2) - .map(([key, data]) => ({ - label: labelMap.get(key) ?? key, - data, - rawData: rawMap.get(key) ?? Array(n).fill(null), - })); + // Build ResourceSeries, filtering capacities to those with ≥2 readings + const resources: ResourceSeries[] = []; + resourceMap.forEach(({ label, caps }, resourceId) => { + const capacities: CapacityEntry[] = []; + caps.forEach(({ data, rawData }, name) => { + if (data.filter(v => v !== null).length < 2) return; + const capDecl = getCapacityDecl(resourceId, name); + const spec = capDecl ? quantitySpecs[capDecl.quantity] : undefined; + const statLabel = spec?.symbol ? `${name} (${spec.symbol})` : name; + const formatter: (v: number | bigint) => string = + capDecl && spec ? v => formatQuantity(v, spec, capDecl.kind) : v => formatBytes(v); + capacities.push({ name, statLabel, data, rawData, formatter }); + }); + if (capacities.length > 0) { + if (defaultCapacityPredicate) { + capacities.sort((a, b) => { + const aPref = defaultCapacityPredicate(a.name) ? 0 : 1; + const bPref = defaultCapacityPredicate(b.name) ? 0 : 1; + return aPref - bPref; + }); + } + resources.push({ resourceId, label, capacities }); + } + }); + + if (defaultCapacityPredicate) { + resources.sort((a, b) => { + const aPref = a.capacities.some(c => defaultCapacityPredicate(c.name)) ? 0 : 1; + const bPref = b.capacities.some(c => defaultCapacityPredicate(c.name)) ? 0 : 1; + return aPref - bPref; + }); + } + + return { resources, stateLabels }; + }, [transitions, resourceLabel, quantitySpecs, getCapacityDecl, defaultCapacityPredicate]); + + // Reset selections when the entity changes + useEffect(() => { + setSelectedResourceId(null); + setSelectedCapacityName(null); + }, [transitions]); + + // Resolve active resource + const activeResource = + resources.find(r => r.resourceId === selectedResourceId) ?? resources[0] ?? null; - return { series, stateLabels }; - }, [transitions, resourceLabel]); + // Resolve active capacity — reset capacity selection when resource changes + const activeCapacity = + activeResource?.capacities.find(c => c.name === selectedCapacityName) ?? + activeResource?.capacities[0] ?? + null; const onEvents = useMemo( () => ({ @@ -92,20 +161,21 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa setDataIndex(null); }; - const tooltipItems = hover - ? series.flatMap((item, seriesIndex) => { - const value = item.data[hover.dataIndex]; - if (value == null) return []; - const raw = item.rawData[hover.dataIndex]; - return [ - { - id: `${item.label}-${seriesIndex}`, - label: item.label, - value: formatBytes(raw ?? value), - }, - ]; - }) - : []; + const tooltipItems = + hover && activeCapacity && activeResource + ? (() => { + const value = activeCapacity.data[hover.dataIndex]; + if (value == null) return []; + const raw = activeCapacity.rawData[hover.dataIndex]; + return [ + { + id: activeResource.label, + label: activeResource.label, + value: activeCapacity.formatter(raw ?? value), + }, + ]; + })() + : []; const option = useMemo( () => ({ @@ -119,7 +189,6 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa show: true, fontSize: 9, interval: 0, - // Show only the state number to save space; full name is in the tooltip formatter: (_val: string, idx: number) => String(idx + 1), }, axisLine: { show: false }, @@ -131,7 +200,7 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa axisLabel: { show: true, fontSize: 9, - formatter: (v: number) => formatBytes(v, 0), + formatter: (v: number) => (activeCapacity ? activeCapacity.formatter(v) : formatBytes(v, 0)), }, splitLine: { show: true, lineStyle: { opacity: 0.25 } }, minInterval: 1, @@ -141,49 +210,92 @@ export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapa showContent: false, axisPointer: { type: 'line' as const, snap: true }, }, - series: series.map(s => ({ - type: 'line' as const, - name: s.label, - data: s.data, - connectNulls: false, - step: 'end' as const, - symbol: 'circle', - symbolSize: 5, - lineStyle: { width: 1.5 }, - })), + series: activeCapacity + ? [ + { + type: 'line' as const, + name: activeResource?.label ?? '', + data: activeCapacity.data, + connectNulls: false, + step: 'end' as const, + symbol: 'circle', + symbolSize: 5, + lineStyle: { width: 1.5 }, + }, + ] + : [], }), - [series, stateLabels] + [activeCapacity, activeResource, stateLabels] ); - if (series.length === 0) return null; + if (resources.length === 0) return null; return ( -
setPointer({ clientX: event.clientX, clientY: event.clientY })} - onPointerLeave={clearHover} - onPointerCancel={clearHover} - > - - {hover && tooltipItems.length > 0 && ( - - - - )} +
+
+ + {activeCapacity?.statLabel} + +
+ {resources.length > 1 && ( + + )} + {activeResource && activeResource.capacities.length > 1 && ( + + )} +
+
+
setPointer({ clientX: event.clientX, clientY: event.clientY })} + onPointerLeave={clearHover} + onPointerCancel={clearHover} + > + + {hover && tooltipItems.length > 0 && ( + + + + )} +
); } diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index d1d8edbb4..6ae9893d5 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { DataFlowBar, DataText, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; -import { formatDuration, formatDurationForWindow, getColorForKey } from '@quent/utils'; +import { formatDuration, formatDurationForWindow, getColorForKey, isBytesStat } from '@quent/utils'; import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; import { ResourceUsageList } from './ResourceUsageList'; @@ -149,6 +149,13 @@ export function EntityDetailPanel({ transitions={fsm.transitions} isDark={theme === THEME_DARK} resourceLabel={resourceLabel} + quantitySpecs={queryBundle.quantity_specs} + defaultCapacityPredicate={isBytesStat} + getCapacityDecl={(resourceId, capacityName) => { + const typeName = queryBundle.entities.resources[resourceId]?.type_name; + const resourceType = typeName ? queryBundle.entities.resource_types[typeName] : undefined; + return resourceType?.capacities.find(c => c.name === capacityName); + }} />
    From 0fd6455aba777583d6bed36e767da033e9e8d249 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 20 Aug 2026 15:55:35 -0500 Subject: [PATCH 11/20] Add unit tests for new fsmcapacitychart component --- .../src/fsm-chart/FsmCapacityChart.test.tsx | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx new file mode 100644 index 000000000..ae1004c3b --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx @@ -0,0 +1,386 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { FsmTransition } from '@quent/utils'; +import { FsmCapacityChart } from './FsmCapacityChart'; + +// ECharts renders to canvas and is not testable in jsdom; stub it out. +vi.mock('echarts-for-react/lib/core', () => ({ + default: () =>
    , +})); + +vi.mock('../lib/echarts', () => ({ echarts: {} })); +vi.mock('../lib/useChartResize', () => ({ useChartResize: () => ({ handleChartReady: vi.fn() }) })); +vi.mock('../timeline/timelineEchartsTheme', () => ({ + useTimelineEchartsTheme: () => ({ themeName: 'light' }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function transition( + name: string, + timestamp: number, + usages: FsmTransition['usages'] = [] +): FsmTransition { + return { name, timestamp, usages, attributes: [], derived_attributes: [] }; +} + +function usage(resource: string, caps: Record): FsmTransition['usages'][0] { + return { resource, capacities: Object.entries(caps) }; +} + +const BYTES_SPEC = { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'binary', + rate_prefix: 'decimal', +} as const; + +const UNIT_SPEC = { + symbol: '', + singular: 'unit', + plural: 'units', + occupancy_prefix: 'none', + rate_prefix: 'none', +} as const; + +const RATE_SPEC = { + symbol: 'B/s', + singular: 'byte per second', + plural: 'bytes per second', + occupancy_prefix: 'binary', + rate_prefix: 'decimal', +} as const; + +const defaultProps = { + isDark: false, + resourceLabel: (id: string) => id, + quantitySpecs: { bytes: BYTES_SPEC, unit: UNIT_SPEC }, + getCapacityDecl: () => undefined, +} as const; + +// Two transitions are the minimum for a capacity to appear (≥2 readings). +const TWO_TRANSITIONS = [ + transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n })]), + transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n })]), +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FsmCapacityChart', () => { + describe('rendering', () => { + it('renders nothing when there are no transitions', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when all capacities have fewer than 2 readings', () => { + const transitions = [ + transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n })]), + ]; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the chart when at least one capacity has ≥2 readings', () => { + render(); + expect(screen.getByTestId('echarts')).toBeInTheDocument(); + }); + + it('shows the capacity stat label in the header', () => { + const getCapacityDecl = (_id: string, name: string) => + name === 'capacity_bytes' + ? { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' } + : undefined; + + render( + + ); + + expect(screen.getByText('capacity_bytes (B)')).toBeInTheDocument(); + }); + + it('shows the bare capacity name when its quantity has no symbol (dimensionless)', () => { + const getCapacityDecl = (_id: string, name: string) => + name === 'unit' + ? { name: 'unit', kind: 'Occupancy' as const, quantity: 'unit' } + : undefined; + + const transitions = [ + transition('running', 0, [usage('cpu-1', { unit: 1n })]), + transition('idle', 1, [usage('cpu-1', { unit: 1n })]), + ]; + + render( + + ); + + expect(screen.getByText('unit')).toBeInTheDocument(); + }); + }); + + describe('resource selector', () => { + it('hides the resource selector when only one resource has data', () => { + render(); + expect(screen.queryByRole('combobox', { name: 'Select resource' })).not.toBeInTheDocument(); + }); + + it('shows the resource selector when multiple resources have data', () => { + const transitions = [ + transition('running', 0, [ + usage('mem-1', { capacity_bytes: 1024n }), + usage('cpu-1', { unit: 1n }), + ]), + transition('idle', 1, [ + usage('mem-1', { capacity_bytes: 2048n }), + usage('cpu-1', { unit: 1n }), + ]), + ]; + + render( + (id === 'mem-1' ? 'Memory' : 'CPU')} + transitions={transitions} + /> + ); + + expect(screen.getByRole('combobox', { name: 'Select resource' })).toBeInTheDocument(); + }); + + it('lists all resources with data in the resource selector', () => { + const transitions = [ + transition('running', 0, [ + usage('mem-1', { capacity_bytes: 1024n }), + usage('cpu-1', { unit: 1n }), + ]), + transition('idle', 1, [ + usage('mem-1', { capacity_bytes: 2048n }), + usage('cpu-1', { unit: 1n }), + ]), + ]; + + render( + (id === 'mem-1' ? 'Memory' : 'CPU')} + transitions={transitions} + /> + ); + + const select = screen.getByRole('combobox', { name: 'Select resource' }); + const options = Array.from(select.querySelectorAll('option')).map(o => o.textContent); + expect(options).toContain('Memory'); + expect(options).toContain('CPU'); + }); + }); + + describe('capacity selector', () => { + it('hides the capacity selector when the active resource has only one capacity', () => { + render(); + expect(screen.queryByRole('combobox', { name: 'Select capacity' })).not.toBeInTheDocument(); + }); + + it('shows the capacity selector when the active resource has multiple capacities', () => { + const transitions = [ + transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n, unit: 1n })]), + transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n, unit: 1n })]), + ]; + + render(); + + expect(screen.getByRole('combobox', { name: 'Select capacity' })).toBeInTheDocument(); + }); + + it('lists all capacities for the active resource', () => { + const transitions = [ + transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n, unit: 1n })]), + transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n, unit: 1n })]), + ]; + + render(); + + const select = screen.getByRole('combobox', { name: 'Select capacity' }); + const options = Array.from(select.querySelectorAll('option')).map(o => o.value); + expect(options).toContain('capacity_bytes'); + expect(options).toContain('unit'); + }); + + it('resets the capacity selection when the resource changes', () => { + const getCapacityDecl = (_id: string, name: string) => { + if (name === 'capacity_bytes') + return { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' }; + if (name === 'rate_bytes') + return { name: 'rate_bytes', kind: 'Rate' as const, quantity: 'rate' }; + return undefined; + }; + + const transitions = [ + transition('running', 0, [ + usage('mem-1', { capacity_bytes: 1024n }), + usage('fs-1', { capacity_bytes: 512n, rate_bytes: 100n }), + ]), + transition('idle', 1, [ + usage('mem-1', { capacity_bytes: 2048n }), + usage('fs-1', { capacity_bytes: 1024n, rate_bytes: 200n }), + ]), + ]; + + render( + (id === 'mem-1' ? 'Memory' : 'Filesystem')} + quantitySpecs={{ bytes: BYTES_SPEC, rate: RATE_SPEC }} + getCapacityDecl={getCapacityDecl} + transitions={transitions} + /> + ); + + // Switch to Filesystem and select rate_bytes + const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); + fireEvent.change(resourceSelect, { target: { value: 'fs-1' } }); + + const capacitySelect = screen.getByRole('combobox', { name: 'Select capacity' }); + fireEvent.change(capacitySelect, { target: { value: 'rate_bytes' } }); + expect(screen.getByText('rate_bytes (B/s)')).toBeInTheDocument(); + + // Switch back to Memory — capacity should reset to its first capacity + fireEvent.change(resourceSelect, { target: { value: 'mem-1' } }); + expect(screen.getByText('capacity_bytes (B)')).toBeInTheDocument(); + }); + }); + + describe('defaultCapacityPredicate', () => { + it('defaults to the first capacity when no predicate is provided', () => { + // unit appears before capacity_bytes in insertion order + const transitions = [ + transition('running', 0, [usage('mem-1', { unit: 1n, capacity_bytes: 1024n })]), + transition('idle', 1, [usage('mem-1', { unit: 1n, capacity_bytes: 2048n })]), + ]; + + render(); + + // Without a predicate, insertion order wins — unit is first + expect(screen.getByText('unit', { selector: 'span' })).toBeInTheDocument(); + }); + + it('sorts the preferred capacity to the front when a predicate is provided', () => { + const getCapacityDecl = (_id: string, name: string) => + name === 'capacity_bytes' + ? { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' } + : undefined; + + // unit is inserted before capacity_bytes + const transitions = [ + transition('running', 0, [usage('mem-1', { unit: 1n, capacity_bytes: 1024n })]), + transition('idle', 1, [usage('mem-1', { unit: 1n, capacity_bytes: 2048n })]), + ]; + + render( + name === 'capacity_bytes'} + /> + ); + + // Predicate pushes capacity_bytes to front — it becomes the default + expect(screen.getByText('capacity_bytes (B)')).toBeInTheDocument(); + }); + + it('sorts resources with the preferred capacity to the front', () => { + const transitions = [ + transition('running', 0, [ + usage('cpu-1', { unit: 1n }), + usage('mem-1', { capacity_bytes: 1024n }), + ]), + transition('idle', 1, [ + usage('cpu-1', { unit: 1n }), + usage('mem-1', { capacity_bytes: 2048n }), + ]), + ]; + + render( + (id === 'mem-1' ? 'Memory' : 'CPU')} + transitions={transitions} + defaultCapacityPredicate={name => name === 'capacity_bytes'} + /> + ); + + // Memory (with capacity_bytes) should be the active resource by default + const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); + expect((resourceSelect as HTMLSelectElement).value).toBe('mem-1'); + }); + }); + + describe('entity change', () => { + it('resets selections when transitions change', () => { + const getCapacityDecl = (_id: string, name: string) => + name === 'capacity_bytes' + ? { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' } + : undefined; + + const firstTransitions = [ + transition('running', 0, [ + usage('mem-1', { capacity_bytes: 1024n }), + usage('mem-2', { capacity_bytes: 512n }), + ]), + transition('idle', 1, [ + usage('mem-1', { capacity_bytes: 2048n }), + usage('mem-2', { capacity_bytes: 1024n }), + ]), + ]; + + const { rerender } = render( + id} + getCapacityDecl={getCapacityDecl} + transitions={firstTransitions} + /> + ); + + // Select the second resource + const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); + fireEvent.change(resourceSelect, { target: { value: 'mem-2' } }); + expect((resourceSelect as HTMLSelectElement).value).toBe('mem-2'); + + // Simulate opening a different entity (new transitions reference) + const secondTransitions = [ + transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n })]), + transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n })]), + ]; + + rerender( + id} + getCapacityDecl={getCapacityDecl} + transitions={secondTransitions} + /> + ); + + // Resource selector should be gone (only one resource) and mem-1 is active + expect(screen.queryByRole('combobox', { name: 'Select resource' })).not.toBeInTheDocument(); + expect(screen.getByText('capacity_bytes (B)')).toBeInTheDocument(); + }); + }); +}); From 625dec8d493e1fd58eea4ba2192d9c25f7567b9c Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 20 Aug 2026 16:51:56 -0500 Subject: [PATCH 12/20] Add unit tests for entity details panel --- .../entities-table/EntityDetailPanel.test.tsx | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 ui/src/components/entities-table/EntityDetailPanel.test.tsx diff --git a/ui/src/components/entities-table/EntityDetailPanel.test.tsx b/ui/src/components/entities-table/EntityDetailPanel.test.tsx new file mode 100644 index 000000000..c6fbdb6da --- /dev/null +++ b/ui/src/components/entities-table/EntityDetailPanel.test.tsx @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; +import { EntityDetailPanel } from './EntityDetailPanel'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/contexts/ThemeContext', () => ({ + useTheme: () => ({ theme: 'light' }), + THEME_DARK: 'dark', +})); + +vi.mock('@quent/components', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + FsmCapacityChart: () =>
    , + }; +}); + +vi.mock('./ResourceUsageList', () => ({ + ResourceUsageList: () => null, +})); + +vi.mock('./TransitionAttributes', () => ({ + TransitionAttributes: () => null, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeTransition( + name: string, + timestamp: number +): FiniteStateMachine['transitions'][0] { + return { name, timestamp, usages: [], attributes: [], derived_attributes: [] }; +} + +const QUERY_BUNDLE = { + entities: { resources: {}, resource_types: {} }, + quantity_specs: {}, +} as unknown as QueryBundle; + +const BASE_FSM: FiniteStateMachine = { + id: 'test-uuid-1234', + instance_name: 'task-7', + type_name: 'task', + transitions: [ + makeTransition('queueing', 0), + makeTransition('running', 0.001), + makeTransition('done', 0.003), + ], +}; + +const DEFAULT_PROPS = { + resourceLabel: (id: string) => id, + operatorLabel: (id: string) => id, + queryBundle: QUERY_BUNDLE, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('EntityDetailPanel', () => { + describe('empty state', () => { + it('shows a placeholder when fsm is null', () => { + render(); + expect(screen.getByText('Select an entity to view its states.')).toBeInTheDocument(); + }); + + it('renders nothing structural when fsm is null', () => { + render(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + }); + + describe('header', () => { + it('shows the instance name and type badge', () => { + render(); + expect(screen.getByText('task-7')).toBeInTheDocument(); + expect(screen.getByText('task')).toBeInTheDocument(); + }); + + it('shows the entity id', () => { + render(); + expect(screen.getByText('test-uuid-1234')).toBeInTheDocument(); + }); + + it('copies the entity id when the copy button is clicked', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Copy ID' })); + + expect(writeText).toHaveBeenCalledWith('test-uuid-1234'); + }); + }); + + describe('total span', () => { + it('displays the total span derived from the first and last transition timestamps', () => { + // timestamps: 0s → 1s → total span = 1000ms + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [makeTransition('running', 0), makeTransition('done', 1)], + }; + render(); + // Scope to the "Total span" row to avoid ambiguity with the transition duration + const totalSpanRow = screen.getByText('Total span').closest('div'); + expect(totalSpanRow).toHaveTextContent('1.00s'); + }); + + it('shows a zero span when there is only one transition', () => { + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [makeTransition('running', 5)], + }; + render(); + // formatDuration(0) returns "0.00ns" + const totalSpanRow = screen.getByText('Total span').closest('div'); + expect(totalSpanRow).toHaveTextContent('0.00ns'); + }); + }); + + describe('dominant state', () => { + it('shows the state with the most accumulated time', () => { + // queueing: 1ms, running: 2ms → dominant is running (66.7%) + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [ + makeTransition('queueing', 0), + makeTransition('running', 0.001), + makeTransition('done', 0.003), + ], + }; + render(); + expect(screen.getByText('Dominant state')).toBeInTheDocument(); + // The dominant state name and percentage are rendered together in one element + expect(screen.getByText(/running.*66\.7%/)).toBeInTheDocument(); + }); + + it('does not show dominant state when there is only one transition (no measurable durations)', () => { + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [makeTransition('running', 0)], + }; + render(); + expect(screen.queryByText('Dominant state')).not.toBeInTheDocument(); + }); + + it('uses stateColorFn to color the dominant state when provided', () => { + const stateColorFn = vi.fn().mockReturnValue('#ff0000'); + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [makeTransition('running', 0), makeTransition('done', 1)], + }; + render(); + expect(stateColorFn).toHaveBeenCalledWith('running'); + }); + + it('accumulates time correctly for repeated states', () => { + // running twice: 1ms + 3ms = 4ms; idle once: 2ms → dominant is running (66.7%) + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [ + makeTransition('running', 0), + makeTransition('idle', 0.001), + makeTransition('running', 0.003), + makeTransition('done', 0.006), + ], + }; + render(); + // total span 6ms, running = 4ms = 66.7% + expect(screen.getByText(/running.*66\.7%/)).toBeInTheDocument(); + }); + }); + + describe('transition list', () => { + it('renders all transitions with 1-based indices', () => { + render(); + // The index spans render as "1.", "2.", "3." — scope to span to avoid ambiguity + expect(screen.getAllByText('1.', { selector: 'span' })).toHaveLength(1); + expect(screen.getAllByText('2.', { selector: 'span' })).toHaveLength(1); + expect(screen.getAllByText('3.', { selector: 'span' })).toHaveLength(1); + }); + + it('shows all transition state names', () => { + render(); + expect(screen.getByText('queueing')).toBeInTheDocument(); + expect(screen.getByText('running')).toBeInTheDocument(); + expect(screen.getByText('done')).toBeInTheDocument(); + }); + + it('shows a duration for all transitions except the last', () => { + // transitions at 0ms, 500ms, 1000ms + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [ + makeTransition('queueing', 0), + makeTransition('running', 0.5), + makeTransition('done', 1), + ], + }; + render(); + // Both intermediate transitions have a duration of 500ms + const durations = screen.getAllByText('500.00ms'); + expect(durations).toHaveLength(2); + }); + + it('highlights a bottleneck transition that consumes more than 50% of total span', () => { + // running: 900ms out of 1000ms total = 90% → bottleneck + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [ + makeTransition('running', 0), + makeTransition('done', 0.9), + makeTransition('end', 1), + ], + }; + render(); + const bottleneckDuration = screen.getByText('900.00ms'); + expect(bottleneckDuration).toHaveClass('text-orange-500'); + }); + + it('does not highlight a non-bottleneck transition', () => { + // running: 400ms, done: 600ms out of 1000ms → neither is >50% in first, done is but check running + const fsm: FiniteStateMachine = { + ...BASE_FSM, + transitions: [ + makeTransition('running', 0), + makeTransition('done', 0.4), + makeTransition('end', 1), + ], + }; + render(); + const nonBottleneck = screen.getByText('400.00ms'); + expect(nonBottleneck).not.toHaveClass('text-orange-500'); + }); + }); +}); From b17267a31afe7ad73b7c1457adbca06bba5b75cc Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 20 Aug 2026 16:55:58 -0500 Subject: [PATCH 13/20] fix: linting --- .../components/src/fsm-chart/FsmCapacityChart.test.tsx | 8 ++++---- .../@quent/components/src/fsm-chart/FsmCapacityChart.tsx | 8 ++++++-- .../components/entities-table/EntityDetailPanel.test.tsx | 7 ++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx index ae1004c3b..dbc0f7d95 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx @@ -82,10 +82,10 @@ describe('FsmCapacityChart', () => { }); it('renders nothing when all capacities have fewer than 2 readings', () => { - const transitions = [ - transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n })]), - ]; - const { container } = render(); + const transitions = [transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n })])]; + const { container } = render( + + ); expect(container.firstChild).toBeNull(); }); diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index 7b0337453..7b97519cc 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -67,7 +67,10 @@ export function FsmCapacityChart({ // Accumulate data keyed by resourceId → capacityName const resourceMap = new Map< string, - { label: string; caps: Map; rawData: Array }> } + { + label: string; + caps: Map; rawData: Array }>; + } >(); transitions.forEach((t, i) => { @@ -200,7 +203,8 @@ export function FsmCapacityChart({ axisLabel: { show: true, fontSize: 9, - formatter: (v: number) => (activeCapacity ? activeCapacity.formatter(v) : formatBytes(v, 0)), + formatter: (v: number) => + activeCapacity ? activeCapacity.formatter(v) : formatBytes(v, 0), }, splitLine: { show: true, lineStyle: { opacity: 0.25 } }, minInterval: 1, diff --git a/ui/src/components/entities-table/EntityDetailPanel.test.tsx b/ui/src/components/entities-table/EntityDetailPanel.test.tsx index c6fbdb6da..1be3cd688 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.test.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { EntityDetailPanel } from './EntityDetailPanel'; @@ -35,10 +35,7 @@ vi.mock('./TransitionAttributes', () => ({ // Fixtures // --------------------------------------------------------------------------- -function makeTransition( - name: string, - timestamp: number -): FiniteStateMachine['transitions'][0] { +function makeTransition(name: string, timestamp: number): FiniteStateMachine['transitions'][0] { return { name, timestamp, usages: [], attributes: [], derived_attributes: [] }; } From f65d283e729824d46e2ee8cf1b7aac4354d7150c Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 08:53:08 -0500 Subject: [PATCH 14/20] fix: QuantitySpec type errors --- .../src/fsm-chart/FsmCapacityChart.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx index dbc0f7d95..ba743e40d 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { FsmTransition } from '@quent/utils'; +import type { FsmTransition, QuantitySpec } from '@quent/utils'; import { FsmCapacityChart } from './FsmCapacityChart'; // ECharts renders to canvas and is not testable in jsdom; stub it out. @@ -37,25 +37,25 @@ const BYTES_SPEC = { symbol: 'B', singular: 'byte', plural: 'bytes', - occupancy_prefix: 'binary', - rate_prefix: 'decimal', -} as const; + occupancy_prefix: 'Iec', + rate_prefix: 'Si', +} satisfies QuantitySpec; const UNIT_SPEC = { symbol: '', singular: 'unit', plural: 'units', - occupancy_prefix: 'none', - rate_prefix: 'none', -} as const; + occupancy_prefix: 'None', + rate_prefix: 'None', +} satisfies QuantitySpec; const RATE_SPEC = { symbol: 'B/s', singular: 'byte per second', plural: 'bytes per second', - occupancy_prefix: 'binary', - rate_prefix: 'decimal', -} as const; + occupancy_prefix: 'Iec', + rate_prefix: 'Si', +} satisfies QuantitySpec; const defaultProps = { isDark: false, From 62f0f6f5ae199289b33860864be0dd9a57eb8379 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 09:01:43 -0500 Subject: [PATCH 15/20] fix: calculate shift from bigint bit length; update tests accordingly --- .../@quent/utils/src/formatters.test.ts | 32 +++++++++++++++++++ ui/packages/@quent/utils/src/formatters.ts | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/ui/packages/@quent/utils/src/formatters.test.ts b/ui/packages/@quent/utils/src/formatters.test.ts index 1503a11d4..ad3ecd527 100644 --- a/ui/packages/@quent/utils/src/formatters.test.ts +++ b/ui/packages/@quent/utils/src/formatters.test.ts @@ -20,6 +20,7 @@ import { formatAttributeValue, isBytesRateStat, isNumericValue, + bigintToChartNumber, } from './formatters'; import type { QuantitySpec } from './types/index'; @@ -326,6 +327,37 @@ describe('formatBytes', () => { }); }); +// --------------------------------------------------------------------------- +// bigintToChartNumber +// --------------------------------------------------------------------------- + +describe('bigintToChartNumber', () => { + it('converts values within MAX_SAFE_INTEGER exactly', () => { + expect(bigintToChartNumber(0n)).toBe(0); + expect(bigintToChartNumber(1024n)).toBe(1024); + expect(bigintToChartNumber(BigInt(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('scales values just above MAX_SAFE_INTEGER within a safe relative error', () => { + const n = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const result = bigintToChartNumber(n); + expect(Number.isSafeInteger(result) || result <= Number.MAX_SAFE_INTEGER * 2).toBe(true); + expect(Math.abs(result - Number(n)) / Number(n)).toBeLessThan(1e-9); + }); + + it('retains precision for values above 2^63', () => { + const n = 1n << 63n; + const result = bigintToChartNumber(n); + expect(Math.abs(result - Number(n)) / Number(n)).toBeLessThan(1e-9); + }); + + it('retains precision for u64::MAX', () => { + const n = (1n << 64n) - 1n; + const result = bigintToChartNumber(n); + expect(Math.abs(result - Number(n)) / Number(n)).toBeLessThan(1e-9); + }); +}); + // --------------------------------------------------------------------------- // formatNumber // --------------------------------------------------------------------------- diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts index de32a80a6..81bc9fb2c 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -269,12 +269,15 @@ export function formatBytes(value: number | bigint, decimals = 1): string { /** * Convert a bigint to a JS number safe for use as a chart data point. * Values within Number.MAX_SAFE_INTEGER are converted exactly. Larger values - * are scaled to the nearest KiB to stay within safe integer range (preserving - * precision up to ~9 EiB). + * are right-shifted by just enough bits to fit their mantissa within 53 bits + * before conversion, so precision is retained regardless of magnitude (up to + * and beyond u64::MAX). */ export function bigintToChartNumber(n: bigint): number { if (n <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(n); - return Number(n >> 10n) * 1024; + const bitLength = n.toString(2).length; + const shift = BigInt(bitLength - 53); + return Number(n >> shift) * 2 ** Number(shift); } /** Bytes-like statistic names (pivot tables, DAG field labels). */ From 22f769408b2a233cae6050afebc47dcebdedf539 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 09:11:29 -0500 Subject: [PATCH 16/20] fix: store settimeout handle and clear timer --- .../entities-table/EntityDetailPanel.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index 6ae9893d5..e36937479 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { DataFlowBar, DataText, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; import { formatDuration, formatDurationForWindow, getColorForKey, isBytesStat } from '@quent/utils'; @@ -28,6 +28,15 @@ export function EntityDetailPanel({ const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? ('dark' as const) : ('light' as const); const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimeoutRef.current != null) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); if (!fsm) { return ( @@ -37,6 +46,7 @@ export function EntityDetailPanel({ ); } + const fsmId = fsm.id; const firstTs = fsm.transitions[0]?.timestamp ?? 0; const lastTs = fsm.transitions[fsm.transitions.length - 1]?.timestamp ?? firstTs; const totalSpanMs = (lastTs - firstTs) * 1000; @@ -75,9 +85,12 @@ export function EntityDetailPanel({ } function copyId() { - void navigator.clipboard.writeText(fsm!.id); + void navigator.clipboard.writeText(fsmId); setCopied(true); - setTimeout(() => setCopied(false), 1500); + if (copiedTimeoutRef.current != null) { + clearTimeout(copiedTimeoutRef.current); + } + copiedTimeoutRef.current = setTimeout(() => setCopied(false), 1500); } return ( From 5af2cfb8132f6e2abe5a9bf693d15910b1f07a61 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 09:41:32 -0500 Subject: [PATCH 17/20] fix: use cn for class generation --- .../entities-table/EntityDetailPanel.tsx | 17 ++++++++++++----- .../entities-table/TransitionAttributes.tsx | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index e36937479..bbe25be36 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -4,7 +4,13 @@ import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { DataFlowBar, DataText, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; -import { formatDuration, formatDurationForWindow, getColorForKey, isBytesStat } from '@quent/utils'; +import { + cn, + formatDuration, + formatDurationForWindow, + getColorForKey, + isBytesStat, +} from '@quent/utils'; import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; import { ResourceUsageList } from './ResourceUsageList'; @@ -171,7 +177,7 @@ export function EntityDetailPanel({ }} /> -
      +
        {fsm.transitions.map((transition, index) => { const durationMs = durations[index] ?? null; const isBottleneck = @@ -198,9 +204,10 @@ export function EntityDetailPanel({
        {durationMs != null && ( {formatDuration(durationMs)} diff --git a/ui/src/components/entities-table/TransitionAttributes.tsx b/ui/src/components/entities-table/TransitionAttributes.tsx index 7d8efd601..23b0c57e3 100644 --- a/ui/src/components/entities-table/TransitionAttributes.tsx +++ b/ui/src/components/entities-table/TransitionAttributes.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Card } from '@quent/components'; -import { formatAttributeValue, unwrapTaggedValue } from '@quent/utils'; +import { cn, formatAttributeValue, unwrapTaggedValue } from '@quent/utils'; import type { DynamicAttribute } from '@quent/utils'; interface TransitionAttributesProps { @@ -47,7 +47,7 @@ function AttributeGroup({ return (

        {title}

        -
        +
        {attributes.map((attribute, index) => { const { label, value } = resolveAttributeDisplay(attribute, operatorLabel); return ( From 74cac3d554b83b6ed105119f868664899dbd7c2c Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 09:52:25 -0500 Subject: [PATCH 18/20] fix: don't clear selection until gant click runs; add test --- ui/src/components/EntityDetailDrawer.test.tsx | 25 +++++++++++++++++++ ui/src/components/EntityDetailDrawer.tsx | 11 +++++++- ui/src/components/LongEntitiesRow.tsx | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/ui/src/components/EntityDetailDrawer.test.tsx b/ui/src/components/EntityDetailDrawer.test.tsx index e619256aa..23e395b6b 100644 --- a/ui/src/components/EntityDetailDrawer.test.tsx +++ b/ui/src/components/EntityDetailDrawer.test.tsx @@ -48,4 +48,29 @@ describe('EntityDetailDrawer', () => { expect(onClose).toHaveBeenCalledOnce(); }); + + it('does not close when a long-entities Gantt entity is clicked', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + + render( + <> +
        + +
        + id} + operatorLabel={id => id} + onClose={onClose} + queryBundle={queryBundle} + /> + + ); + + await waitFor(() => expect(document.body).toHaveStyle({ pointerEvents: 'auto' })); + await user.click(screen.getByText('Entity bar')); + + expect(onClose).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx index 6dfee7f0c..708511064 100644 --- a/ui/src/components/EntityDetailDrawer.tsx +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -45,7 +45,16 @@ export function EntityDetailDrawer({ > { + const target = event.detail.originalEvent.target; + // Entity clicks on the long-entities Gantt already toggle the + // selection via onEntitySelect; closing here first would clear + // drawerFsm before that handler runs, breaking the toggle. + if (target instanceof Element && target.closest('[data-long-entities-gantt]')) { + return; + } + onClose(); + }} className="h-full w-80 shadow-xl sm:max-w-none" >
        diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index 4f09f5eb1..4e59c2baf 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -137,7 +137,7 @@ export function LongEntitiesRow({ } return ( -
        +
        Date: Fri, 21 Aug 2026 11:54:57 -0500 Subject: [PATCH 19/20] Use shadcn select fields; add testing infra to test these components, update useMemo dep --- .../src/fsm-chart/FsmCapacityChart.test.tsx | 47 ++++++++++--------- .../src/fsm-chart/FsmCapacityChart.tsx | 44 ++++++++--------- ui/src/components/QueryResourceTree.tsx | 2 +- ui/src/test/setup.ts | 6 +++ 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx index ba743e40d..f85e6f4d4 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import type { FsmTransition, QuantitySpec } from '@quent/utils'; import { FsmCapacityChart } from './FsmCapacityChart'; @@ -70,6 +71,13 @@ const TWO_TRANSITIONS = [ transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n })]), ]; +/** Open a combobox by its accessible name and pick the option with the given text. */ +async function selectOption(comboboxName: string, optionName: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox', { name: comboboxName })); + await user.click(await screen.findByRole('option', { name: optionName })); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -163,7 +171,7 @@ describe('FsmCapacityChart', () => { expect(screen.getByRole('combobox', { name: 'Select resource' })).toBeInTheDocument(); }); - it('lists all resources with data in the resource selector', () => { + it('lists all resources with data in the resource selector', async () => { const transitions = [ transition('running', 0, [ usage('mem-1', { capacity_bytes: 1024n }), @@ -183,8 +191,9 @@ describe('FsmCapacityChart', () => { /> ); - const select = screen.getByRole('combobox', { name: 'Select resource' }); - const options = Array.from(select.querySelectorAll('option')).map(o => o.textContent); + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox', { name: 'Select resource' })); + const options = (await screen.findAllByRole('option')).map(o => o.textContent); expect(options).toContain('Memory'); expect(options).toContain('CPU'); }); @@ -207,7 +216,7 @@ describe('FsmCapacityChart', () => { expect(screen.getByRole('combobox', { name: 'Select capacity' })).toBeInTheDocument(); }); - it('lists all capacities for the active resource', () => { + it('lists all capacities for the active resource', async () => { const transitions = [ transition('running', 0, [usage('mem-1', { capacity_bytes: 1024n, unit: 1n })]), transition('idle', 1, [usage('mem-1', { capacity_bytes: 2048n, unit: 1n })]), @@ -215,13 +224,14 @@ describe('FsmCapacityChart', () => { render(); - const select = screen.getByRole('combobox', { name: 'Select capacity' }); - const options = Array.from(select.querySelectorAll('option')).map(o => o.value); + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox', { name: 'Select capacity' })); + const options = (await screen.findAllByRole('option')).map(o => o.textContent); expect(options).toContain('capacity_bytes'); expect(options).toContain('unit'); }); - it('resets the capacity selection when the resource changes', () => { + it('resets the capacity selection when the resource changes', async () => { const getCapacityDecl = (_id: string, name: string) => { if (name === 'capacity_bytes') return { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' }; @@ -252,15 +262,12 @@ describe('FsmCapacityChart', () => { ); // Switch to Filesystem and select rate_bytes - const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); - fireEvent.change(resourceSelect, { target: { value: 'fs-1' } }); - - const capacitySelect = screen.getByRole('combobox', { name: 'Select capacity' }); - fireEvent.change(capacitySelect, { target: { value: 'rate_bytes' } }); + await selectOption('Select resource', 'Filesystem'); + await selectOption('Select capacity', 'rate_bytes'); expect(screen.getByText('rate_bytes (B/s)')).toBeInTheDocument(); // Switch back to Memory — capacity should reset to its first capacity - fireEvent.change(resourceSelect, { target: { value: 'mem-1' } }); + await selectOption('Select resource', 'Memory'); expect(screen.getByText('capacity_bytes (B)')).toBeInTheDocument(); }); }); @@ -276,7 +283,7 @@ describe('FsmCapacityChart', () => { render(); // Without a predicate, insertion order wins — unit is first - expect(screen.getByText('unit', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('unit', { selector: 'span.font-mono' })).toBeInTheDocument(); }); it('sorts the preferred capacity to the front when a predicate is provided', () => { @@ -326,13 +333,12 @@ describe('FsmCapacityChart', () => { ); // Memory (with capacity_bytes) should be the active resource by default - const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); - expect((resourceSelect as HTMLSelectElement).value).toBe('mem-1'); + expect(screen.getByRole('combobox', { name: 'Select resource' })).toHaveTextContent('Memory'); }); }); describe('entity change', () => { - it('resets selections when transitions change', () => { + it('resets selections when transitions change', async () => { const getCapacityDecl = (_id: string, name: string) => name === 'capacity_bytes' ? { name: 'capacity_bytes', kind: 'Occupancy' as const, quantity: 'bytes' } @@ -359,9 +365,8 @@ describe('FsmCapacityChart', () => { ); // Select the second resource - const resourceSelect = screen.getByRole('combobox', { name: 'Select resource' }); - fireEvent.change(resourceSelect, { target: { value: 'mem-2' } }); - expect((resourceSelect as HTMLSelectElement).value).toBe('mem-2'); + await selectOption('Select resource', 'mem-2'); + expect(screen.getByRole('combobox', { name: 'Select resource' })).toHaveTextContent('mem-2'); // Simulate opening a different entity (new transitions reference) const secondTransitions = [ diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx index 7b97519cc..35ca01055 100644 --- a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -9,6 +9,7 @@ import { echarts } from '../lib/echarts'; import { useChartResize } from '../lib/useChartResize'; import type { PointerPosition } from '../ui/pointer-tooltip-portal'; import { PositionedTooltip } from '../ui/positioned-tooltip'; +import { SelectField } from '../ui/select-field'; import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; import { FsmCapacityTooltip } from './FsmCapacityTooltip'; @@ -42,8 +43,8 @@ export interface FsmCapacityChartProps { defaultCapacityPredicate?: (name: string) => boolean; } -const SELECT_CLASS = - 'max-w-[140px] truncate rounded border border-border bg-background px-1 py-0.5 text-[10px] text-foreground focus:outline-none focus:ring-1 focus:ring-ring'; +const SELECT_TRIGGER_CLASS = + 'h-auto w-auto max-w-[140px] gap-1 truncate rounded border border-border bg-background px-1 py-0.5 text-[10px] text-foreground focus:outline-none focus:ring-1 focus:ring-ring [&>svg]:h-3 [&>svg]:w-3'; export function FsmCapacityChart({ transitions, @@ -242,35 +243,28 @@ export function FsmCapacityChart({
        {resources.length > 1 && ( - + clearable={false} + triggerClassName={SELECT_TRIGGER_CLASS} + /> )} {activeResource && activeResource.capacities.length > 1 && ( - + onValueChange={value => value && setSelectedCapacityName(value)} + clearable={false} + triggerClassName={SELECT_TRIGGER_CLASS} + /> )}
        diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index f61a0ff67..fbe3594f5 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -399,7 +399,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr handleZoomChange, operatorEntriesByWorker, toggleDrawerFsm, - drawerFsm, + drawerFsm?.id, closeDrawer, ]); diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index 3def09c66..34e3cff2b 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -33,6 +33,12 @@ class ResizeObserverMock { // Mock scrollIntoView for Radix UI Select components Element.prototype.scrollIntoView = vi.fn(); +// jsdom doesn't implement pointer capture; Radix UI Select calls these during +// open/select interactions. +Element.prototype.hasPointerCapture = vi.fn().mockReturnValue(false); +Element.prototype.setPointerCapture = vi.fn(); +Element.prototype.releasePointerCapture = vi.fn(); + // Start MSW server before all tests beforeAll(() => { server.listen({ onUnhandledRequest: 'warn' }); From 47dc413c1c3c960039ecf3e66e0b7a4886fc4d86 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 21 Aug 2026 12:16:52 -0500 Subject: [PATCH 20/20] Rename DataFlowBar to SegmentedBar and move to own directory, update refs --- ui/packages/@quent/components/src/index.ts | 6 ++++-- .../@quent/components/src/query-plan/NodeFlowBar.tsx | 6 +++--- .../SegmentValueLabel.tsx | 0 .../SegmentedBar.test.tsx} | 8 ++++---- .../DataFlowBar.tsx => segmented-bar/SegmentedBar.tsx} | 10 +++++----- ui/src/components/entities-table/EntityDetailPanel.tsx | 4 ++-- 6 files changed, 18 insertions(+), 16 deletions(-) rename ui/packages/@quent/components/src/{query-plan => segmented-bar}/SegmentValueLabel.tsx (100%) rename ui/packages/@quent/components/src/{query-plan/DataFlowBar.test.tsx => segmented-bar/SegmentedBar.test.tsx} (89%) rename ui/packages/@quent/components/src/{query-plan/DataFlowBar.tsx => segmented-bar/SegmentedBar.tsx} (96%) diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 0f2bd1b8e..c79078d3d 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -195,10 +195,12 @@ export { DagPlayhead } from './dag/DagPlayhead'; // ─── Query-plan components ──────────────────────────────────────────────────── export { QueryPlanNode } from './query-plan/QueryPlanNode'; -export { DataFlowBar } from './query-plan/DataFlowBar'; -export type { DataFlowBarProps, DataFlowBarSegment } from './query-plan/DataFlowBar'; export { NodeFlowBar } from './query-plan/NodeFlowBar'; +// ─── Segmented-bar components ───────────────────────────────────────────────── +export { SegmentedBar } from './segmented-bar/SegmentedBar'; +export type { SegmentedBarProps, SegmentedBarSegment } from './segmented-bar/SegmentedBar'; + // ─── Resource-tree components ───────────────────────────────────────────────── export { InlineSelector } from './resource-tree/InlineSelector'; export { ResourceColumn } from './resource-tree/ResourceColumn'; diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index aaa81a3a4..82e649b05 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -20,7 +20,7 @@ import { FLOW_BAR_TRACK_GAP, FLOW_BAR_LABEL_HEIGHT, } from '../dag/layout'; -import { DataFlowBar } from './DataFlowBar'; +import { SegmentedBar } from '../segmented-bar/SegmentedBar'; const BAR_TRANSITION = 'width 120ms linear'; @@ -133,7 +133,7 @@ export const NodeFlowBar = memo( style={{ marginTop: FLOW_BAR_TOP_MARGIN }} data-testid="node-flow-bar" > - - { +describe('SegmentedBar', () => { it('configures height, fill scaling, labels, and tooltips', () => { const { container, rerender } = render( - { expect(screen.queryByText('Running: 2')).not.toBeInTheDocument(); rerender( - + ); fireEvent.mouseEnter(screen.getByRole('img', { name: 'running: 20%' }), { diff --git a/ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.tsx similarity index 96% rename from ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx rename to ui/packages/@quent/components/src/segmented-bar/SegmentedBar.tsx index 80ccb8e80..e2528a6c0 100644 --- a/ui/packages/@quent/components/src/query-plan/DataFlowBar.tsx +++ b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.tsx @@ -6,7 +6,7 @@ import { cn } from '@quent/utils'; import { PointerTooltipPortal, type PointerPosition } from '../ui/pointer-tooltip-portal'; import { SegmentValueLabel } from './SegmentValueLabel'; -export interface DataFlowBarSegment { +export interface SegmentedBarSegment { id: string; value: number; color: string; @@ -18,8 +18,8 @@ export interface DataFlowBarSegment { title?: string; } -export interface DataFlowBarProps { - segments: DataFlowBarSegment[]; +export interface SegmentedBarProps { + segments: SegmentedBarSegment[]; fillValue?: number; maxValue?: number; height?: number | string; @@ -33,7 +33,7 @@ export interface DataFlowBarProps { style?: CSSProperties; } -export function DataFlowBar({ +export function SegmentedBar({ segments, fillValue, maxValue, @@ -46,7 +46,7 @@ export function DataFlowBar({ trackClassName, labelTestId, style, -}: DataFlowBarProps) { +}: SegmentedBarProps) { const [tooltip, setTooltip] = useState<{ content: ReactNode; pointer: PointerPosition; diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index bbe25be36..7718a7840 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; -import { DataFlowBar, DataText, FsmCapacityChart, thinScrollbarClass } from '@quent/components'; +import { DataText, FsmCapacityChart, SegmentedBar, thinScrollbarClass } from '@quent/components'; import { cn, formatDuration, @@ -138,7 +138,7 @@ export function EntityDetailPanel({
        )} {totalSpanMs > 0 && stateTimeMs.size > 0 && ( -