Repository navigation
feat(ui): entities chart implementation, entities swimlanes 2.5 #497
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SegmentDatum[]>(() => { | ||
| 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 ( | ||
| <GanttChart | ||
| data={customSeriesData} | ||
| durationSeconds={durationSeconds} | ||
| height={height} | ||
| maxHeight={MAX_HEIGHT} | ||
| rowHeight={BAR_HEIGHT} | ||
| isDark={isDark} | ||
| seriesName={SERIES_NAME} | ||
| renderItem={renderItem} | ||
| emptyMessage="No long entities" | ||
| /> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[]; | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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> = {} | ||||||||||||||||||||||||||
| ): FsmTransition { | ||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| name, | ||||||||||||||||||||||||||
| timestamp, | ||||||||||||||||||||||||||
| usages: [], | ||||||||||||||||||||||||||
| attributes: [], | ||||||||||||||||||||||||||
| derived_attributes: [], | ||||||||||||||||||||||||||
| ...overrides, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function makeFsm( | ||||||||||||||||||||||||||
| id: string, | ||||||||||||||||||||||||||
| transitions: FsmTransition[], | ||||||||||||||||||||||||||
| overrides: Partial<FiniteStateMachine> = {} | ||||||||||||||||||||||||||
| ): 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); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Comment on lines
+104
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Use a canonical metadata fixture and cover both attribute fields. Line 107 casts a hand-written value through As per coding guidelines, fixtures must use canonical production or generated types. As per path instructions, build fixtures from canonical production or ts-binding types instead of brittle lookalike interfaces. 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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]); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Comment on lines
+115
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Test adjacent intervals without a gap. The current case leaves a one-second gap. Consecutive FSM state spans meet at the transition timestamp, so an entity ending at Proposed test change- const b = makeFsm('b', [transition('s', 2), transition('exit', 3)]);
+ const b = makeFsm('b', [transition('s', 1), transition('exit', 2)]);As per coding guidelines, tests must cover meaningful boundaries. As per path instructions, tests must cover meaningful boundary behavior. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: rapidsai/quent
Length of output: 12302
🏁 Script executed:
Repository: rapidsai/quent
Length of output: 9751
🏁 Script executed:
Repository: rapidsai/quent
Length of output: 345
Keep the entity label visible after horizontal navigation.
When the first segment is outside the zoom window, later visible segments do not render
entry.label. Render the label on the first visible segment or provide a persistent row or tooltip label. Add regression coverage.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm going to include this in part 3, other refactors happen there
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@johallar, acknowledged. Keep this finding for part 3 with the related refactors. The current PR can remain focused on the initial Gantt rendering work.🐇
You are interacting with an AI system.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also handle in part 3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@johallar, acknowledged. Handle the entity-label visibility case and regression coverage in part 3.🐇
You are interacting with an AI system.