(
-
- {label}
-
-);
diff --git a/ui/packages/@quent/components/src/segmented-bar/SegmentValueLabel.tsx b/ui/packages/@quent/components/src/segmented-bar/SegmentValueLabel.tsx
new file mode 100644
index 000000000..7e26dce6e
--- /dev/null
+++ b/ui/packages/@quent/components/src/segmented-bar/SegmentValueLabel.tsx
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { cn, isLightColor } from '@quent/utils';
+import { DataText } from '../ui/data-text';
+
+/** Width-gated value label centered inside an overflow-hidden segment. */
+export const SegmentValueLabel = ({
+ label,
+ segmentColor,
+ testId,
+ className,
+ autoContrast = true,
+}: {
+ label: string;
+ segmentColor: string;
+ testId?: string;
+ className?: string;
+ autoContrast?: boolean;
+}) => (
+
+ {label}
+
+);
diff --git a/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.test.tsx b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.test.tsx
new file mode 100644
index 000000000..60d6b5e62
--- /dev/null
+++ b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.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 { SegmentedBar } from './SegmentedBar';
+
+const segments = [
+ {
+ id: 'running',
+ value: 2,
+ color: '#76b900',
+ label: '2',
+ tooltip:
Running: 2,
+ ariaLabel: 'running: 20%',
+ },
+];
+
+describe('SegmentedBar', () => {
+ 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/segmented-bar/SegmentedBar.tsx b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.tsx
new file mode 100644
index 000000000..e2528a6c0
--- /dev/null
+++ b/ui/packages/@quent/components/src/segmented-bar/SegmentedBar.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 SegmentedBarSegment {
+ id: string;
+ value: number;
+ color: string;
+ label?: string;
+ labelClassName?: string;
+ autoLabelContrast?: boolean;
+ tooltip?: ReactNode;
+ ariaLabel?: string;
+ title?: string;
+}
+
+export interface SegmentedBarProps {
+ segments: SegmentedBarSegment[];
+ 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 SegmentedBar({
+ segments,
+ fillValue,
+ maxValue,
+ height = 12,
+ minimumFillPx = 0,
+ showLabels = true,
+ showTooltips = true,
+ transition,
+ className,
+ trackClassName,
+ labelTestId,
+ style,
+}: SegmentedBarProps) {
+ 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/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(
- ,
- document.body
+
);
}
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/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
+ );
+}
diff --git a/ui/packages/@quent/utils/src/formatters.test.ts b/ui/packages/@quent/utils/src/formatters.test.ts
index 8fe360796..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';
@@ -109,6 +110,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');
+ });
});
// ---------------------------------------------------------------------------
@@ -310,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 8c637a026..81bc9fb2c 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);
}
@@ -259,6 +266,20 @@ 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 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);
+ 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). */
export function isBytesStat(name: string): boolean {
return (
@@ -378,7 +399,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
@@ -393,7 +414,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/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts
index 672124165..8bcc02577 100644
--- a/ui/packages/@quent/utils/src/index.ts
+++ b/ui/packages/@quent/utils/src/index.ts
@@ -47,6 +47,8 @@ export {
inferFieldFormatter,
formatStatWithQuantity,
isNumericValue,
+ isBytesStat,
+ bigintToChartNumber,
} from './formatters';
// Rust-generated TypeScript types
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index e401505b9..067d4d4dd 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:'
@@ -1179,6 +1182,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:
@@ -1276,6 +1292,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:
@@ -1289,6 +1314,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:
@@ -4184,6 +4222,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'}
@@ -5154,6 +5198,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
@@ -5238,6 +5305,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)
@@ -5249,6 +5322,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)
@@ -7946,6 +8030,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..23e395b6b
--- /dev/null
+++ b/ui/src/components/EntityDetailDrawer.test.tsx
@@ -0,0 +1,76 @@
+// 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 type { EntityRef, QueryBundle } from '@quent/utils';
+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: [],
+};
+const queryBundle = {} as QueryBundle;
+
+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}
+ queryBundle={queryBundle}
+ />
+ >
+ );
+
+ 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();
+ });
+
+ 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
new file mode 100644
index 000000000..708511064
--- /dev/null
+++ b/ui/src/components/EntityDetailDrawer.tsx
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { X } from 'lucide-react';
+import {
+ Button,
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerPortal,
+ DrawerTitle,
+} from '@quent/components';
+import type { EntityRef, FiniteStateMachine, QueryBundle } 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;
+ queryBundle: QueryBundle;
+}
+
+export function EntityDetailDrawer({
+ fsm,
+ resourceLabel,
+ operatorLabel,
+ onClose,
+ stateColorFn,
+ queryBundle,
+}: EntityDetailDrawerProps) {
+ return (
+ {
+ if (!open) onClose();
+ }}
+ direction="right"
+ modal={false}
+ noBodyStyles
+ shouldScaleBackground={false}
+ handleOnly
+ >
+
+ {
+ 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"
+ >
+
+ Entity details
+
+ Details for the selected entity.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx
index 528fc1999..4e59c2baf 100644
--- a/ui/src/components/LongEntitiesRow.tsx
+++ b/ui/src/components/LongEntitiesRow.tsx
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { useMemo, useRef, useState } from 'react';
+import { useCallback, useMemo, useRef, useState } from 'react';
import { useEntityList } from '@quent/client';
import {
useBulkInitialized,
@@ -11,7 +11,7 @@ import {
useReturnedTimelineNumBins,
useSelectedNodeIds,
} from '@quent/hooks';
-import { type FsmTypeDecl, MAX_TIMELINE_BINS } from '@quent/utils';
+import { type FiniteStateMachine, type FsmTypeDecl, MAX_TIMELINE_BINS } from '@quent/utils';
import {
Button,
LONG_ENTITIES_TIMELINE_HEIGHT,
@@ -19,6 +19,7 @@ import {
Skeleton,
buildLongEntityEntries,
getLongEntitiesThreshold,
+ type LongEntityEntry,
} from '@quent/components';
const ENTITIES_PER_PAGE = 100;
@@ -33,6 +34,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;
};
/**
@@ -48,6 +52,9 @@ export function LongEntitiesRow({
fsmTypes,
isDark,
fsmStateScope = 'resource',
+ onEntitySelect,
+ selectedEntityId,
+ onBackgroundClick,
}: LongEntitiesRowProps) {
const selectedNodeIds = useSelectedNodeIds();
const debouncedZoomRange = useDebouncedZoomRange();
@@ -105,6 +112,15 @@ export function LongEntitiesRow({
const isLoadingMore = isPlaceholderData && entities.length < maxEntities;
const showMoreButton = hasMoreEntities && (!isLoadingMore || maxEntities < totalEntities);
+ const handleEntityClick = useCallback(
+ (entry: LongEntityEntry) => {
+ if (!onEntitySelect) return;
+ const fsm = entities.find(e => e.id === entry.entityId);
+ if (fsm) onEntitySelect(fsm);
+ },
+ [entities, onEntitySelect]
+ );
+
if (displayedMinUsageSeconds == null || (!data && isFetching)) {
return (
+
{showMoreButton && (
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 5baed8754..fbe3594f5 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,34 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr
const [selectedTypes, setSelectedTypes] = useAtom(selectedTypesAtom);
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(
+ () => 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 +360,9 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr
durationSeconds={durationSeconds}
fsmTypes={entities.fsm_types}
isDark={isDark}
+ onEntitySelect={toggleDrawerFsm}
+ selectedEntityId={drawerFsm?.id}
+ onBackgroundClick={closeDrawer}
/>
);
}
@@ -364,6 +398,9 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr
queryBundle,
handleZoomChange,
operatorEntriesByWorker,
+ toggleDrawerFsm,
+ drawerFsm?.id,
+ closeDrawer,
]);
return (
@@ -383,6 +420,14 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr
rowHeight={DEFAULT_TIMELINE_HEIGHT}
/>
+
);
}
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..1be3cd688
--- /dev/null
+++ b/ui/src/components/entities-table/EntityDetailPanel.test.tsx
@@ -0,0 +1,244 @@
+// 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 { 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');
+ });
+ });
+});
diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx
new file mode 100644
index 000000000..7718a7840
--- /dev/null
+++ b/ui/src/components/entities-table/EntityDetailPanel.tsx
@@ -0,0 +1,247 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useEffect, useRef, useState } from 'react';
+import { Check, Copy } from 'lucide-react';
+import { DataText, FsmCapacityChart, SegmentedBar, thinScrollbarClass } from '@quent/components';
+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';
+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({
+ fsm,
+ resourceLabel,
+ operatorLabel,
+ stateColorFn,
+ queryBundle,
+}: EntityDetailPanelProps) {
+ 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 (
+
+ Select an entity to view its states.
+
+ );
+ }
+
+ 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;
+
+ // 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),
+ };
+ }
+
+ function copyId() {
+ void navigator.clipboard.writeText(fsmId);
+ setCopied(true);
+ if (copiedTimeoutRef.current != null) {
+ clearTimeout(copiedTimeoutRef.current);
+ }
+ copiedTimeoutRef.current = 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)}%
+
+
+ )}
+ {totalSpanMs > 0 && stateTimeMs.size > 0 && (
+
{
+ const color = stateColorFn ? stateColorFn(name) : getColorForKey(name, paletteTheme);
+ const pct = (ms / totalSpanMs) * 100;
+ return {
+ id: name,
+ value: pct,
+ color,
+ ariaLabel: `${name}: ${pct.toFixed(1)}%`,
+ tooltip: (
+
+ {name}
+ {pct.toFixed(1)}%
+
+ ),
+ };
+ })}
+ />
+ )}
+
+
+
{
+ 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);
+ }}
+ />
+
+
+ {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 (
+ -
+ {/* State name + duration (prominent) + absolute timestamp (secondary) */}
+
+
+ {index + 1}. {transition.name}
+
+
+ {durationMs != null && (
+
+ {formatDuration(durationMs)}
+
+ )}
+
+ @{formatDurationForWindow(transition.timestamp * 1000, totalSpanMs, 15)}
+
+
+
+
+ {/* Proportional duration bar */}
+ {pct != null && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
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..b7e91a4cc
--- /dev/null
+++ b/ui/src/components/entities-table/ResourceUsageList.tsx
@@ -0,0 +1,65 @@
+// 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';
+import { DataText } from '@quent/components';
+
+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..23b0c57e3
--- /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 { cn, 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) };
+}
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' });