diff --git a/ui/packages/@quent/client/src/entityList.test.ts b/ui/packages/@quent/client/src/entityList.test.ts new file mode 100644 index 000000000..95f8fc27e --- /dev/null +++ b/ui/packages/@quent/client/src/entityList.test.ts @@ -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(); + }); +}); diff --git a/ui/packages/@quent/client/src/entityList.ts b/ui/packages/@quent/client/src/entityList.ts index e029204ab..e2ede7f90 100644 --- a/ui/packages/@quent/client/src/entityList.ts +++ b/ui/packages/@quent/client/src/entityList.ts @@ -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, @@ -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). */ @@ -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 { return { entry: { @@ -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 }, }; @@ -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)); diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index ed4cc2b9c..0a6085cb3 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -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'; @@ -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'; diff --git a/ui/packages/@quent/components/src/dag/ColorDot.tsx b/ui/packages/@quent/components/src/dag/ColorDot.tsx deleted file mode 100644 index fae2686e8..000000000 --- a/ui/packages/@quent/components/src/dag/ColorDot.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** Small square color swatch used as an inline legend marker. */ -export const ColorDot = ({ color }: { color: string }) => ( - -); diff --git a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx index 1278258fd..9d85dd033 100644 --- a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx +++ b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx @@ -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 @@ -84,7 +84,7 @@ export const DataFlowMatrix = ({ className="text-right font-normal text-muted-foreground px-1.5" > - + {k.display_name} @@ -99,7 +99,7 @@ export const DataFlowMatrix = ({ - + {state} diff --git a/ui/packages/@quent/components/src/gantt-chart/utils.test.ts b/ui/packages/@quent/components/src/gantt-chart/utils.test.ts index 0883c301b..62fd5a25e 100644 --- a/ui/packages/@quent/components/src/gantt-chart/utils.test.ts +++ b/ui/packages/@quent/components/src/gantt-chart/utils.test.ts @@ -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); }); }); diff --git a/ui/packages/@quent/components/src/gantt-chart/utils.ts b/ui/packages/@quent/components/src/gantt-chart/utils.ts index 8dc42ca0a..d60c91907 100644 --- a/ui/packages/@quent/components/src/gantt-chart/utils.ts +++ b/ui/packages/@quent/components/src/gantt-chart/utils.ts @@ -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; } diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 9fc0303af..88fb1b965 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -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 { @@ -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'; @@ -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'; diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.test.ts b/ui/packages/@quent/components/src/lib/timeline.utils.test.ts index a343d7143..d6a2966d8 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.test.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.test.ts @@ -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', () => { diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index 702450908..a28bd9592 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -36,7 +36,7 @@ import { MAX_TIMELINE_BINS } from '@quent/utils'; // Suppress unused import warning — getColorForKey is used by consumers of this module void getColorForKey; -const LONG_ENTITIES_BIN_MULTIPLIER = 30; +const LONG_ENTITIES_BIN_MULTIPLIER = 2; /** Minimum bin duration in nanoseconds — the backend cannot produce sub-1ns bins. */ export const MIN_BIN_DURATION_NS = 10; @@ -61,7 +61,7 @@ export function getAdaptiveNumBins(): number { return MAX_TIMELINE_BINS; } -/** Threshold for "long" entities: 10x the current bin duration in seconds. */ +/** Threshold for "long" entities as a fraction of the current bin duration. */ export function getLongEntitiesThreshold(windowSeconds: number): number { const numBins = getAdaptiveNumBins(); return LONG_ENTITIES_BIN_MULTIPLIER * (windowSeconds / numBins); @@ -676,8 +676,6 @@ export function buildBulkParamsForItem( } else { fsmTypeName = lookupFsmTypeName(item, entities); } - const threshold = getLongEntitiesThreshold(config.end - config.start); - if (isGroup) { return { ResourceGroup: { @@ -694,7 +692,7 @@ export function buildBulkParamsForItem( return { Resource: { resource_id: item.id, - long_entities_threshold_s: threshold, + long_entities_threshold_s: null, entity_filter: { entity_type_name: fsmTypeName }, application: { operator_ids: operatorId ? [operatorId] : [] }, config, diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 080fcad34..958f4df67 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -6,18 +6,26 @@ import { useCallback, useMemo } from 'react'; import { MARK_AREA_BORDER_OPACITY, MARK_AREA_FILL_OPACITY, - MARK_LABEL_TEXT_COLOR, + useTimelineEchartsTheme, } from '../timeline/timelineEchartsTheme'; +import { useZoomRange } from '@quent/hooks'; import { withOpacity } from '@quent/utils'; import type { LongEntityEntry } from './types'; import { GanttChart, type GanttRenderItem } from '../gantt-chart/GanttChart'; +import type { GanttHover } from '../gantt-chart/hover'; import { clipRectByRect } from '../gantt-chart/utils'; +import { getLongEntitySegmentsAtTimestamp } from './utils'; +import { PointerTooltipPortal } from '../ui/pointer-tooltip-portal'; +import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; -const DEFAULT_HEIGHT = 120; -const MAX_HEIGHT = 400; -const BAR_FONT_SIZE = 9; -const BAR_HEIGHT = 16; -const BAR_GAP = 2; +export const LONG_ENTITIES_TIMELINE_HEIGHT = 75; +const LABEL_FONT_SIZE = 9; +const BAR_HEIGHT = LABEL_FONT_SIZE + 4; +/** Vertical gap between stacked rows. */ +const ROW_GAP = 1; +const ROW_HEIGHT = BAR_HEIGHT + ROW_GAP; +/** Radius applied only to the outer corners of each entity's segment run. */ +const CORNER_RADIUS = 2; const SERIES_NAME = 'long-entity-segment'; /** Flat segment datum: one ECharts custom-series item per state span. */ @@ -38,9 +46,11 @@ export interface LongEntitiesGanttProps { export function LongEntitiesGantt({ entries, durationSeconds, - height = DEFAULT_HEIGHT, + height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, }: LongEntitiesGanttProps) { + const { textColor } = useTimelineEchartsTheme(isDark); + const zoomRange = useZoomRange(); // One custom-series datum per segment, tagged with its parent entry/segment. const customSeriesData = useMemo(() => { const data: SegmentDatum[] = []; @@ -55,6 +65,34 @@ export function LongEntitiesGantt({ }); return data; }, [entries]); + const renderTooltip = useCallback( + (hover: GanttHover | null) => { + const activeMarks: ActiveMark[] = hover + ? getLongEntitySegmentsAtTimestamp(entries, hover.timestampMs).map( + ({ entry, segment }) => ({ + color: segment.color, + label: entry.label, + stateName: segment.stateName, + durationMs: segment.endMs - segment.startMs, + attributes: segment.attributes, + derivedAttributes: segment.derivedAttributes, + }) + ) + : []; + return ( + 0 ? hover : null}> + {hover && ( + + )} + + ); + }, + [entries, zoomRange.end, zoomRange.start] + ); const renderItem: GanttRenderItem = useCallback( (params, api) => { @@ -71,8 +109,7 @@ export function LongEntitiesGantt({ 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 barTop = startPoint[1] - BAR_HEIGHT / 2; const width = Math.max(1, endPoint[0] - startPoint[0]); const coord = params.coordSys as { x?: number; y?: number; width?: number; height?: number }; @@ -80,14 +117,24 @@ export function LongEntitiesGantt({ 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 rectShape = { x: startPoint[0], y: barTop, width, height: BAR_HEIGHT }; const clippedShape = clipBound ? clipRectByRect(rectShape, clipBound) : rectShape; if (!clippedShape) return null; const color = segment.color; + const isFirst = datum!.segmentIndex === 0; + const isLast = datum!.segmentIndex === entry.segments.length - 1; + // [topLeft, topRight, bottomRight, bottomLeft] — round only the run's outer corners + // so touching segments tile with square inner seams. + const r: [number, number, number, number] = [ + isFirst ? CORNER_RADIUS : 0, + isLast ? CORNER_RADIUS : 0, + isLast ? CORNER_RADIUS : 0, + isFirst ? CORNER_RADIUS : 0, + ]; const rect = { type: 'rect' as const, - shape: { ...clippedShape, r: 1 }, + shape: { ...clippedShape, r }, // Mirror timeline marks: faint fill, stronger border, same state color. style: { fill: withOpacity(color, MARK_AREA_FILL_OPACITY), @@ -96,27 +143,21 @@ export function LongEntitiesGantt({ }, }; - // 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 + clippedShape.width > 10 ? [ { type: 'text' as const, style: { - text: entry.label, - x: textX, - y: textY, + text: `${entry.label} (${segment.stateName})`, + x: clippedShape.x + clippedShape.width / 2, + y: clippedShape.y + clippedShape.height / 2, + textAlign: 'center' as const, 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], + fontSize: LABEL_FONT_SIZE, + fill: textColor, overflow: 'truncate' as const, - width: Math.max(0, clippedShape.width - 8), + width: Math.max(0, clippedShape.width - 6), }, }, ] @@ -124,7 +165,7 @@ export function LongEntitiesGantt({ return { type: 'group' as const, children: [rect, ...labelChildren] }; }, - [entries, customSeriesData] + [entries, customSeriesData, textColor] ); return ( @@ -132,12 +173,13 @@ export function LongEntitiesGantt({ data={customSeriesData} durationSeconds={durationSeconds} height={height} - maxHeight={MAX_HEIGHT} - rowHeight={BAR_HEIGHT} + maxHeight={height} + rowHeight={ROW_HEIGHT} isDark={isDark} seriesName={SERIES_NAME} renderItem={renderItem} - emptyMessage="No long entities" + emptyMessage="No entities" + renderTooltip={renderTooltip} /> ); } diff --git a/ui/packages/@quent/components/src/long-entities/utils.test.ts b/ui/packages/@quent/components/src/long-entities/utils.test.ts index 63993b43c..8a45938f5 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.test.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import type { FiniteStateMachine, FsmTransition } from '@quent/utils'; -import { buildLongEntityEntries } from './utils'; +import { buildLongEntityEntries, getLongEntitySegmentsAtTimestamp } from './utils'; function transition( name: string, @@ -53,6 +53,47 @@ describe('buildLongEntityEntries', () => { expect(entry.segments[1]).toMatchObject({ startMs: 1000, endMs: 3000 }); }); + it('keeps only states used on a filtered resource', () => { + const fsm = makeFsm('e1', [ + transition('queueing', 0, { + usages: [{ resource: 'resource-2', capacities: [] }], + }), + transition('computing', 1, { + usages: [{ resource: 'resource-1', capacities: [] }], + }), + transition('exit', 3), + ]); + + const [entry] = buildLongEntityEntries([fsm], {}, 'light', new Set(['resource-1'])); + + expect(entry.segments.map(segment => segment.stateName)).toEqual(['computing']); + expect(entry).toMatchObject({ startMs: 1000, endMs: 3000 }); + }); + + it('drops entities with no states used on a filtered resource', () => { + const matching = makeFsm('matching', [ + transition('computing', 0, { + usages: [{ resource: 'resource-1', capacities: [] }], + }), + transition('exit', 1), + ]); + const unrelated = makeFsm('unrelated', [ + transition('queueing', 0, { + usages: [{ resource: 'resource-2', capacities: [] }], + }), + transition('exit', 1), + ]); + + const entries = buildLongEntityEntries( + [matching, unrelated], + {}, + 'light', + new Set(['resource-1']) + ); + + expect(entries.map(entry => entry.entityId)).toEqual(['matching']); + }); + it('spans the bar from first to last transition', () => { const fsm = makeFsm('e1', [ transition('a', 0.5), @@ -126,3 +167,38 @@ describe('buildLongEntityEntries', () => { expect(new Set(entries.map(e => e.rowIndex)).size).toBe(2); }); }); + +describe('getLongEntitySegmentsAtTimestamp', () => { + it('returns every entity and its active state', () => { + const first = makeFsm( + 'first', + [transition('loading', 0), transition('running', 1), transition('exit', 3)], + { instance_name: 'task-1' } + ); + const second = makeFsm('second', [transition('queued', 0.5), transition('done', 2)], { + instance_name: 'task-2', + }); + const entries = buildLongEntityEntries([first, second], {}, 'light'); + + expect( + getLongEntitySegmentsAtTimestamp(entries, 1_500).map(({ entry, segment }) => [ + entry.label, + segment.stateName, + ]) + ).toEqual([ + ['task-1', 'running'], + ['task-2', 'queued'], + ]); + }); + + it('uses half-open state boundaries', () => { + const fsm = makeFsm('entity', [ + transition('loading', 0), + transition('running', 1), + transition('exit', 2), + ]); + const entries = buildLongEntityEntries([fsm], {}, 'light'); + const [{ segment }] = getLongEntitySegmentsAtTimestamp(entries, 1_000); + expect(segment.stateName).toBe('running'); + }); +}); diff --git a/ui/packages/@quent/components/src/long-entities/utils.ts b/ui/packages/@quent/components/src/long-entities/utils.ts index 9a1155516..549e2865e 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.ts @@ -30,13 +30,20 @@ export function resourceIdFromLongEntitiesRowId(id: string): string | null { */ function buildSegments( fsm: FiniteStateMachine, - colorFsm: (stateName: string) => string + colorFsm: (stateName: string) => string, + resourceIdsForFilter?: ReadonlySet | null ): LongEntitySegment[] { return fsm.transitions .slice(0, -1) .map((transition, i): LongEntitySegment | null => { const next = fsm.transitions[i + 1]; if (!next) return null; + if ( + resourceIdsForFilter != null && + !transition.usages?.some(usage => resourceIdsForFilter.has(usage.resource)) + ) { + return null; + } const startMs = transition.timestamp * 1000; const endMs = next.timestamp * 1000; if (endMs <= startMs) return null; @@ -65,13 +72,14 @@ function buildSegments( export function buildLongEntityEntries( items: FiniteStateMachine[], fsmTypes: { [key in string]?: FsmTypeDecl } | undefined, - theme: PaletteTheme + theme: PaletteTheme, + resourceIdsForFilter?: ReadonlySet | null ): LongEntityEntry[] { const colorFsm = createFsmTypeColorFn(fsmTypes ?? {}, theme); const entries: LongEntityEntry[] = []; for (const fsm of items) { - const segments = buildSegments(fsm, colorFsm); + const segments = buildSegments(fsm, colorFsm, resourceIdsForFilter); if (segments.length === 0) continue; const startMs = segments[0]!.startMs; const endMs = segments[segments.length - 1]!.endMs; @@ -88,3 +96,16 @@ export function buildLongEntityEntries( return stackIntervalsIntoRows(entries); } + +/** Return every entity state whose half-open segment contains the timestamp. */ +export function getLongEntitySegmentsAtTimestamp( + entries: LongEntityEntry[], + timestampMs: number +): Array<{ entry: LongEntityEntry; segment: LongEntitySegment }> { + return entries.flatMap(entry => { + const segment = entry.segments.find( + candidate => candidate.startMs <= timestampMs && timestampMs < candidate.endMs + ); + return segment ? [{ entry, segment }] : []; + }); +} diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 1b47f8123..b36785ca5 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -16,7 +16,10 @@ import { import { continuousColor, withOpacity, getOperationTypeColor } from '@quent/utils'; import type { OperatorActiveSpanEntry } from './types'; import { GanttChart, type GanttRenderItem } from '../gantt-chart/GanttChart'; +import type { GanttHover } from '../gantt-chart/hover'; import { clipRectByRect } from '../gantt-chart/utils'; +import { getOperatorsAtTimestamp } from './utils'; +import { GanttTooltipPortal, type GanttTooltipItem } from '../ui/gantt-tooltip'; const DEFAULT_HEIGHT = 75; const MAX_HEIGHT = 200; @@ -62,6 +65,19 @@ export function OperatorGanttChart({ })), [operators] ); + const renderTooltip = useCallback( + (hover: GanttHover | null) => { + const items: GanttTooltipItem[] = hover + ? getOperatorsAtTimestamp(operators, hover.timestampMs).map(operator => ({ + id: operator.operatorId, + color: getOperatorBarColors(operator.typeName).stroke, + name: operator.label, + })) + : []; + return ; + }, + [operators] + ); const operatorFieldStyles = useMemo(() => { const styles = new Map(); if (!nodeColoring) return styles; @@ -218,6 +234,7 @@ export function OperatorGanttChart({ emptyMessage="No operator active spans" cursor="pointer" onEvents={handleClick} + renderTooltip={renderTooltip} /> ); } diff --git a/ui/packages/@quent/components/src/operator-timeline/types.ts b/ui/packages/@quent/components/src/operator-timeline/types.ts index bfe293e74..4573e9834 100644 --- a/ui/packages/@quent/components/src/operator-timeline/types.ts +++ b/ui/packages/@quent/components/src/operator-timeline/types.ts @@ -5,7 +5,7 @@ import type { StatValue } from '../services/query-plan/types'; /** * One operator with an active span, normalized for chart consumption. - * Time is in milliseconds (aligned with timeline startTime). + * Time is ms elapsed from query start (same domain as resource timelines). */ export type OperatorActiveSpanEntry = { operatorId: string; diff --git a/ui/packages/@quent/components/src/operator-timeline/utils.test.ts b/ui/packages/@quent/components/src/operator-timeline/utils.test.ts index c2d329362..93e285f12 100644 --- a/ui/packages/@quent/components/src/operator-timeline/utils.test.ts +++ b/ui/packages/@quent/components/src/operator-timeline/utils.test.ts @@ -3,11 +3,13 @@ import { describe, it, expect } from 'vitest'; import type { QueryBundle, EntityRef, Operator, PlanTree } from '@quent/utils'; +import type { OperatorActiveSpanEntry } from './types'; import { operatorTimelineRowId, workerIdFromOperatorTimelineRowId, getWorkerIdsFromPlanTree, getPlanIdsForWorker, + getOperatorsAtTimestamp, spanToMs, operatorsWithActiveSpans, operatorsWithActiveSpansForWorker, @@ -152,6 +154,36 @@ describe('getPlanIdsForWorker', () => { }); }); +describe('getOperatorsAtTimestamp', () => { + const operator = ( + operatorId: string, + startMs: number, + endMs: number + ): OperatorActiveSpanEntry => ({ + operatorId, + label: operatorId, + typeName: 'scan', + startMs, + endMs, + rowIndex: 0, + planId: 'plan', + statistics: [], + }); + + it('returns every overlapping operator', () => { + const operators = [operator('a', 0, 20), operator('b', 10, 30), operator('c', 30, 40)]; + expect(getOperatorsAtTimestamp(operators, 15).map(entry => entry.operatorId)).toEqual([ + 'a', + 'b', + ]); + }); + + it('treats spans as half-open at adjacent boundaries', () => { + const operators = [operator('a', 0, 10), operator('b', 10, 20)]; + expect(getOperatorsAtTimestamp(operators, 10).map(entry => entry.operatorId)).toEqual(['b']); + }); +}); + // ---- spanToMs -------------------------------------------------------------- describe('spanToMs', () => { diff --git a/ui/packages/@quent/components/src/operator-timeline/utils.ts b/ui/packages/@quent/components/src/operator-timeline/utils.ts index 55df6f76c..c1ddb1a05 100644 --- a/ui/packages/@quent/components/src/operator-timeline/utils.ts +++ b/ui/packages/@quent/components/src/operator-timeline/utils.ts @@ -47,6 +47,16 @@ export function getPlanIdsForWorker(planTree: PlanTree, workerId: string): strin return planIds; } +/** Return every operator whose half-open active span contains the timestamp. */ +export function getOperatorsAtTimestamp( + operators: OperatorActiveSpanEntry[], + timestampMs: number +): OperatorActiveSpanEntry[] { + return operators.filter( + operator => operator.startMs <= timestampMs && timestampMs < operator.endMs + ); +} + /** * SpanSec from the API is in seconds relative to query start. * Returns ms offsets relative to query start (no absolute epoch base) so the diff --git a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx index c8c7a8dd9..6b1ab24ff 100644 --- a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx +++ b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx @@ -6,7 +6,6 @@ import { DEFAULT_STALE_TIME, fetchSingleTimeline } from '@quent/client'; import { useBulkInitialized, useDebouncedZoomRange, - useHideTasks, timelineCacheKey, useTimelineData, useSelectedNodeIds, @@ -20,15 +19,12 @@ import type { TimelineHoverPosition } from './Timeline'; import { useCallback, useEffect, useId, useMemo, useRef, lazy, Suspense } from 'react'; import { buildBinnedTimelineSeries, - buildTimelineMarks, dimSeries, - getLongFsms, mergeOverlaySeries, getAdaptiveNumBins, getTimelineConfig, - getLongEntitiesThreshold, } from '../lib/timeline.utils'; -import { TimelineSeries, TimelineMark } from './types'; +import { TimelineSeries } from './types'; import { EntityTypeKey } from '@quent/utils'; import { WHITE, withOpacity, type PaletteTheme } from '@quent/utils'; import type { @@ -90,7 +86,6 @@ export function ResourceTimeline({ const zoomRange = useDebouncedZoomRange(); const bulkInitialized = useBulkInitialized(); const operatorLabel = useSelectedOperatorLabel(); - const hideTasks = useHideTasks(); const selectedNodeIds = useSelectedNodeIds(); const operatorId = selectedNodeIds.size > 0 ? selectedNodeIds.values().next().value! : null; @@ -142,7 +137,6 @@ export function ResourceTimeline({ const isGroup = resourceType === EntityTypeKey.ResourceGroup; const start = zoomRange?.start ?? 0; const end = zoomRange?.end ?? durationSeconds; - const windowSeconds = end - start; const config = { num_bins: getAdaptiveNumBins(), start, @@ -154,7 +148,7 @@ export function ResourceTimeline({ ResourceGroup: { resource_group_id: resourceId, resource_type_name: resourceTypeName ?? '', - long_entities_threshold_s: getLongEntitiesThreshold(windowSeconds), + long_entities_threshold_s: null, entity_filter: { entity_type_name: fsmTypeName ?? null }, app_params: { operator_ids: [] }, config, @@ -163,7 +157,7 @@ export function ResourceTimeline({ : { Resource: { resource_id: resourceId, - long_entities_threshold_s: getLongEntitiesThreshold(windowSeconds), + long_entities_threshold_s: null, entity_filter: { entity_type_name: fsmTypeName ?? null }, application: { operator_ids: [] }, config, @@ -178,10 +172,9 @@ export function ResourceTimeline({ placeholderData: keepPreviousData, }); - const { timestamps, series, marks, yAxisLabel } = useMemo<{ + const { timestamps, series, yAxisLabel } = useMemo<{ timestamps: number[]; series: TimelineSeries; - marks?: TimelineMark[]; yAxisLabel?: string; }>(() => { const data = preloadedData ?? fetchedData; @@ -195,11 +188,6 @@ export function ResourceTimeline({ quantitySpecs, fsmTypes ); - const longFsms = getLongFsms(data.data); - const filterSet = - resourceType === EntityTypeKey.Resource ? new Set([resourceId]) : new Set(); - - const timelineMarks = buildTimelineMarks(longFsms, paletteTheme, filterSet, fsmTypes); if (operatorId && operatorLabel) { if (overlayPreloadedData) { @@ -215,19 +203,10 @@ export function ResourceTimeline({ quantitySpecs, fsmTypes ); - const opLongFsmIds = new Set(getLongFsms(overlayPreloadedData.data).map(f => f.id)); return { timestamps: base.timestamps, series: mergeOverlaySeries(base.series, opResult.series, operatorLabel), yAxisLabel: base.yAxisLabel, - marks: buildTimelineMarks( - longFsms, - paletteTheme, - filterSet, - fsmTypes, - opLongFsmIds, - operatorLabel - ), }; } } @@ -239,11 +218,10 @@ export function ResourceTimeline({ timestamps: base.timestamps, series: dimSeries(base.series), yAxisLabel: base.yAxisLabel, - marks: timelineMarks, }; } - return { ...base, marks: timelineMarks }; + return base; }, [ preloadedData, fetchedData, @@ -252,8 +230,6 @@ export function ResourceTimeline({ resourceTypeDecl, quantitySpecs, fsmTypes, - resourceType, - resourceId, operatorLabel, paletteTheme, ]); @@ -297,7 +273,6 @@ export function ResourceTimeline({ ); } - const effectiveMarks = hideTasks ? undefined : marks; const effectiveYAxisLabel = yAxisLabel ?? fsmTypeName; return ( @@ -308,18 +283,12 @@ export function ResourceTimeline({ timestamps={timestamps ?? []} durationSeconds={durationSeconds} showTooltip={showTooltip} - marks={effectiveMarks} isDark={isDark} yAxisLabel={effectiveYAxisLabel} onHoverChange={handleHoverChange} /> {showTooltip && ( - + )} diff --git a/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx new file mode 100644 index 000000000..410542bc1 --- /dev/null +++ b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Settings } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; + +export function TimelineSettingsPopover() { + return ( + + + + + No settings yet. + + ); +} diff --git a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx index a57803254..3255b2958 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx @@ -1,20 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Maximize2, Settings } from 'lucide-react'; -import { - useHideTasks, - useSetHideTasks, - useSetZoomRange, - useSetDebouncedZoomRange, -} from '@quent/hooks'; -import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'; +import { Maximize2 } from 'lucide-react'; +import { useSetZoomRange, useSetDebouncedZoomRange } from '@quent/hooks'; import { QueryToolbar } from './QueryToolbar'; -/** Toolbar for the timeline view: shows active operator filter, zoom reset, and settings. */ +/** Toolbar for the timeline view: shows the active operator filter and zoom reset. */ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number }) { - const hideTasks = useHideTasks(); - const setHideTasks = useSetHideTasks(); const setZoomRange = useSetZoomRange(); const setDebouncedZoomRange = useSetDebouncedZoomRange(); @@ -34,30 +26,6 @@ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number } Reset zoom - -
- - - - - - - - - ); } diff --git a/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx index 1b179fa1e..c9d35197c 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { TooltipContent, type ActiveMark } from './TimelineTooltip'; +import { EntityTooltipContent, TooltipContent, type ActiveMark } from './TimelineTooltip'; import type { DynamicValue } from '@quent/utils'; // The Rust `DynamicValue` enum serializes externally tagged. This is the shape the @@ -69,4 +69,24 @@ describe('TooltipContent active marks', () => { expect(screen.getByText('task-0')).toBeInTheDocument(); expect(screen.getByText('sending')).toBeInTheDocument(); }); + + it('renders entity-only content without a timeline total', () => { + render( + + ); + expect(screen.getByText('task-0')).toBeInTheDocument(); + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(screen.queryByText('Total')).not.toBeInTheDocument(); + }); }); diff --git a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx index 565ecfff5..95124f963 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx @@ -8,6 +8,7 @@ import { cn, type DynamicAttribute, } from '@quent/utils'; +import { ColorSwatch } from '../ui/color-swatch'; import { DataText } from '../ui/data-text'; /** A timeline mark under the hover cursor, as shown in the tooltip. */ @@ -43,9 +44,7 @@ const TooltipSeriesStat = ({ }) => { return (
  • - {series.color && ( - - )} + {series.color && } {series.name} {fmt(series.value ?? 0)} @@ -131,7 +130,7 @@ function SegmentedBarRow({
  • ', + 'text-foreground font-semibold text-[11px] text-right tracking-tighter', valueClassName )} > @@ -207,13 +206,7 @@ function ActiveMarksSection({ marks }: { marks: ActiveMark[] }) { {marks.map((m, i) => (
    - + {m.label} {m.stateName}
    @@ -342,6 +335,27 @@ function OverlayBarTooltip({ ); } +/** ResourceTimeline entity-mark tooltip, reusable by entity Gantt charts. */ +export function EntityTooltipContent({ + timestamp, + windowMs, + activeMarks, +}: { + /** Elapsed ms from query start. */ + timestamp: number; + windowMs: number; + activeMarks: ActiveMark[]; +}) { + return ( +
    + + {formatDurationForWindow(timestamp, windowMs)} + + +
    + ); +} + export function TooltipContent({ timestamp, series, diff --git a/ui/packages/@quent/components/src/ui/button.tsx b/ui/packages/@quent/components/src/ui/button.tsx index 60767d053..5cf2ad553 100644 --- a/ui/packages/@quent/components/src/ui/button.tsx +++ b/ui/packages/@quent/components/src/ui/button.tsx @@ -21,6 +21,7 @@ const buttonVariants = cva( }, size: { default: 'h-10 px-4 py-2', + xs: 'h-6 rounded-sm px-2 text-xs', sm: 'h-9 rounded-sm px-3', lg: 'h-11 rounded-sm px-8', icon: 'h-10 w-10', diff --git a/ui/packages/@quent/components/src/ui/color-swatch.tsx b/ui/packages/@quent/components/src/ui/color-swatch.tsx new file mode 100644 index 000000000..085584f67 --- /dev/null +++ b/ui/packages/@quent/components/src/ui/color-swatch.tsx @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from '@quent/utils'; + +type ColorSwatchProps = { + color: string; + shape?: 'circle' | 'square'; + className?: string; +}; + +export function ColorSwatch({ color, shape = 'circle', className }: ColorSwatchProps) { + return ( + + ); +} diff --git a/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx new file mode 100644 index 000000000..2bb4ebd69 --- /dev/null +++ b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColorSwatch } from './color-swatch'; +import { DataText } from './data-text'; +import { PointerTooltipPortal } from './pointer-tooltip-portal'; +import type { GanttHover } from '../gantt-chart/hover'; + +export interface GanttTooltipItem { + id: string; + color: string; + name: string; + detail?: string; +} + +export function GanttTooltipPortal({ + hover, + items, +}: { + hover: GanttHover | null; + items: GanttTooltipItem[]; +}) { + if (!hover || items.length === 0) return null; + return ( + +
    +
      + {items.map(item => ( +
    • + + {item.name} + {item.detail && ( + + {item.detail} + + )} +
    • + ))} +
    +
    +
    + ); +} diff --git a/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx b/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx new file mode 100644 index 000000000..8e0027aac --- /dev/null +++ b/ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +const POINTER_OFFSET = 12; +const VIEWPORT_MARGIN = 4; + +export interface PointerPosition { + clientX: number; + clientY: number; +} + +export function PointerTooltipPortal({ + hover, + children, +}: { + hover: PointerPosition | null; + children: ReactNode; +}) { + if (!hover) return null; + return {children}; +} + +function PositionedPointerTooltip({ + hover, + children, +}: { + hover: PointerPosition; + children: ReactNode; +}) { + const hostRef = useRef(null); + const [position, setPosition] = useState({ + left: hover.clientX + POINTER_OFFSET, + top: hover.clientY + POINTER_OFFSET, + }); + + useLayoutEffect(() => { + const element = hostRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + let left = hover.clientX + POINTER_OFFSET; + let top = hover.clientY + POINTER_OFFSET; + if (left + rect.width + VIEWPORT_MARGIN > window.innerWidth) { + left = Math.max(VIEWPORT_MARGIN, hover.clientX - rect.width - POINTER_OFFSET); + } + if (top + rect.height + VIEWPORT_MARGIN > window.innerHeight) { + top = Math.max(VIEWPORT_MARGIN, hover.clientY - rect.height - POINTER_OFFSET); + } + setPosition({ left, top }); + }, [hover.clientX, hover.clientY, children]); + + return createPortal( +
    + {children} +
    , + document.body + ); +} diff --git a/ui/packages/@quent/hooks/src/atoms/timeline.ts b/ui/packages/@quent/hooks/src/atoms/timeline.ts index e2befcfa0..64233020d 100644 --- a/ui/packages/@quent/hooks/src/atoms/timeline.ts +++ b/ui/packages/@quent/hooks/src/atoms/timeline.ts @@ -77,6 +77,3 @@ export const bulkInitializedAtom = atom(false); /** Visible entries for bulk fetch — set in useEffect, read imperatively via store.get() */ export const visibleEntriesAtom = atom>>({}); - -/** When true, hides task annotation marks on timeline charts */ -export const hideTasksAtom = atom(false); diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index 8be8914a0..5d3846a87 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -29,8 +29,6 @@ export { useSetBulkInitialized, useVisibleEntries, useSetVisibleEntries, - useHideTasks, - useSetHideTasks, useHydrateTimelineAtoms, } from './timeline/useTimelineAtoms'; diff --git a/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts index c7c01047e..f79336d0d 100644 --- a/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts +++ b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts @@ -11,7 +11,6 @@ import { startTimeMsAtom, bulkInitializedAtom, visibleEntriesAtom, - hideTasksAtom, } from '../atoms/timeline'; import type { ZoomRange, SingleTimelineResponse } from '@quent/utils'; @@ -33,8 +32,6 @@ export const useBulkInitialized = () => useAtomValue(bulkInitializedAtom); export const useSetBulkInitialized = () => useSetAtom(bulkInitializedAtom); export const useVisibleEntries = () => useAtomValue(visibleEntriesAtom); export const useSetVisibleEntries = () => useSetAtom(visibleEntriesAtom); -export const useHideTasks = () => useAtomValue(hideTasksAtom); -export const useSetHideTasks = () => useSetAtom(hideTasksAtom); /** * Hydrates the timeline atoms with initial values synchronously during render. diff --git a/ui/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx new file mode 100644 index 000000000..2088218fc --- /dev/null +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ButtonHTMLAttributes, HTMLAttributes } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LongEntitiesRow } from './LongEntitiesRow'; + +const mocks = vi.hoisted(() => ({ + buildLongEntityEntries: vi.fn((items: unknown[]) => items), + fetchNextPage: vi.fn(), + getLongEntitiesThreshold: vi.fn((_windowSeconds: number) => 0.06), + longEntitiesGantt: vi.fn((_props: { entries: unknown[]; height: number }) => null), + useInfiniteEntityList: vi.fn(), +})); + +vi.mock('@quent/client', () => ({ + useInfiniteEntityList: mocks.useInfiniteEntityList, +})); + +vi.mock('@quent/hooks', () => ({ + useDebouncedZoomRange: () => ({ start: 0.2, end: 0.6 }), + useSelectedNodeIds: () => new Set(['operator-1']), +})); + +vi.mock('@quent/components', () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), + LONG_ENTITIES_TIMELINE_HEIGHT: 110, + LongEntitiesGantt: (props: { entries: unknown[]; height: number }) => { + mocks.longEntitiesGantt(props); + return
    ; + }, + Skeleton: (props: HTMLAttributes) =>
    , + buildLongEntityEntries: mocks.buildLongEntityEntries, + getLongEntitiesThreshold: mocks.getLongEntitiesThreshold, +})); + +describe('LongEntitiesRow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.useInfiniteEntityList.mockReturnValue({ + data: undefined, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isPlaceholderData: false, + }); + }); + + it('filters the entity request using the visible timeline window', () => { + render( + + ); + + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[0]).toBeCloseTo(0.4); + expect(mocks.useInfiniteEntityList).toHaveBeenCalledWith( + expect.objectContaining({ + window: { start: 0.2, end: 0.6 }, + operatorIds: ['operator-1'], + minUsageSeconds: 0.06, + maxItems: 100, + }) + ); + expect(mocks.longEntitiesGantt.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ height: 110 }) + ); + }); + + it('can limit FSM states to those used on the associated resource', () => { + render( + + ); + + expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( + [], + {}, + 'light', + new Set(['resource-1']) + ); + }); + + it('renders a chart-shaped skeleton during the initial load', () => { + mocks.useInfiniteEntityList.mockReturnValue({ + data: undefined, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: false, + isFetching: true, + isFetchingNextPage: false, + isPlaceholderData: false, + }); + + render( + + ); + + const skeleton = screen.getByRole('status', { name: 'Loading entities' }); + expect(skeleton.children).toHaveLength(3); + expect(screen.queryByText('Loading entities…')).not.toBeInTheDocument(); + }); + + it('loads the next page and appends its entities', () => { + const firstEntity = { id: 'entity-1' }; + const secondEntity = { id: 'entity-2' }; + mocks.useInfiniteEntityList.mockReturnValue({ + data: { pages: [{ items: [firstEntity], total: 2 }] }, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isPlaceholderData: false, + }); + + const props = { + engineId: 'engine-1', + queryId: 'query-1', + resourceId: 'resource-1', + durationSeconds: 1, + fsmTypes: {}, + isDark: false, + }; + const { rerender } = render(); + + const button = screen.getByRole('button', { name: 'Show more (1 of 2)' }); + expect(screen.getByTestId('long-entities-gantt').nextElementSibling).toContainElement(button); + fireEvent.click(button); + expect(mocks.fetchNextPage).toHaveBeenCalledOnce(); + + mocks.useInfiniteEntityList.mockReturnValue({ + data: { + pages: [ + { items: [firstEntity], total: 2 }, + { items: [secondEntity], total: 2 }, + ], + }, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isPlaceholderData: false, + }); + rerender(); + + expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( + [firstEntity, secondEntity], + {}, + 'light', + new Set(['resource-1']) + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('keeps the previous entities visible while a changed request loads', () => { + const previousEntity = { id: 'entity-1' }; + mocks.useInfiniteEntityList.mockReturnValue({ + data: { pages: [{ items: [previousEntity], total: 2 }] }, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: true, + isFetching: true, + isFetchingNextPage: false, + isPlaceholderData: true, + }); + + render( + + ); + + expect(screen.queryByText('Loading entities…')).not.toBeInTheDocument(); + expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( + [previousEntity], + {}, + 'light', + new Set(['resource-1']) + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx new file mode 100644 index 000000000..afa714d5f --- /dev/null +++ b/ui/src/components/LongEntitiesRow.tsx @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import { useInfiniteEntityList } from '@quent/client'; +import { useDebouncedZoomRange, useSelectedNodeIds } from '@quent/hooks'; +import type { FsmTypeDecl } from '@quent/utils'; +import { + Button, + LONG_ENTITIES_TIMELINE_HEIGHT, + LongEntitiesGantt, + Skeleton, + buildLongEntityEntries, + getLongEntitiesThreshold, +} from '@quent/components'; + +const ENTITIES_PER_PAGE = 100; + +type LongEntitiesRowProps = { + engineId: string; + queryId: string; + /** The resource this row's entities are scoped to. */ + resourceId: string; + durationSeconds: number; + fsmTypes: { [key in string]?: FsmTypeDecl }; + isDark: boolean; + /** Defaults to all states; resource scope keeps states used on this row's resource. */ + fsmStateScope?: 'all' | 'resource'; +}; + +/** + * Per-resource long-entities Gantt. Fetches the resource's entities (ranked by + * longest usage) as soon as the row is shown and renders them as a compact + * stacked Gantt directly under the timeline. + */ +export function LongEntitiesRow({ + engineId, + queryId, + resourceId, + durationSeconds, + fsmTypes, + isDark, + fsmStateScope = 'resource', +}: LongEntitiesRowProps) { + const selectedNodeIds = useSelectedNodeIds(); + const debouncedZoomRange = useDebouncedZoomRange(); + const operatorIds = useMemo(() => [...selectedNodeIds], [selectedNodeIds]); + const zoomWindow = + debouncedZoomRange.end > debouncedZoomRange.start + ? debouncedZoomRange + : { start: 0, end: durationSeconds }; + const minUsageSeconds = getLongEntitiesThreshold(zoomWindow.end - zoomWindow.start); + + const { data, fetchNextPage, hasNextPage, isFetching, isPlaceholderData } = useInfiniteEntityList( + { + engineId, + queryId, + window: zoomWindow, + operatorIds, + minUsageSeconds, + sortDir: 'Desc', + maxItems: ENTITIES_PER_PAGE, + filter: { scope: { Resource: { resource_id: resourceId } } }, + } + ); + + const entities = useMemo(() => data?.pages.flatMap(page => page.items) ?? [], [data]); + const entries = useMemo( + () => + buildLongEntityEntries( + entities, + fsmTypes, + isDark ? 'dark' : 'light', + fsmStateScope === 'resource' ? new Set([resourceId]) : null + ), + [entities, fsmStateScope, fsmTypes, isDark, resourceId] + ); + const totalEntities = data?.pages[data.pages.length - 1]?.total ?? entities.length; + + if (!data && isFetching) { + return ( +
    + + + +
    + ); + } + + return ( +
    + + + {hasNextPage && !isPlaceholderData && ( +
    + +
    + )} +
    + ); +} diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 1a33c65d5..5baed8754 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useAtom } from 'jotai'; import { useHighlightedItemIds, useBulkTimelines, useHydrateTimelineAtoms } from '@quent/hooks'; -import { ResourceTree, QueryBundle } from '@quent/utils'; +import { ResourceTree, QueryBundle, EntityTypeKey } from '@quent/utils'; import type { EntityRef, SingleTimelineRequest, QueryFilter, OperatorFilter } from '@quent/utils'; import { TimelineController, TimelineRuler } from '@quent/components'; import { collectResourceTypesFromTree } from '@quent/components'; @@ -40,6 +40,12 @@ import { operatorsWithActiveSpansForWorker, workerIdFromOperatorTimelineRowId, } from '@quent/components'; +import { + LONG_ENTITIES_ROW_TYPE, + longEntitiesRowId, + resourceIdFromLongEntitiesRowId, +} from '@quent/components'; +import { LongEntitiesRow } from '@/components/LongEntitiesRow'; function getRootResourceGroupId(resourceTree: ResourceTree): string | null { if (!('ResourceGroup' in resourceTree)) return null; @@ -75,6 +81,42 @@ function injectOperatorTimelineRows(item: TreeTableItem, workerIds: Set) return { ...item, children }; } +/** Create the synthetic long-entities row for a leaf resource. */ +function createLongEntitiesRow(resourceId: string): TreeTableItem { + return { + id: longEntitiesRowId(resourceId), + type: LONG_ENTITIES_ROW_TYPE, + entity: {} as TreeTableItem['entity'], + }; +} + +function GanttRowLabel({ children }: { children: string }) { + return ( + + + {children} + + ); +} + +/** + * Insert a long-entities row as a sibling immediately after each leaf resource, + * so its compact Gantt is always shown below the resource (whenever in view) + * rather than gated behind expansion. Leaf resources keep no synthetic children, + * so they stay non-expandable. Groups (which aggregate resources) are untouched. + */ +function injectLongEntitiesRows(item: TreeTableItem): TreeTableItem { + if (!item.children?.length) return { ...item }; + const children: TreeTableItem[] = []; + for (const child of item.children) { + children.push(injectLongEntitiesRows(child)); + if (child.type === EntityTypeKey.Resource) { + children.push(createLongEntitiesRow(child.id)); + } + } + return { ...item, children }; +} + interface QueryResourceTreeProps { engineId: string; queryBundle: QueryBundle; @@ -186,7 +228,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr ); const treeData = useMemo( - () => [injectOperatorTimelineRows(rootItem, workerIdsFromPlanTree)], + () => [injectLongEntitiesRows(injectOperatorTimelineRows(rootItem, workerIdsFromPlanTree))], [rootItem, workerIdsFromPlanTree] ); @@ -214,7 +256,10 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr render: ({ item }: { item: TreeTableItem; level: number }) => { switch (item.type) { case OPERATOR_TIMELINE_ROW_TYPE: { - return null; + return Operators; + } + case LONG_ENTITIES_ROW_TYPE: { + return Entities; } default: { const selectedType = @@ -273,6 +318,20 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr /> ); } + case LONG_ENTITIES_ROW_TYPE: { + const resourceId = resourceIdFromLongEntitiesRowId(item.id); + if (resourceId == null) return null; + return ( + + ); + } default: { return (