Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
6 changes: 6 additions & 0 deletions ui/packages/@quent/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
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;
Comment on lines +84 to +85

@coderabbitai coderabbitai Bot Aug 4, 2026 •

Copy link
Copy Markdown

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:

#!/bin/bash
set -eu

printf '%s\n' '--- LongEntitiesGantt outline ---'
ast-grep outline ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx --match 'LongEntitiesGantt' --view expanded || true

printf '%s\n' '--- LongEntitiesGantt relevant lines ---'
sed -n '1,180p' ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx

printf '%s\n' '--- Gantt options relevant lines ---'
sed -n '1,220p' ui/packages/@quent/components/src/gantt-chart/options.ts

printf '%s\n' '--- related tests and call sites ---'
rg -n -S 'LongEntitiesGantt|segmentIndex|clipRectByRect|entity label|long entities' ui/packages/@quent/components ui/src

Repository: rapidsai/quent

Length of output: 12302


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- clipping utility ---'
sed -n '1,100p' ui/packages/@quent/components/src/gantt-chart/utils.ts

printf '%s\n' '--- GanttChart implementation ---'
ast-grep outline ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx --match 'GanttChart' --view expanded || true
sed -n '1,260p' ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx

printf '%s\n' '--- LongEntityEntry types and component tests ---'
sed -n '1,180p' ui/packages/@quent/components/src/long-entities/types.ts
fd -i 'LongEntitiesGantt' ui/packages/@quent/components ui/src || true
rg -n -S 'buildGanttOption|tooltip|dataZoom|LongEntityEntry' ui/packages/@quent/components/src/long-entities ui/packages/@quent/components/src/gantt-chart ui/src

Repository: rapidsai/quent

Length of output: 9751


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

gantt = Path("ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx").read_text()
options = Path("ui/packages/@quent/components/src/gantt-chart/options.ts").read_text()
chart = Path("ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx").read_text()
clip_source = Path("ui/packages/@quent/components/src/gantt-chart/utils.ts").read_text()

assert "datum!.segmentIndex === 0" in gantt
assert "if (!clippedShape) return null;" in gantt
assert "axisLabel: { show: false }" in options
assert "renderTooltip" in chart
assert "renderTooltip={renderTooltip}" not in gantt
assert "return undefined;" in clip_source

# Model two segments for one entity. The first is fully outside the viewport;
# the second is visible. The render path therefore reaches the label condition
# with segmentIndex 1, which produces no label child.
bounds = {"x": 100, "y": 0, "width": 200, "height": 100}
segments = [
    {"index": 0, "x": 0, "width": 50},
    {"index": 1, "x": 120, "width": 50},
]

def clip(rect, bounds):
    x = max(rect["x"], bounds["x"])
    x2 = min(rect["x"] + rect["width"], bounds["x"] + bounds["width"])
    y = max(rect.get("y", 0), bounds["y"])
    y2 = min(rect.get("y", 0) + rect.get("height", 10), bounds["y"] + bounds["height"])
    return None if x2 < x or y2 < y else {"x": x, "width": x2 - x}

visible = []
for segment in segments:
    clipped = clip(segment, bounds)
    if clipped is not None:
        visible.append((segment["index"], segment["index"] == 0))

assert visible == [(1, False)]
print("PASS: the first segment is discarded outside the viewport, and the visible later segment has no label.")
print("PASS: the chart hides y-axis labels and LongEntitiesGantt does not provide renderTooltip.")
PY

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/`@quent/components/src/long-entities/LongEntitiesGantt.tsx around
lines 84 - 85, Update the segment-rendering logic around clippedShape in
LongEntitiesGantt so the entity label is rendered on the first segment that
remains visible after horizontal clipping, rather than only on the original
first segment. Preserve label visibility when navigating horizontally, and add
regression coverage for an offscreen first segment followed by a visible
segment.

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown

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.

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown

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.


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"
/>
);
}
35 changes: 35 additions & 0 deletions ui/packages/@quent/components/src/long-entities/types.ts
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[];
};
128 changes: 128 additions & 0 deletions ui/packages/@quent/components/src/long-entities/utils.test.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 unknown to never. This disables the FsmTransition attribute contract. The test also omits derived_attributes, so a regression that drops derivedAttributes still passes. Build a typed DynamicAttribute fixture or use a production builder, then assert both output fields.

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 Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/`@quent/components/src/long-entities/utils.test.ts around lines
104 - 113, Update the “carries transition attributes onto segments” test to
construct its transition metadata with the canonical production or ts-binding
DynamicAttribute fixture/builder, removing the unknown-to-never cast. Include
both regular and derived attributes in the fixture, then assert the segment
preserves both attributes and derivedAttributes outputs.

Sources: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 1 and another starting at 1 must reuse the same row. Test that boundary to catch a < versus <= error in row packing.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 non-overlapping entities onto the same row', () => {
const a = makeFsm('a', [transition('s', 0), transition('exit', 1)]);
const b = makeFsm('b', [transition('s', 1), transition('exit', 2)]);
const entries = buildLongEntityEntries([a, b], {}, 'light');
expect(entries.map(e => e.rowIndex).sort()).toEqual([0, 0]);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/`@quent/components/src/long-entities/utils.test.ts around lines
115 - 120, Update the “stacks non-overlapping entities onto the same row” test
to use adjacent intervals whose end and start timestamps are equal, such as
ending the first FSM at 1 and starting the second at 1. Keep the expected
rowIndex values as [0, 0] to verify boundary-touching entities reuse the same
row.

Sources: 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);
});
});
Loading