diff --git a/ui/packages/@quent/components/src/gantt-chart/options.test.ts b/ui/packages/@quent/components/src/gantt-chart/options.test.ts index b81ff4a95..0dd0d3cf7 100644 --- a/ui/packages/@quent/components/src/gantt-chart/options.test.ts +++ b/ui/packages/@quent/components/src/gantt-chart/options.test.ts @@ -38,7 +38,6 @@ describe('buildGanttOption', () => { }); expect((option.dataZoom as object[])[0]).toMatchObject({ type: 'slider', minSpan: 2 }); }); - it('uses the timeline spacing defaults when grid spacing is omitted', () => { const option = buildGanttOption({ data: [], diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index f3a2ebf5e..9fc0303af 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -239,6 +239,12 @@ export { } from './pivot-table/utils'; export type { GroupIndexDef, RowWithGroupKeys } from './pivot-table/utils'; +// ─── Long-entities components ───────────────────────────────────────────────── +export { LongEntitiesGantt } from './long-entities/LongEntitiesGantt'; +export type { LongEntitiesGanttProps } from './long-entities/LongEntitiesGantt'; +export type { LongEntityEntry, LongEntitySegment } from './long-entities/types'; +export { buildLongEntityEntries } from './long-entities/utils'; + // ─── Operator-timeline components ──────────────────────────────────────────── export { OperatorGanttChart } from './operator-timeline/OperatorGanttChart'; export type { OperatorGanttChartProps } from './operator-timeline/OperatorGanttChart'; diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx new file mode 100644 index 000000000..080fcad34 --- /dev/null +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useMemo } from 'react'; + +import { + MARK_AREA_BORDER_OPACITY, + MARK_AREA_FILL_OPACITY, + MARK_LABEL_TEXT_COLOR, +} from '../timeline/timelineEchartsTheme'; +import { withOpacity } from '@quent/utils'; +import type { LongEntityEntry } from './types'; +import { GanttChart, type GanttRenderItem } from '../gantt-chart/GanttChart'; +import { clipRectByRect } from '../gantt-chart/utils'; + +const DEFAULT_HEIGHT = 120; +const MAX_HEIGHT = 400; +const BAR_FONT_SIZE = 9; +const BAR_HEIGHT = 16; +const BAR_GAP = 2; +const SERIES_NAME = 'long-entity-segment'; + +/** Flat segment datum: one ECharts custom-series item per state span. */ +type SegmentDatum = { + value: [number, number, number]; + entryIndex: number; + segmentIndex: number; +}; + +export interface LongEntitiesGanttProps { + entries: LongEntityEntry[]; + durationSeconds: number; + height?: number; + /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ + isDark: boolean; +} + +export function LongEntitiesGantt({ + entries, + durationSeconds, + height = DEFAULT_HEIGHT, + isDark, +}: LongEntitiesGanttProps) { + // One custom-series datum per segment, tagged with its parent entry/segment. + const customSeriesData = useMemo(() => { + const data: SegmentDatum[] = []; + entries.forEach((entry, entryIndex) => { + entry.segments.forEach((seg, segmentIndex) => { + data.push({ + value: [seg.startMs, seg.endMs, entry.rowIndex], + entryIndex, + segmentIndex, + }); + }); + }); + return data; + }, [entries]); + + const renderItem: GanttRenderItem = useCallback( + (params, api) => { + const startMs = api.value(0) as number; + const endMs = api.value(1) as number; + const rowIndex = api.value(2) as number; + if (endMs <= startMs) return null; + + const datum = customSeriesData[params.dataIndex]; + const entry = datum ? entries[datum.entryIndex] : undefined; + const segment = entry?.segments[datum!.segmentIndex]; + if (!entry || !segment) return null; + + const startPoint = api.coord([startMs, rowIndex]); + const endPoint = api.coord([endMs, rowIndex]); + + const barHeight = Math.max(1, BAR_HEIGHT - BAR_GAP); + const y = startPoint[1] - barHeight / 2; + const width = Math.max(1, endPoint[0] - startPoint[0]); + + const coord = params.coordSys as { x?: number; y?: number; width?: number; height?: number }; + const clipBound = + typeof coord.width === 'number' && typeof coord.height === 'number' + ? { x: coord.x ?? 0, y: coord.y ?? 0, width: coord.width, height: coord.height } + : null; + const rectShape = { x: startPoint[0], y, width, height: barHeight }; + const clippedShape = clipBound ? clipRectByRect(rectShape, clipBound) : rectShape; + if (!clippedShape) return null; + + const color = segment.color; + const rect = { + type: 'rect' as const, + shape: { ...clippedShape, r: 1 }, + // Mirror timeline marks: faint fill, stronger border, same state color. + style: { + fill: withOpacity(color, MARK_AREA_FILL_OPACITY), + stroke: withOpacity(color, MARK_AREA_BORDER_OPACITY), + lineWidth: 1, + }, + }; + + // Entity label chip on the first segment only (white text on state color). + const textX = clippedShape.x + 4; + const textY = clippedShape.y + clippedShape.height / 2; + const labelChildren = + datum!.segmentIndex === 0 + ? [ + { + type: 'text' as const, + style: { + text: entry.label, + x: textX, + y: textY, + textVerticalAlign: 'middle' as const, + fontSize: BAR_FONT_SIZE, + fontWeight: 500, + fill: MARK_LABEL_TEXT_COLOR, + backgroundColor: withOpacity(color, 0.85), + borderRadius: 1, + padding: [1, 2] as [number, number], + overflow: 'truncate' as const, + width: Math.max(0, clippedShape.width - 8), + }, + }, + ] + : []; + + return { type: 'group' as const, children: [rect, ...labelChildren] }; + }, + [entries, customSeriesData] + ); + + return ( + + ); +} diff --git a/ui/packages/@quent/components/src/long-entities/types.ts b/ui/packages/@quent/components/src/long-entities/types.ts new file mode 100644 index 000000000..c5ffc9863 --- /dev/null +++ b/ui/packages/@quent/components/src/long-entities/types.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { DynamicAttribute } from '@quent/utils'; + +/** + * One state span within an entity, styled like a timeline mark. + * Time is milliseconds elapsed from query start. + */ +export type LongEntitySegment = { + stateName: string; + startMs: number; + endMs: number; + /** State color from the FSM palette. */ + color: string; + attributes?: DynamicAttribute[]; + derivedAttributes?: DynamicAttribute[]; +}; + +/** + * One entity (FSM) as a Gantt bar, subdivided into state-colored segments. + * The bar spans from its first to its last transition. + */ +export type LongEntityEntry = { + entityId: string; + /** Display name (instance name or id). */ + label: string; + /** FSM type name. */ + typeName: string; + startMs: number; + endMs: number; + /** Row index for the categorical y-axis (0-based), assigned by stacking. */ + rowIndex: number; + segments: LongEntitySegment[]; +}; diff --git a/ui/packages/@quent/components/src/long-entities/utils.test.ts b/ui/packages/@quent/components/src/long-entities/utils.test.ts new file mode 100644 index 000000000..63993b43c --- /dev/null +++ b/ui/packages/@quent/components/src/long-entities/utils.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import type { FiniteStateMachine, FsmTransition } from '@quent/utils'; +import { buildLongEntityEntries } from './utils'; + +function transition( + name: string, + timestamp: number, + overrides: Partial = {} +): FsmTransition { + return { + name, + timestamp, + usages: [], + attributes: [], + derived_attributes: [], + ...overrides, + }; +} + +function makeFsm( + id: string, + transitions: FsmTransition[], + overrides: Partial = {} +): FiniteStateMachine { + return { + id, + type_name: 'task', + instance_name: '', + transitions, + ...overrides, + }; +} + +describe('buildLongEntityEntries', () => { + it('returns [] for no items', () => { + expect(buildLongEntityEntries([], {}, 'light')).toEqual([]); + }); + + it('builds one segment per consecutive transition pair', () => { + const fsm = makeFsm('e1', [ + transition('queueing', 0), + transition('computing', 1), + transition('exit', 3), + ]); + const [entry] = buildLongEntityEntries([fsm], {}, 'light'); + expect(entry.segments).toHaveLength(2); + expect(entry.segments.map(s => s.stateName)).toEqual(['queueing', 'computing']); + // seconds → elapsed milliseconds + expect(entry.segments[0]).toMatchObject({ startMs: 0, endMs: 1000 }); + expect(entry.segments[1]).toMatchObject({ startMs: 1000, endMs: 3000 }); + }); + + it('spans the bar from first to last transition', () => { + const fsm = makeFsm('e1', [ + transition('a', 0.5), + transition('b', 1.5), + transition('exit', 2.5), + ]); + const [entry] = buildLongEntityEntries([fsm], {}, 'light'); + expect(entry.startMs).toBe(500); + expect(entry.endMs).toBe(2500); + }); + + it('drops single-transition FSMs (no state span)', () => { + const fsm = makeFsm('e1', [transition('a', 0)]); + expect(buildLongEntityEntries([fsm], {}, 'light')).toEqual([]); + }); + + it('drops zero-duration segments', () => { + const fsm = makeFsm('e1', [ + transition('a', 1), + transition('b', 1), // zero duration → dropped + transition('exit', 2), + ]); + const [entry] = buildLongEntityEntries([fsm], {}, 'light'); + expect(entry.segments).toHaveLength(1); + expect(entry.segments[0].stateName).toBe('b'); + }); + + it('drops entities whose only segments are zero-duration', () => { + const fsm = makeFsm('e1', [transition('a', 1), transition('exit', 1)]); + expect(buildLongEntityEntries([fsm], {}, 'light')).toEqual([]); + }); + + it('uses instance_name for the label, falling back to id', () => { + const named = makeFsm('e1', [transition('a', 0), transition('exit', 1)], { + instance_name: 'task-7', + }); + const anon = makeFsm('e2', [transition('a', 0), transition('exit', 1)]); + const [n, a] = buildLongEntityEntries([named, anon], {}, 'light'); + expect(n.label).toBe('task-7'); + expect(a.label).toBe('e2'); + }); + + it('assigns a color to each segment', () => { + const fsm = makeFsm('e1', [transition('a', 0), transition('exit', 1)]); + const [entry] = buildLongEntityEntries([fsm], {}, 'light'); + expect(entry.segments[0].color).toMatch(/^#/); + }); + + it('carries transition attributes onto segments', () => { + const fsm = makeFsm('e1', [ + transition('a', 0, { + attributes: [{ key: 'bytes', value: { Int: 42 } } as unknown as never], + }), + transition('exit', 1), + ]); + const [entry] = buildLongEntityEntries([fsm], {}, 'light'); + expect(entry.segments[0].attributes).toHaveLength(1); + }); + + it('stacks non-overlapping entities onto the same row', () => { + const a = makeFsm('a', [transition('s', 0), transition('exit', 1)]); + const b = makeFsm('b', [transition('s', 2), transition('exit', 3)]); + const entries = buildLongEntityEntries([a, b], {}, 'light'); + expect(entries.map(e => e.rowIndex).sort()).toEqual([0, 0]); + }); + + it('stacks overlapping entities onto different rows', () => { + const a = makeFsm('a', [transition('s', 0), transition('exit', 10)]); + const b = makeFsm('b', [transition('s', 2), transition('exit', 8)]); + const entries = buildLongEntityEntries([a, b], {}, 'light'); + expect(new Set(entries.map(e => e.rowIndex)).size).toBe(2); + }); +}); diff --git a/ui/packages/@quent/components/src/long-entities/utils.ts b/ui/packages/@quent/components/src/long-entities/utils.ts new file mode 100644 index 000000000..9a1155516 --- /dev/null +++ b/ui/packages/@quent/components/src/long-entities/utils.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FiniteStateMachine, FsmTypeDecl, PaletteTheme } from '@quent/utils'; +import { createFsmTypeColorFn } from '@quent/utils'; +import { stackIntervalsIntoRows } from '../gantt-chart/utils'; +import type { LongEntityEntry, LongEntitySegment } from './types'; + +/** Row type identifier for synthetic long-entities rows in the resource tree. */ +export const LONG_ENTITIES_ROW_TYPE = 'long-entities'; +const LONG_ENTITIES_ROW_ID_PREFIX = '__long_entities__'; + +/** Id used for the synthetic long-entities row under a resource. */ +export function longEntitiesRowId(resourceId: string): string { + return `${LONG_ENTITIES_ROW_ID_PREFIX}${resourceId}`; +} + +/** Extract the resource id from a long-entities row id, or null if it is not one. */ +export function resourceIdFromLongEntitiesRowId(id: string): string | null { + return id.startsWith(LONG_ENTITIES_ROW_ID_PREFIX) + ? id.slice(LONG_ENTITIES_ROW_ID_PREFIX.length) + : null; +} + +/** + * Convert an FSM's consecutive transition pairs into state-colored segments. + * Each pair defines the time range of the state entered by the first + * transition (identical semantics to timeline marks). Zero-duration spans are + * dropped. + */ +function buildSegments( + fsm: FiniteStateMachine, + colorFsm: (stateName: string) => string +): LongEntitySegment[] { + return fsm.transitions + .slice(0, -1) + .map((transition, i): LongEntitySegment | null => { + const next = fsm.transitions[i + 1]; + if (!next) return null; + const startMs = transition.timestamp * 1000; + const endMs = next.timestamp * 1000; + if (endMs <= startMs) return null; + return { + stateName: transition.name, + startMs, + endMs, + color: colorFsm(transition.name), + // Tolerate responses from servers predating attributes. + ...((transition.attributes?.length ?? 0) > 0 && { attributes: transition.attributes }), + ...((transition.derived_attributes?.length ?? 0) > 0 && { + derivedAttributes: transition.derived_attributes, + }), + }; + }) + .filter((s): s is LongEntitySegment => s != null); +} + +/** + * Convert entity-list FSMs into compactly stacked Gantt entries. + * + * Each entity becomes one bar spanning its first→last transition, subdivided + * into state-colored segments. Non-overlapping entities share a row via the + * greedy first-fit packing shared with the operator Gantt. + */ +export function buildLongEntityEntries( + items: FiniteStateMachine[], + fsmTypes: { [key in string]?: FsmTypeDecl } | undefined, + theme: PaletteTheme +): LongEntityEntry[] { + const colorFsm = createFsmTypeColorFn(fsmTypes ?? {}, theme); + + const entries: LongEntityEntry[] = []; + for (const fsm of items) { + const segments = buildSegments(fsm, colorFsm); + if (segments.length === 0) continue; + const startMs = segments[0]!.startMs; + const endMs = segments[segments.length - 1]!.endMs; + entries.push({ + entityId: fsm.id, + label: fsm.instance_name || fsm.id, + typeName: fsm.type_name, + startMs, + endMs, + rowIndex: 0, + segments, + }); + } + + return stackIntervalsIntoRows(entries); +}