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/fsm-chart/FsmCapacityChart.test.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx new file mode 100644 index 000000000..f85e6f4d4 --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.test.tsx @@ -0,0 +1,391 @@ +// 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 userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { FsmTransition, QuantitySpec } 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: 'Iec', + rate_prefix: 'Si', +} satisfies QuantitySpec; + +const UNIT_SPEC = { + symbol: '', + singular: 'unit', + plural: 'units', + occupancy_prefix: 'None', + rate_prefix: 'None', +} satisfies QuantitySpec; + +const RATE_SPEC = { + symbol: 'B/s', + singular: 'byte per second', + plural: 'bytes per second', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', +} satisfies QuantitySpec; + +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 })]), +]; + +/** 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 +// --------------------------------------------------------------------------- + +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', async () => { + 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 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'); + }); + }); + + 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', 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 })]), + ]; + + render(); + + 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', async () => { + 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 + 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 + await selectOption('Select resource', 'Memory'); + 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.font-mono' })).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 + expect(screen.getByRole('combobox', { name: 'Select resource' })).toHaveTextContent('Memory'); + }); + }); + + describe('entity 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' } + : 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 + 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 = [ + 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(); + }); + }); +}); 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..35ca01055 --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import EChartsReactCore from 'echarts-for-react/lib/core'; +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'; +import { PositionedTooltip } from '../ui/positioned-tooltip'; +import { SelectField } from '../ui/select-field'; +import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; +import { FsmCapacityTooltip } from './FsmCapacityTooltip'; + +const CHART_HEIGHT = 90; +const GRID = { left: 52, right: 8, top: 8, bottom: 36 }; + +interface CapacityEntry { + name: string; + statLabel: string; + data: Array; + rawData: Array; + formatter: (v: number | bigint) => string; +} + +interface ResourceSeries { + resourceId: string; + label: string; + capacities: CapacityEntry[]; +} + +interface AxisPointerEvent { + axesInfo?: Array<{ axisDim?: string; value?: number }>; +} + +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; +} + +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, + 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 { resources, stateLabels } = useMemo(() => { + const n = transitions.length; + const stateLabels = transitions.map((t, i) => `${i + 1}. ${t.name}`); + + // 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 => { + 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) return; + if (!entry.caps.has(name)) { + entry.caps.set(name, { + data: Array(n).fill(null), + rawData: Array(n).fill(null), + }); + } + const capEntry = entry.caps.get(name)!; + capEntry.data[i] = bigintToChartNumber(cap); + capEntry.rawData[i] = cap; + }); + }); + }); + + // 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; + + // 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( + () => ({ + 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 && 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( + () => ({ + animation: false, + grid: GRID, + xAxis: { + type: 'category' as const, + data: stateLabels, + boundaryGap: false, + axisLabel: { + show: true, + fontSize: 9, + interval: 0, + formatter: (_val: string, idx: number) => String(idx + 1), + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + splitNumber: 3, + axisLabel: { + show: true, + fontSize: 9, + formatter: (v: number) => + activeCapacity ? activeCapacity.formatter(v) : formatBytes(v, 0), + }, + splitLine: { show: true, lineStyle: { opacity: 0.25 } }, + minInterval: 1, + }, + tooltip: { + trigger: 'axis' as const, + showContent: false, + axisPointer: { type: 'line' as const, snap: true }, + }, + 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 }, + }, + ] + : [], + }), + [activeCapacity, activeResource, stateLabels] + ); + + if (resources.length === 0) return null; + + return ( +
+
+ + {activeCapacity?.statLabel} + +
+ {resources.length > 1 && ( + ({ value: r.resourceId, label: r.label }))} + value={activeResource?.resourceId ?? ''} + onValueChange={value => { + if (!value) return; + setSelectedResourceId(value); + setSelectedCapacityName(null); + }} + clearable={false} + triggerClassName={SELECT_TRIGGER_CLASS} + /> + )} + {activeResource && activeResource.capacities.length > 1 && ( + ({ value: c.name, label: c.name }))} + value={activeCapacity?.name ?? ''} + onValueChange={value => value && setSelectedCapacityName(value)} + clearable={false} + triggerClassName={SELECT_TRIGGER_CLASS} + /> + )} +
+
+
setPointer({ clientX: event.clientX, clientY: event.clientY })} + onPointerLeave={clearHover} + onPointerCancel={clearHover} + > + + {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/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 172d82178..70effd321 100644 --- a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx +++ b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx @@ -49,6 +49,8 @@ export interface GanttChartProps { contentPaddingBottom?: number; animateHeight?: boolean; renderTooltip?: (hover: GanttHover | null) => ReactNode; + /** Called when the user clicks the chart background (not a series item). */ + onBackgroundClick?: () => void; } export function GanttChart({ @@ -67,6 +69,7 @@ export function GanttChart({ contentPaddingBottom = 0, animateHeight = false, renderTooltip, + onBackgroundClick, }: GanttChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const [hover, setHover] = useState(null); @@ -119,15 +122,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 02a479cca..c79078d3d 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 { @@ -46,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 { @@ -184,6 +197,10 @@ export { DagPlayhead } from './dag/DagPlayhead'; export { QueryPlanNode } from './query-plan/QueryPlanNode'; 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'; @@ -241,6 +258,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 651dd26fe..cca80431a 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -46,6 +46,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({ @@ -54,6 +59,9 @@ export function LongEntitiesGantt({ minUsageSeconds, height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, + onEntityClick, + selectedEntityId, + onBackgroundClick, }: LongEntitiesGanttProps) { const { textColor } = useTimelineEchartsTheme(isDark); const zoomRange = useZoomRange(); @@ -137,6 +145,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; @@ -156,6 +168,7 @@ export function LongEntitiesGantt({ fill: withOpacity(color, MARK_AREA_FILL_OPACITY), stroke: withOpacity(color, MARK_AREA_BORDER_OPACITY), lineWidth: 1, + opacity, }, }; @@ -174,6 +187,7 @@ export function LongEntitiesGantt({ fill: textColor, overflow: 'truncate' as const, width: Math.max(0, clippedShape.width - 6), + opacity, }, }, ] @@ -181,9 +195,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 (
} renderTooltip={renderTooltip} + cursor={onEntityClick ? 'pointer' : undefined} + onEvents={onEvents} + onBackgroundClick={onBackgroundClick} /> {canResize && ( + 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 ( +
  1. + {/* 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 && ( +
    +
    +
    + )} + + + +
  2. + ); + })} +
+
+ ); +} 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' });