Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8bec1f9
feat(ui): per-resource long-entities Gantt; drop timeline entity marks
johallar Jul 23, 2026
349013a
refactor: always include entities row under leaves, move label to top…
johallar Jul 23, 2026
684e968
feature: operator gantt chart tooltip, sync hover line
johallar Jul 28, 2026
950e896
refactor: update entities gantt chart for echarts 6 paradigm (seconds…
johallar Jul 28, 2026
36bdc69
refactor: shrink long entities max height
johallar Jul 28, 2026
511c85c
refactor: user facing messaging -> entities
johallar Jul 30, 2026
0620be1
test(components): align entity hover tests with elapsed time
johallar Jul 30, 2026
b25023c
chore(ui): canonicalize entity UI license headers
johallar Jul 30, 2026
8efe45c
Constant height swimlane rows, keep previous data
johallar Aug 3, 2026
ada35dc
Move labels to inside boxes, tweak long entity threshold
johallar Aug 3, 2026
45c30f8
Label entity/operator rows
johallar Aug 3, 2026
082284d
Simple pagination for entities gantt charts
johallar Aug 3, 2026
bc5a38c
Skeleton loader instead of text
johallar Aug 3, 2026
4e98851
fix: add xs button variant, linting/test fixes
johallar Aug 5, 2026
b5652f9
refactor: move timeline settings to new component, but remove from to…
johallar Aug 5, 2026
83593c1
refactor: consolidate colordot, colorcircle; only show entities that …
johallar Aug 5, 2026
147e20d
refactor(ui): preserve API ordering before inserting new entities, le…
johallar Aug 5, 2026
d461ef6
chore: rename window, only show 'resource' specific entities, do not …
johallar Aug 10, 2026
6b4ca81
chore: update tests w new 'resource' default
johallar Aug 10, 2026
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
52 changes: 52 additions & 0 deletions ui/packages/@quent/client/src/entityList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from 'vitest';
import { keepPreviousData } from '@tanstack/react-query';
import type { EntityListResponse } from '@quent/utils';
import { entityListInfiniteQueryOptions, entityListQueryOptions } from './entityList';

describe('entityListQueryOptions', () => {
it('copies selected operator IDs into the entity-list request', () => {
const options = entityListQueryOptions({
engineId: 'engine-1',
queryId: 'query-1',
window: { start: 0, end: 1 },
operatorIds: ['operator-1'],
minUsageSeconds: 0.15,
maxItems: 20,
page: 2,
});

expect(options.queryKey).toEqual([
'entityList',
'engine-1',
expect.objectContaining({
entry: expect.objectContaining({
application: { operator_ids: ['operator-1'] },
filter: expect.objectContaining({ min_usage_s: 0.15 }),
page: { page: 2, max: 20 },
}),
}),
]);
expect(options.placeholderData).toBe(keepPreviousData);
});

it('continues paging until all matching entities are loaded', () => {
const options = entityListInfiniteQueryOptions({
engineId: 'engine-1',
queryId: 'query-1',
window: { start: 0, end: 1 },
maxItems: 1,
});
const item = {} as EntityListResponse['items'][number];
const firstPage: EntityListResponse = { items: [item], total: 3 };
const secondPage: EntityListResponse = { items: [item], total: 3 };

expect(options.placeholderData).toBe(keepPreviousData);
expect(options.getNextPageParam?.(secondPage, [firstPage, secondPage], 1, [0, 1])).toBe(2);
expect(
options.getNextPageParam?.({ items: [item], total: 2 }, [firstPage, secondPage], 1, [0, 1])
).toBeUndefined();
});
});
49 changes: 43 additions & 6 deletions ui/packages/@quent/client/src/entityList.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { queryOptions, useQuery } from '@tanstack/react-query';
import {
infiniteQueryOptions,
keepPreviousData,
queryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import type {
EntityListRequest,
EntityScope,
Expand All @@ -18,8 +24,8 @@ interface EntityListParams {
queryId: string;
/** Window bounds in seconds relative to the query epoch. */
window: { start: number; end: number };
/** Restrict to a single operator; `null` returns entities across all. */
operatorId?: string | null;
/** Restrict entities to the selected operators; empty returns entities across all. */
operatorIds?: string[];
/** Restrict entities to a resource / resource-group scope; `null` for all. */
filter?: { scope?: EntityScope | null; entityTypeName?: string | null };
/** Keep only entities whose longest usage span exceeds this (seconds). */
Expand All @@ -28,17 +34,20 @@ interface EntityListParams {
sortDir?: SortDir;
/** Max entities to return; omit for the full (unpaged) list. */
maxItems?: number | null;
/** Zero-based page index; only used when `maxItems` is set. */
page?: number;
}

function buildRequest({
queryId,
window,
operatorId = null,
operatorIds = [],
filter,
minUsageSeconds = null,
sortKey = 'UsageDuration',
sortDir = 'Desc',
maxItems = null,
page = 0,
}: EntityListParams): EntityListRequest<QueryFilter, OperatorFilter> {
return {
entry: {
Expand All @@ -49,8 +58,8 @@ function buildRequest({
min_usage_s: minUsageSeconds,
},
sort: { key: sortKey, dir: sortDir },
page: maxItems != null ? { page: 0, max: maxItems } : null,
application: { operator_ids: operatorId == null ? [] : [operatorId] },
page: maxItems != null ? { page, max: maxItems } : null,
application: { operator_ids: operatorIds },
},
app_params: { query_id: queryId },
};
Expand All @@ -66,9 +75,37 @@ export const entityListQueryOptions = (
queryFn: () => fetchEntityList(params.engineId, request),
staleTime: options?.staleTime ?? DEFAULT_STALE_TIME,
enabled: options?.enabled ?? true,
placeholderData: keepPreviousData,
});
};
export const useEntityList = (
params: EntityListParams,
options?: { staleTime?: number; enabled?: boolean }
) => useQuery(entityListQueryOptions(params, options));

type PaginatedEntityListParams = EntityListParams & { maxItems: number };

export const entityListInfiniteQueryOptions = (
params: PaginatedEntityListParams,
options?: { staleTime?: number; enabled?: boolean }
) => {
const initialRequest = buildRequest({ ...params, page: 0 });
return infiniteQueryOptions({
queryKey: ['entityList', 'infinite', params.engineId, initialRequest],
queryFn: ({ pageParam }) =>
fetchEntityList(params.engineId, buildRequest({ ...params, page: pageParam })),
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => {
const loadedCount = pages.reduce((count, page) => count + page.items.length, 0);
return lastPage.items.length > 0 && loadedCount < lastPage.total ? pages.length : undefined;
},
staleTime: options?.staleTime ?? DEFAULT_STALE_TIME,
enabled: options?.enabled ?? true,
placeholderData: keepPreviousData,
});
};

export const useInfiniteEntityList = (
params: PaginatedEntityListParams,
options?: { staleTime?: number; enabled?: boolean }
) => useInfiniteQuery(entityListInfiniteQueryOptions(params, options));
4 changes: 2 additions & 2 deletions ui/packages/@quent/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export { queriesQueryOptions } from './queries';
export { singleTimelineQueryOptions } from './timeline';
export { bulkTimelineQueryOptions } from './bulkTimelines';
export { dataFlowQueryOptions } from './dataFlow';
export { entityListQueryOptions } from './entityList';
export { entityListInfiniteQueryOptions, entityListQueryOptions } from './entityList';

// Hooks
export { useQueryBundle } from './queryBundle';
Expand All @@ -34,4 +34,4 @@ export { useQueryGroups } from './queryGroups';
export { useQueries } from './queries';
export { useTimeline } from './timeline';
export { useDataFlow } from './dataFlow';
export { useEntityList } from './entityList';
export { useEntityList, useInfiniteEntityList } from './entityList';
7 changes: 0 additions & 7 deletions ui/packages/@quent/components/src/dag/ColorDot.tsx

This file was deleted.

6 changes: 3 additions & 3 deletions ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
formatDuration,
type PaletteTheme,
} from '@quent/utils';
import { ColorSwatch } from '../ui/color-swatch';
import { DataText } from '../ui/data-text';
import { ColorDot } from './ColorDot';

/**
* State × dimension matrix of the data-flow distribution for the selected
Expand Down Expand Up @@ -84,7 +84,7 @@ export const DataFlowMatrix = ({
className="text-right font-normal text-muted-foreground px-1.5"
>
<span className="inline-flex items-center gap-1">
<ColorDot color={dimensionColor(k.key)} />
<ColorSwatch color={dimensionColor(k.key)} shape="square" />
<DataText>{k.display_name}</DataText>
</span>
</th>
Expand All @@ -99,7 +99,7 @@ export const DataFlowMatrix = ({
<tr key={state}>
<th scope="row" className="pr-2 text-left font-normal">
<span className="inline-flex items-center gap-1">
<ColorDot color={stateColor(state)} />
<ColorSwatch color={stateColor(state)} shape="square" />
<DataText>{state}</DataText>
</span>
</th>
Expand Down
39 changes: 29 additions & 10 deletions ui/packages/@quent/components/src/gantt-chart/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,42 @@ describe('stackIntervalsIntoRows', () => {

it('packs adjacent and non-overlapping intervals into one row', () => {
const entries = [span(0, 10), span(10, 20), span(40, 50)];
stackIntervalsIntoRows(entries);
expect(entries.map(entry => entry.rowIndex)).toEqual([0, 0, 0]);
const stacked = stackIntervalsIntoRows(entries);
expect(stacked.map(entry => entry.rowIndex)).toEqual([0, 0, 0]);
});

it('packs overlapping intervals into the minimum rows', () => {
it('reuses the first compatible row', () => {
const a = span(0, 10);
const b = span(5, 15);
const c = span(12, 20);
stackIntervalsIntoRows([a, b, c]);
expect([a.rowIndex, b.rowIndex, c.rowIndex]).toEqual([0, 1, 0]);
const stacked = stackIntervalsIntoRows([a, b, c]);
expect(stacked.map(entry => entry.rowIndex)).toEqual([0, 1, 0]);
});

it('handles unsorted input and mutates the original entries', () => {
const later = span(10, 20);
const earlier = span(0, 5);
const entries = [later, earlier];
expect(stackIntervalsIntoRows(entries)).toBe(entries);
it('uses input order as the packing priority', () => {
const rankedFirst = span(5, 10);
const rankedSecond = span(0, 6);
const entries = [rankedFirst, rankedSecond];
const stacked = stackIntervalsIntoRows(entries);
expect(stacked.map(entry => entry.rowIndex)).toEqual([0, 1]);
});

it('does not mutate the input array or its entries', () => {
const entries = [span(0, 10), span(5, 15)];
const stacked = stackIntervalsIntoRows(entries);

expect(stacked).not.toBe(entries);
expect(stacked[0]).not.toBe(entries[0]);
expect(entries.map(entry => entry.rowIndex)).toEqual([0, 0]);
expect(stacked.map(entry => entry.rowIndex)).toEqual([0, 1]);
});

it('does not move existing entries when new entries are appended', () => {
const existing = stackIntervalsIntoRows([span(5, 10), span(0, 6), span(10, 20)]);
const previousRows = existing.map(entry => entry.rowIndex);

stackIntervalsIntoRows([...existing, span(4, 12)]);

expect(existing.map(entry => entry.rowIndex)).toEqual(previousRows);
});
});
52 changes: 36 additions & 16 deletions ui/packages/@quent/components/src/gantt-chart/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,47 @@ export function clipRectByRect(target: GanttRect, bounds: GanttRect): GanttRect
return undefined;
}

/** Greedily pack intervals into non-overlapping rows. */
export function stackIntervalsIntoRows<
T extends { startMs: number; endMs: number; rowIndex: number },
>(entries: T[]): T[] {
if (entries.length === 0) return entries;
type PackedInterval = { startMs: number; endMs: number };

const sorted = [...entries].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const rowEndMs: number[] = [];
function findInsertionIndex(intervals: PackedInterval[], startMs: number): number {
let low = 0;
let high = intervals.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (intervals[mid]!.startMs < startMs) low = mid + 1;
else high = mid;
}
return low;
}

for (const entry of sorted) {
/** Greedily pack intervals in input order so appended entries do not move existing rows. */
export function stackIntervalsIntoRows<
T extends { startMs: number; endMs: number; rowIndex: number },
>(entries: readonly T[]): T[] {
const rows: PackedInterval[][] = [];
const stackedEntries: T[] = [];
for (const entry of entries) {
let row = 0;
while (row < rowEndMs.length && entry.startMs < rowEndMs[row]) {
let insertionIndex = 0;
while (row < rows.length) {
const intervals = rows[row]!;
insertionIndex = findInsertionIndex(intervals, entry.startMs);
const previous = intervals[insertionIndex - 1];
const next = intervals[insertionIndex];
if (
(previous == null || previous.endMs <= entry.startMs) &&
(next == null || entry.endMs <= next.startMs)
) {
break;
}
row++;
}
if (row === rowEndMs.length) {
rowEndMs.push(entry.endMs);
} else {
rowEndMs[row] = Math.max(rowEndMs[row], entry.endMs);
}
entry.rowIndex = row;

if (row === rows.length) rows.push([]);
const stackedEntry = { ...entry, rowIndex: row };
rows[row]!.splice(insertionIndex, 0, stackedEntry);
stackedEntries.push(stackedEntry);
}

return entries;
return stackedEntries;
}
15 changes: 13 additions & 2 deletions ui/packages/@quent/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export {
NavigationMenuViewport,
} from './ui/navigation-menu';
export { Popover, PopoverTrigger, PopoverContent } from './ui/popover';
export { PointerTooltipPortal } from './ui/pointer-tooltip-portal';
export type { PointerPosition } from './ui/pointer-tooltip-portal';
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './ui/resizable';
export { ScrollArea, ScrollBar } from './ui/scroll-area';
export {
Expand Down Expand Up @@ -146,6 +148,7 @@ export type { DAGData, QueryPlanDataItem, QueryPlanNodeData } from './services/q
// ─── Timeline components ──────────────────────────────────────────────────────
export { TimelineController } from './timeline/TimelineController';
export { TimelineRuler } from './timeline/TimelineRuler';
export { TimelineSettingsPopover } from './timeline/TimelineSettingsPopover';
export { TimelineSkeleton } from './timeline/TimelineSkeleton';
export { TimelineToolbar } from './timeline/TimelineToolbar';
export { QueryToolbar } from './timeline/QueryToolbar';
Expand Down Expand Up @@ -240,10 +243,18 @@ export {
export type { GroupIndexDef, RowWithGroupKeys } from './pivot-table/utils';

// ─── Long-entities components ─────────────────────────────────────────────────
export { LongEntitiesGantt } from './long-entities/LongEntitiesGantt';
export {
LongEntitiesGantt,
LONG_ENTITIES_TIMELINE_HEIGHT,
} 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';
export {
buildLongEntityEntries,
LONG_ENTITIES_ROW_TYPE,
longEntitiesRowId,
resourceIdFromLongEntitiesRowId,
} from './long-entities/utils';

// ─── Operator-timeline components ────────────────────────────────────────────
export { OperatorGanttChart } from './operator-timeline/OperatorGanttChart';
Expand Down
12 changes: 5 additions & 7 deletions ui/packages/@quent/components/src/lib/timeline.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,13 @@ describe('nanosToMs', () => {
// ---- getLongEntitiesThreshold ----------------------------------------------

describe('getLongEntitiesThreshold', () => {
// Formula: 30 * (windowSeconds / MAX_TIMELINE_BINS) = 30 * (windowSeconds / 200)

it('returns the correct threshold for a 200-second window', () => {
expect(getLongEntitiesThreshold(200)).toBe(30);
it('returns the bin-scaled threshold for a 200-second window', () => {
expect(getLongEntitiesThreshold(200)).toBe(2);
});

it('scales linearly with window size', () => {
expect(getLongEntitiesThreshold(100)).toBe(15);
expect(getLongEntitiesThreshold(400)).toBe(60);
it('scales linearly with the visible window', () => {
expect(getLongEntitiesThreshold(100)).toBe(1);
expect(getLongEntitiesThreshold(400)).toBe(4);
});

it('returns 0 for a zero-second window', () => {
Expand Down
Loading