diff --git a/ui/packages/@quent/client/src/entityList.test.ts b/ui/packages/@quent/client/src/entityList.test.ts index 95f8fc27e..99cabd5be 100644 --- a/ui/packages/@quent/client/src/entityList.test.ts +++ b/ui/packages/@quent/client/src/entityList.test.ts @@ -3,8 +3,7 @@ import { describe, expect, it } from 'vitest'; import { keepPreviousData } from '@tanstack/react-query'; -import type { EntityListResponse } from '@quent/utils'; -import { entityListInfiniteQueryOptions, entityListQueryOptions } from './entityList'; +import { entityListQueryOptions } from './entityList'; describe('entityListQueryOptions', () => { it('copies selected operator IDs into the entity-list request', () => { @@ -31,22 +30,4 @@ describe('entityListQueryOptions', () => { ]); 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 e2ede7f90..c0278751e 100644 --- a/ui/packages/@quent/client/src/entityList.ts +++ b/ui/packages/@quent/client/src/entityList.ts @@ -1,13 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - infiniteQueryOptions, - keepPreviousData, - queryOptions, - useInfiniteQuery, - useQuery, -} from '@tanstack/react-query'; +import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; import type { EntityListRequest, EntityScope, @@ -82,30 +76,3 @@ 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 2790a70e9..1a70110ce 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -28,7 +28,7 @@ export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; export { dataFlowQueryOptions } from './dataFlow'; -export { entityListInfiniteQueryOptions, entityListQueryOptions } from './entityList'; +export { entityListQueryOptions } from './entityList'; export { canonicalizeNvtxRequest, canonicalizeNvtxSelections, @@ -44,5 +44,5 @@ export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; export { useDataFlow } from './dataFlow'; -export { useEntityList, useInfiniteEntityList } from './entityList'; +export { useEntityList } from './entityList'; export { useEngineContexts, useNvtxCatalog, useNvtxViewport } from './nvtx'; diff --git a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx index 6e2828f79..172d82178 100644 --- a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx +++ b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx @@ -42,10 +42,12 @@ export interface GanttChartProps { isDark: boolean; seriesName: string; renderItem: GanttRenderItem; - emptyMessage: string; + emptyMessage: ReactNode; cursor?: GanttSeriesCursor; onEvents?: EChartsEvents; gridSpacing?: GanttGridSpacing; + contentPaddingBottom?: number; + animateHeight?: boolean; renderTooltip?: (hover: GanttHover | null) => ReactNode; } @@ -62,6 +64,8 @@ export function GanttChart({ cursor, onEvents, gridSpacing, + contentPaddingBottom = 0, + animateHeight = false, renderTooltip, }: GanttChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); @@ -79,7 +83,7 @@ export function GanttChart({ rowCount: maxRow + 1, }; }, [data]); - const chartHeight = Math.max(height, rowCount * rowHeight); + const chartHeight = Math.max(height, rowCount * rowHeight + contentPaddingBottom); const wrapperHeight = Math.min(chartHeight, maxHeight); const option = useMemo( @@ -141,7 +145,15 @@ export function GanttChart({ return ( <> - + { // ---- getLongEntitiesThreshold ---------------------------------------------- describe('getLongEntitiesThreshold', () => { - it('returns the bin-scaled threshold for a 200-second window', () => { - expect(getLongEntitiesThreshold(200)).toBe(2); + it('uses the middle density threshold by default', () => { + expect(getLongEntitiesThreshold(200, 200)).toBe(1); + }); + + it.each([ + [1, 100], + [2, 10], + [3, 1], + [4, 0.1], + [5, 0.01], + ] as const)('maps density %s to its bin multiplier', (density, expected) => { + expect(getLongEntitiesThreshold(200, 200, density)).toBe(expected); }); it('scales linearly with the visible window', () => { - expect(getLongEntitiesThreshold(100)).toBe(1); - expect(getLongEntitiesThreshold(400)).toBe(4); + expect(getLongEntitiesThreshold(100, 200)).toBe(0.5); + expect(getLongEntitiesThreshold(400, 200)).toBe(2); + }); + + it('uses the returned bin count', () => { + expect(getLongEntitiesThreshold(200, 400)).toBe(0.5); }); it('returns 0 for a zero-second window', () => { - expect(getLongEntitiesThreshold(0)).toBe(0); + expect(getLongEntitiesThreshold(0, 200)).toBe(0); }); }); diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index a28bd9592..a80caca75 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -30,13 +30,15 @@ import { entityRefToEntitiesKey } from './queryBundle.utils'; import { collectResourceTypesFromTree, getIconForType } from './resource.utils'; import { EntityTypeValue, EntityRefKey, EntityTypeKey } from '@quent/utils'; import type { EChartsInstance } from 'echarts-for-react'; +import { LONG_ENTITY_DENSITIES, type LongEntityDensity } from '@quent/hooks'; import { connect } from './echarts'; import { CHART_GROUP } from '../timeline/types'; 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 = 2; + +const LONG_ENTITY_DENSITY_MULTIPLIERS = [100, 10, 1, 0.1, 0.01] as const; /** Minimum bin duration in nanoseconds — the backend cannot produce sub-1ns bins. */ export const MIN_BIN_DURATION_NS = 10; @@ -61,10 +63,15 @@ export function getAdaptiveNumBins(): number { return MAX_TIMELINE_BINS; } -/** 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); +/** Threshold for "long" entities using the bin count returned by the timeline response. */ +export function getLongEntitiesThreshold( + windowSeconds: number, + numBins: number, + density: LongEntityDensity = 3 +): number { + return ( + LONG_ENTITY_DENSITY_MULTIPLIERS[density - LONG_ENTITY_DENSITIES[0]] * (windowSeconds / numBins) + ); } export function buildBinnedTimelineSeries( @@ -286,19 +293,6 @@ export function mergeOverlaySeries( return merged; } -/** Extract the resource_type_name from a TimelineRequest (empty string for Resource requests) */ -export function getResourceTypeName(params: TimelineRequest | undefined): string { - if (!params) return ''; - if ('ResourceGroup' in params) return params.ResourceGroup.resource_type_name; - return ''; -} - -/** Extract the entity_type_name (FSM filter) from a TimelineRequest */ -export function getFsmTypeName(params: TimelineRequest): string | null { - if ('ResourceGroup' in params) return params.ResourceGroup.entity_filter.entity_type_name; - return params.Resource.entity_filter.entity_type_name; -} - /** Clone entries and set operator_id on each TimelineRequest */ export function setOperatorOnEntry( entry: TimelineRequest, diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.test.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.test.tsx new file mode 100644 index 000000000..0626c5e70 --- /dev/null +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.test.tsx @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LongEntitiesGantt } from './LongEntitiesGantt'; +import type { LongEntityEntry } from './types'; + +const mocks = vi.hoisted(() => ({ + ganttChart: vi.fn(), +})); + +vi.mock('@quent/hooks', () => ({ + useZoomRange: () => ({ start: 0, end: 1 }), +})); + +vi.mock('../timeline/timelineEchartsTheme', () => ({ + MARK_AREA_BORDER_OPACITY: 0.8, + MARK_AREA_FILL_OPACITY: 0.2, + useTimelineEchartsTheme: () => ({ textColor: '#000000' }), +})); + +vi.mock('../gantt-chart/GanttChart', () => ({ + GanttChart: (props: { + animateHeight: boolean; + contentPaddingBottom: number; + emptyMessage: ReactNode; + gridSpacing: { bottom: number }; + maxHeight: number; + }) => { + mocks.ganttChart(props); + return
{props.emptyMessage}
; + }, +})); + +describe('LongEntitiesGantt', () => { + it('explains the active threshold when no entities match', () => { + render( + + ); + + expect(screen.getByText('No Matching Entities')).toBeInTheDocument(); + expect( + screen.getByText('Showing entities longer than 60.0ms. Zoom to see more.') + ).toBeInTheDocument(); + }); + + it('expands to fit all rows and collapses to the default height', () => { + const entries: LongEntityEntry[] = [ + { + entityId: 'entity-1', + label: 'Entity 1', + typeName: 'test', + startMs: 0, + endMs: 100, + rowIndex: 5, + segments: [ + { + stateName: 'running', + startMs: 0, + endMs: 100, + color: '#76b900', + }, + ], + }, + ]; + + render( + + ); + + expect(mocks.ganttChart).toHaveBeenLastCalledWith( + expect.objectContaining({ + animateHeight: true, + contentPaddingBottom: 12, + gridSpacing: expect.objectContaining({ bottom: 14.5 }), + maxHeight: 75, + }) + ); + + const expandButton = screen.getByRole('button', { name: 'Expand entities chart' }); + expect(expandButton).toHaveStyle({ right: '10px' }); + expect(expandButton).toHaveClass('focus-visible:ring-0', 'focus-visible:ring-offset-0'); + fireEvent.click(expandButton); + expect(mocks.ganttChart).toHaveBeenLastCalledWith(expect.objectContaining({ maxHeight: 96 })); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse entities chart' })); + expect(mocks.ganttChart).toHaveBeenLastCalledWith(expect.objectContaining({ maxHeight: 75 })); + }); +}); diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 958f4df67..651dd26fe 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { MARK_AREA_BORDER_OPACITY, @@ -9,7 +10,7 @@ import { useTimelineEchartsTheme, } from '../timeline/timelineEchartsTheme'; import { useZoomRange } from '@quent/hooks'; -import { withOpacity } from '@quent/utils'; +import { formatDuration, withOpacity } from '@quent/utils'; import type { LongEntityEntry } from './types'; import { GanttChart, type GanttRenderItem } from '../gantt-chart/GanttChart'; import type { GanttHover } from '../gantt-chart/hover'; @@ -17,6 +18,8 @@ import { clipRectByRect } from '../gantt-chart/utils'; import { getLongEntitySegmentsAtTimestamp } from './utils'; import { PointerTooltipPortal } from '../ui/pointer-tooltip-portal'; import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; +import { Button } from '../ui/button'; +import { TIMELINE_SPACING } from '../timeline/types'; export const LONG_ENTITIES_TIMELINE_HEIGHT = 75; const LABEL_FONT_SIZE = 9; @@ -24,6 +27,7 @@ const BAR_HEIGHT = LABEL_FONT_SIZE + 4; /** Vertical gap between stacked rows. */ const ROW_GAP = 1; const ROW_HEIGHT = BAR_HEIGHT + ROW_GAP; +const RESIZE_CONTROL_HEIGHT = 12; /** Radius applied only to the outer corners of each entity's segment run. */ const CORNER_RADIUS = 2; const SERIES_NAME = 'long-entity-segment'; @@ -38,6 +42,7 @@ type SegmentDatum = { export interface LongEntitiesGanttProps { entries: LongEntityEntry[]; durationSeconds: number; + minUsageSeconds: number; height?: number; /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; @@ -46,11 +51,22 @@ export interface LongEntitiesGanttProps { export function LongEntitiesGantt({ entries, durationSeconds, + minUsageSeconds, height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, }: LongEntitiesGanttProps) { const { textColor } = useTimelineEchartsTheme(isDark); const zoomRange = useZoomRange(); + const [isExpanded, setIsExpanded] = useState(false); + const rowCount = useMemo( + () => entries.reduce((count, entry) => Math.max(count, entry.rowIndex + 1), 0), + [entries] + ); + const canResize = rowCount * ROW_HEIGHT > height; + const resizeControlHeight = canResize ? RESIZE_CONTROL_HEIGHT : 0; + const contentHeight = useMemo(() => { + return Math.max(height, rowCount * ROW_HEIGHT + resizeControlHeight); + }, [height, resizeControlHeight, rowCount]); // One custom-series datum per segment, tagged with its parent entry/segment. const customSeriesData = useMemo(() => { const data: SegmentDatum[] = []; @@ -169,17 +185,50 @@ export function LongEntitiesGantt({ ); return ( - +
+ +
No Matching Entities
+
+ Showing entities longer than {formatDuration(minUsageSeconds * 1_000, 1)}. Zoom to see + more. +
+
+ } + renderTooltip={renderTooltip} + /> + {canResize && ( + + )} + ); } diff --git a/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.test.tsx b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.test.tsx new file mode 100644 index 000000000..6bb4e142e --- /dev/null +++ b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.test.tsx @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LongEntityDensity } from '@quent/hooks'; +import { TimelineSettingsPopover } from './TimelineSettingsPopover'; + +const mocks = vi.hoisted(() => ({ + density: 3 as LongEntityDensity, + setDensity: vi.fn(), +})); + +vi.mock('@quent/hooks', async importOriginal => ({ + ...(await importOriginal()), + useLongEntityDensity: () => mocks.density, + useSetLongEntityDensity: () => mocks.setDensity, +})); + +vi.mock('../ui/popover', () => ({ + Popover: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +describe('TimelineSettingsPopover', () => { + beforeEach(() => { + mocks.density = 3; + mocks.setDensity.mockClear(); + }); + + it('renders the five entity density snap points', () => { + render(); + + const slider = screen.getByRole('slider', { name: 'Entities' }); + expect(slider).toHaveAttribute('min', '1'); + expect(slider).toHaveAttribute('max', '5'); + expect(slider).toHaveAttribute('step', '1'); + expect(slider).toHaveValue('3'); + expect(screen.getByText('Less')).toBeInTheDocument(); + expect(screen.getByText('More')).toBeInTheDocument(); + }); + + it('updates the density when the slider moves', () => { + render(); + + fireEvent.change(screen.getByRole('slider', { name: 'Entities' }), { + target: { value: '5' }, + }); + + expect(mocks.setDensity).toHaveBeenCalledWith(5); + }); +}); diff --git a/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx index 410542bc1..7ef1af1be 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx @@ -2,9 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { Settings } from 'lucide-react'; +import { useId } from 'react'; +import { + LONG_ENTITY_DENSITIES, + useLongEntityDensity, + useSetLongEntityDensity, + type LongEntityDensity, +} from '@quent/hooks'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +const DENSITY_MIN = Math.min(...LONG_ENTITY_DENSITIES); +const DENSITY_MAX = Math.max(...LONG_ENTITY_DENSITIES); + +function isLongEntityDensity(value: number): value is LongEntityDensity { + return LONG_ENTITY_DENSITIES.some(density => density === value); +} + export function TimelineSettingsPopover() { + const density = useLongEntityDensity(); + const setDensity = useSetLongEntityDensity(); + const sliderId = useId(); + return ( @@ -17,7 +35,31 @@ export function TimelineSettingsPopover() { - No settings yet. + + + { + const nextDensity = Number(event.target.value); + if (isLongEntityDensity(nextDensity)) { + setDensity(nextDensity); + } + }} + className="mt-2 h-1.5 w-full cursor-pointer accent-primary" + /> +
+ Less + More +
+
); } diff --git a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx index 3255b2958..0bee53e03 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx @@ -4,8 +4,9 @@ import { Maximize2 } from 'lucide-react'; import { useSetZoomRange, useSetDebouncedZoomRange } from '@quent/hooks'; import { QueryToolbar } from './QueryToolbar'; +import { TimelineSettingsPopover } from './TimelineSettingsPopover'; -/** Toolbar for the timeline view: shows the active operator filter and zoom reset. */ +/** Toolbar for the timeline view: shows the active operator filter, zoom reset, and settings. */ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number }) { const setZoomRange = useSetZoomRange(); const setDebouncedZoomRange = useSetDebouncedZoomRange(); @@ -26,6 +27,8 @@ 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 c9d35197c..58af024f8 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.test.tsx @@ -12,6 +12,13 @@ const tagged = (v: object) => v as unknown as DynamicValue; describe('TooltipContent active marks', () => { const series = [{ color: '#8884d8', name: 'computing', value: 1 }]; + const makeMarks = (count: number): ActiveMark[] => + Array.from({ length: count }, (_, index) => ({ + label: `task-${index}`, + stateName: 'computing', + color: '#ff0000', + durationMs: 500, + })); const renderWithMarks = (marks: ActiveMark[]) => render(); @@ -89,4 +96,20 @@ describe('TooltipContent active marks', () => { expect(screen.getByText('loading')).toBeInTheDocument(); expect(screen.queryByText('Total')).not.toBeInTheDocument(); }); + + it('keeps full details for six or fewer overlapping entities', () => { + render(); + + expect(screen.getAllByText('500.00ms')).toHaveLength(6); + }); + + it('caps detailed rows and reports entities not shown', () => { + render(); + + expect(screen.getByText('task-0')).toBeInTheDocument(); + expect(screen.getByText('task-5')).toBeInTheDocument(); + expect(screen.queryByText('task-6')).not.toBeInTheDocument(); + expect(screen.getAllByText('500.00ms')).toHaveLength(6); + expect(screen.getByText('1 more entity not shown')).toBeInTheDocument(); + }); }); diff --git a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx index 95124f963..b52b82a58 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx @@ -199,11 +199,16 @@ function MarkDetailRow({ name, value }: { name: string; value: string }) { ); } +const ACTIVE_MARK_LIMIT = 6; + function ActiveMarksSection({ marks }: { marks: ActiveMark[] }) { if (marks.length === 0) return null; + const visibleMarks = marks.slice(0, ACTIVE_MARK_LIMIT); + const hiddenCount = marks.length - visibleMarks.length; + return (
- {marks.map((m, i) => ( + {visibleMarks.map((m, i) => (
@@ -236,6 +241,11 @@ function ActiveMarksSection({ marks }: { marks: ActiveMark[] }) { )}
))} + {hiddenCount > 0 && ( + + {hiddenCount} more {hiddenCount === 1 ? 'entity' : 'entities'} not shown + + )}
); } @@ -347,7 +357,7 @@ export function EntityTooltipContent({ activeMarks: ActiveMark[]; }) { return ( -
+
{formatDurationForWindow(timestamp, windowMs)} diff --git a/ui/packages/@quent/hooks/src/atoms/timeline.ts b/ui/packages/@quent/hooks/src/atoms/timeline.ts index 64233020d..c2f0b6bfa 100644 --- a/ui/packages/@quent/hooks/src/atoms/timeline.ts +++ b/ui/packages/@quent/hooks/src/atoms/timeline.ts @@ -42,6 +42,12 @@ export const zoomRangeAtom = atom({ start: 0, end: 0 }); /** Debounced zoom range — settles after ZOOM_DEBOUNCE_MS, drives the bulk query */ export const debouncedZoomRangeAtom = atom({ start: 0, end: 0 }); +export const LONG_ENTITY_DENSITIES = [1, 2, 3, 4, 5] as const; +export type LongEntityDensity = (typeof LONG_ENTITY_DENSITIES)[number]; + +/** Controls the minimum usage threshold for entities shown in timeline rows. */ +export const longEntityDensityAtom = atom(3); + /** * Pointer-level hover state used to drive an app-rendered timeline tooltip. * diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index 641607c31..2ca90b049 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -17,10 +17,14 @@ export { useHoveredWorkerId, useSetHoveredWorkerId } from './dag/useHoveredWorke // Timeline hooks export { useTimelineData, + useReturnedTimelineNumBins, + useReturnedTimelineIsStale, useZoomRange, useSetZoomRange, useDebouncedZoomRange, useSetDebouncedZoomRange, + useLongEntityDensity, + useSetLongEntityDensity, useTimelineHover, useSetTimelineHover, useStartTimeMs, @@ -33,8 +37,8 @@ export { } from './timeline/useTimelineAtoms'; // Timeline cache key helpers (consumers need these to address per-item data) -export { timelineCacheKey } from './atoms/timeline'; -export type { TimelineCacheParams, TimelineHoverState } from './atoms/timeline'; +export { LONG_ENTITY_DENSITIES, timelineCacheKey } from './atoms/timeline'; +export type { LongEntityDensity, TimelineCacheParams, TimelineHoverState } from './atoms/timeline'; export { bulkEntryId } from './timeline/timeline.utils'; // Complex timeline hooks diff --git a/ui/packages/@quent/hooks/src/timeline/timeline.utils.ts b/ui/packages/@quent/hooks/src/timeline/timeline.utils.ts index de278d2ef..c08b78d61 100644 --- a/ui/packages/@quent/hooks/src/timeline/timeline.utils.ts +++ b/ui/packages/@quent/hooks/src/timeline/timeline.utils.ts @@ -2,19 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { TimelineRequest, OperatorFilter } from '@quent/utils'; - -/** Extract the resource_type_name from a TimelineRequest (empty string for Resource requests) */ -export function getResourceTypeName(params: TimelineRequest | undefined): string { - if (!params) return ''; - if ('ResourceGroup' in params) return params.ResourceGroup.resource_type_name; - return ''; -} - -/** Extract the entity_type_name (FSM filter) from a TimelineRequest */ -export function getFsmTypeName(params: TimelineRequest): string | null { - if ('ResourceGroup' in params) return params.ResourceGroup.entity_filter.entity_type_name; - return params.Resource.entity_filter.entity_type_name; -} +export { getFsmTypeName, getResourceTypeName } from '@quent/utils'; /** Stable request-entry key for bulk timeline fetches. Omit operatorId for the base variant. */ export function bulkEntryId(resourceId: string, operatorId?: string | null): string { diff --git a/ui/packages/@quent/hooks/src/timeline/useBulkTimelineFetch.ts b/ui/packages/@quent/hooks/src/timeline/useBulkTimelineFetch.ts index 7fb9c92c3..a48d58d4a 100644 --- a/ui/packages/@quent/hooks/src/timeline/useBulkTimelineFetch.ts +++ b/ui/packages/@quent/hooks/src/timeline/useBulkTimelineFetch.ts @@ -18,7 +18,7 @@ import { setOperatorOnEntry, bulkEntryId, } from './timeline.utils'; -import { timelineCacheKey, timelineDataMapAtom } from '../atoms/timeline'; +import { bulkInitializedAtom, timelineCacheKey, timelineDataMapAtom } from '../atoms/timeline'; /** * Mirrors TimelineCacheParams so meta can be passed directly to timelineCacheKey. @@ -128,7 +128,7 @@ export function useBulkTimelineFetch({ requestKey, } = useMemo(() => buildMergedBulkEntries(entries, operatorId), [entries, operatorId]); - const { data } = useQuery({ + const { data, isFetched } = useQuery({ queryKey: ['bulkTimelines', engineId, queryId, debouncedZoomRange, requestKey], queryFn: () => fetchBulkTimelines(engineId, { @@ -145,5 +145,9 @@ export function useBulkTimelineFetch({ applyBulkTimelineResponse(data, idToMeta, store); }, [data, store, idToMeta]); + useEffect(() => { + if (isFetched) store.set(bulkInitializedAtom, true); + }, [isFetched, store]); + return data; } diff --git a/ui/packages/@quent/hooks/src/timeline/useBulkTimelines.ts b/ui/packages/@quent/hooks/src/timeline/useBulkTimelines.ts index 1dbf1717a..dc139de99 100644 --- a/ui/packages/@quent/hooks/src/timeline/useBulkTimelines.ts +++ b/ui/packages/@quent/hooks/src/timeline/useBulkTimelines.ts @@ -13,7 +13,6 @@ import { timelineDataMapAtom, zoomRangeAtom, debouncedZoomRangeAtom, - bulkInitializedAtom, visibleEntriesAtom, } from '../atoms/timeline'; import { selectedNodeIdsAtom } from '../atoms/dag'; @@ -124,7 +123,7 @@ export function useBulkTimelines({ store.set(visibleEntriesAtom, baseVisibleEntries); }, [baseVisibleEntries, store]); - const bulkData = useBulkTimelineFetch({ + useBulkTimelineFetch({ engineId, queryId, debouncedZoomRange, @@ -132,12 +131,6 @@ export function useBulkTimelines({ operatorId, }); - useEffect(() => { - if (bulkData) { - store.set(bulkInitializedAtom, true); - } - }, [bulkData, store]); - // Zoom change handler — stable, uses store imperatively const handleZoomChange = useCallback( (range: ZoomRange) => { diff --git a/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.test.tsx b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.test.tsx new file mode 100644 index 000000000..3497fc6a7 --- /dev/null +++ b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.test.tsx @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropsWithChildren } from 'react'; +import { renderHook } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { describe, expect, it } from 'vitest'; +import type { OperatorFilter, SingleTimelineResponse, TimelineRequest } from '@quent/utils'; +import { + debouncedZoomRangeAtom, + timelineCacheKey, + timelineDataMapAtom, + visibleEntriesAtom, +} from '../atoms/timeline'; +import { useReturnedTimelineIsStale, useReturnedTimelineNumBins } from './useTimelineAtoms'; + +describe('useReturnedTimelineNumBins', () => { + it('reads the returned bin count for the visible resource request', () => { + const store = createStore(); + const request: TimelineRequest = { + Resource: { + resource_id: 'resource-1', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: 'fsm-1' }, + application: { operator_ids: [] }, + config: { num_bins: 200, start: 0, end: 1 }, + }, + }; + const response: SingleTimelineResponse = { + config: { + span: { start: -0.001, end: 1.001 }, + bin_duration: 0.0025, + num_bins: 400n, + }, + data: {} as SingleTimelineResponse['data'], + }; + const cacheKey = timelineCacheKey({ + resourceId: 'resource-1', + resourceTypeName: '', + fsmTypeName: 'fsm-1', + }); + store.set(visibleEntriesAtom, { 'resource-1': request }); + store.set(timelineDataMapAtom, { [cacheKey]: response }); + store.set(debouncedZoomRangeAtom, { start: 0, end: 1 }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook(() => useReturnedTimelineNumBins('resource-1'), { wrapper }); + + expect(result.current).toBe(400); + }); + + it('returns undefined when no response is cached', () => { + const store = createStore(); + const request: TimelineRequest = { + Resource: { + resource_id: 'resource-1', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: 'fsm-1' }, + application: { operator_ids: [] }, + config: { num_bins: 200, start: 0, end: 1 }, + }, + }; + store.set(visibleEntriesAtom, { 'resource-1': request }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook(() => useReturnedTimelineNumBins('resource-1'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('returns undefined while the cached response belongs to the previous viewport', () => { + const store = createStore(); + const request: TimelineRequest = { + Resource: { + resource_id: 'resource-1', + long_entities_threshold_s: null, + entity_filter: { entity_type_name: 'fsm-1' }, + application: { operator_ids: [] }, + config: { num_bins: 200, start: 0.25, end: 1 }, + }, + }; + const cacheKey = timelineCacheKey({ + resourceId: 'resource-1', + resourceTypeName: '', + fsmTypeName: 'fsm-1', + }); + const response: SingleTimelineResponse = { + config: { + span: { start: 0, end: 1 }, + bin_duration: 0.0025, + num_bins: 400n, + }, + data: {} as SingleTimelineResponse['data'], + }; + store.set(visibleEntriesAtom, { 'resource-1': request }); + store.set(timelineDataMapAtom, { [cacheKey]: response }); + store.set(debouncedZoomRangeAtom, { start: 0.25, end: 1 }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook( + () => ({ + numBins: useReturnedTimelineNumBins('resource-1'), + isStale: useReturnedTimelineIsStale('resource-1'), + }), + { wrapper } + ); + + expect(result.current).toEqual({ numBins: undefined, isStale: true }); + }); +}); diff --git a/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts index f79336d0d..051596467 100644 --- a/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts +++ b/ui/packages/@quent/hooks/src/timeline/useTimelineAtoms.ts @@ -11,8 +11,15 @@ import { startTimeMsAtom, bulkInitializedAtom, visibleEntriesAtom, + longEntityDensityAtom, + timelineCacheKey, } from '../atoms/timeline'; -import type { ZoomRange, SingleTimelineResponse } from '@quent/utils'; +import { + getFsmTypeName, + getResourceTypeName, + type ZoomRange, + type SingleTimelineResponse, +} from '@quent/utils'; // Record-based replacement for atomFamily(timelineDataAtom(key)) export function useTimelineData(key: string): SingleTimelineResponse | undefined { @@ -20,10 +27,45 @@ export function useTimelineData(key: string): SingleTimelineResponse | undefined return map[key]; } +function useReturnedTimelineState(resourceId: string): { + data: SingleTimelineResponse | undefined; + isStale: boolean; +} { + const timelineDataMap = useAtomValue(timelineDataMapAtom); + const visibleEntries = useAtomValue(visibleEntriesAtom); + const activeSpan = useAtomValue(debouncedZoomRangeAtom); + const request = visibleEntries[resourceId]; + if (!request) return { data: undefined, isStale: false }; + const key = timelineCacheKey({ + resourceId, + resourceTypeName: getResourceTypeName(request), + fsmTypeName: getFsmTypeName(request), + }); + const data = timelineDataMap[key]; + if (!data) return { data: undefined, isStale: false }; + const tolerance = data.config.bin_duration; + const matchesActiveSpan = + Math.abs(data.config.span.start - activeSpan.start) <= tolerance && + Math.abs(data.config.span.end - activeSpan.end) <= tolerance; + return matchesActiveSpan ? { data, isStale: false } : { data: undefined, isStale: true }; +} + +export function useReturnedTimelineNumBins(resourceId: string): number | undefined { + const { data } = useReturnedTimelineState(resourceId); + const numBins = Number(data?.config.num_bins); + return Number.isInteger(numBins) && numBins > 0 ? numBins : undefined; +} + +export function useReturnedTimelineIsStale(resourceId: string): boolean { + return useReturnedTimelineState(resourceId).isStale; +} + export const useZoomRange = () => useAtomValue(zoomRangeAtom); export const useSetZoomRange = () => useSetAtom(zoomRangeAtom); export const useDebouncedZoomRange = () => useAtomValue(debouncedZoomRangeAtom); export const useSetDebouncedZoomRange = () => useSetAtom(debouncedZoomRangeAtom); +export const useLongEntityDensity = () => useAtomValue(longEntityDensityAtom); +export const useSetLongEntityDensity = () => useSetAtom(longEntityDensityAtom); export const useTimelineHover = () => useAtomValue(timelineHoverAtom); export const useSetTimelineHover = () => useSetAtom(timelineHoverAtom); export const useStartTimeMs = () => useAtomValue(startTimeMsAtom); diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 0d210c06c..672124165 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -4,6 +4,7 @@ // Utilities export { cn } from './cn'; export { parseJsonWithBigInt } from './parseJsonWithBigInt'; +export { getFsmTypeName, getResourceTypeName } from './timeline'; // Color utilities export { diff --git a/ui/packages/@quent/utils/src/timeline.ts b/ui/packages/@quent/utils/src/timeline.ts new file mode 100644 index 000000000..473851d78 --- /dev/null +++ b/ui/packages/@quent/utils/src/timeline.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OperatorFilter, TimelineRequest } from './types'; + +export function getResourceTypeName(request: TimelineRequest | undefined): string { + if (!request || !('ResourceGroup' in request)) return ''; + return request.ResourceGroup.resource_type_name; +} + +export function getFsmTypeName(request: TimelineRequest): string | null { + return 'ResourceGroup' in request + ? request.ResourceGroup.entity_filter.entity_type_name + : request.Resource.entity_filter.entity_type_name; +} diff --git a/ui/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx index 2088218fc..c8a266aa3 100644 --- a/ui/src/components/LongEntitiesRow.test.tsx +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -4,22 +4,37 @@ import type { ButtonHTMLAttributes, HTMLAttributes } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LongEntityDensity } from '@quent/hooks'; +import { MAX_TIMELINE_BINS } from '@quent/utils'; import { LongEntitiesRow } from './LongEntitiesRow'; const mocks = vi.hoisted(() => ({ + bulkInitialized: true, 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(), + debouncedZoomRange: { start: 0.2, end: 0.6 }, + getLongEntitiesThreshold: vi.fn( + (_windowSeconds: number, _numBins: number, density: LongEntityDensity) => + ({ 1: 0.15, 2: 0.12, 3: 0.09, 4: 0.06, 5: 0.03 })[density] + ), + longEntityDensity: 3 as LongEntityDensity, + returnedNumBins: 400 as number | undefined, + returnedTimelineIsStale: false, + longEntitiesGantt: vi.fn( + (_props: { entries: unknown[]; height: number; minUsageSeconds: number }) => null + ), + useEntityList: vi.fn(), })); vi.mock('@quent/client', () => ({ - useInfiniteEntityList: mocks.useInfiniteEntityList, + useEntityList: mocks.useEntityList, })); vi.mock('@quent/hooks', () => ({ - useDebouncedZoomRange: () => ({ start: 0.2, end: 0.6 }), + useBulkInitialized: () => mocks.bulkInitialized, + useDebouncedZoomRange: () => mocks.debouncedZoomRange, + useLongEntityDensity: () => mocks.longEntityDensity, + useReturnedTimelineIsStale: () => mocks.returnedTimelineIsStale, + useReturnedTimelineNumBins: () => mocks.returnedNumBins, useSelectedNodeIds: () => new Set(['operator-1']), })); @@ -28,7 +43,7 @@ vi.mock('@quent/components', () => ({ ), LONG_ENTITIES_TIMELINE_HEIGHT: 110, - LongEntitiesGantt: (props: { entries: unknown[]; height: number }) => { + LongEntitiesGantt: (props: { entries: unknown[]; height: number; minUsageSeconds: number }) => { mocks.longEntitiesGantt(props); return
; }, @@ -40,12 +55,14 @@ vi.mock('@quent/components', () => ({ describe('LongEntitiesRow', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.useInfiniteEntityList.mockReturnValue({ + mocks.bulkInitialized = true; + mocks.debouncedZoomRange = { start: 0.2, end: 0.6 }; + mocks.longEntityDensity = 3; + mocks.returnedNumBins = 400; + mocks.returnedTimelineIsStale = false; + mocks.useEntityList.mockReturnValue({ data: undefined, - fetchNextPage: mocks.fetchNextPage, - hasNextPage: false, isFetching: false, - isFetchingNextPage: false, isPlaceholderData: false, }); }); @@ -63,17 +80,118 @@ describe('LongEntitiesRow', () => { ); expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[0]).toBeCloseTo(0.4); - expect(mocks.useInfiniteEntityList).toHaveBeenCalledWith( + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[1]).toBe(400); + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[2]).toBe(3); + expect(mocks.useEntityList).toHaveBeenCalledWith( expect.objectContaining({ window: { start: 0.2, end: 0.6 }, operatorIds: ['operator-1'], - minUsageSeconds: 0.06, + minUsageSeconds: 0.09, maxItems: 100, - }) + }), + { enabled: true } ); expect(mocks.longEntitiesGantt.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ height: 110 }) + expect.objectContaining({ height: 110, minUsageSeconds: 0.09 }) + ); + }); + + it('uses the selected entity density in the query threshold', () => { + mocks.longEntityDensity = 1; + + render( + + ); + + expect(mocks.useEntityList).toHaveBeenCalledWith( + expect.objectContaining({ minUsageSeconds: 0.15 }), + { enabled: true } + ); + }); + + it('falls back to twice the maximum bin count when the timeline request fails', () => { + mocks.returnedNumBins = undefined; + + render( + ); + + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[0]).toBeCloseTo(0.4); + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.slice(1)).toEqual([ + MAX_TIMELINE_BINS * 2, + 3, + ]); + expect(mocks.useEntityList).toHaveBeenCalledWith( + expect.objectContaining({ minUsageSeconds: 0.09 }), + { enabled: true } + ); + expect(screen.getByTestId('long-entities-gantt')).toBeInTheDocument(); + }); + + it('waits for the timeline request before using the fallback', () => { + mocks.bulkInitialized = false; + mocks.returnedNumBins = undefined; + + render( + + ); + + expect(mocks.getLongEntitiesThreshold).not.toHaveBeenCalled(); + expect(mocks.useEntityList).toHaveBeenCalledWith( + expect.objectContaining({ minUsageSeconds: null }), + { enabled: false } + ); + expect(screen.getByRole('status', { name: 'Loading entities' })).toBeInTheDocument(); + }); + + it('keeps the previous chart visible while a new viewport timeline loads', () => { + const previousEntity = { id: 'entity-1' }; + mocks.useEntityList.mockReturnValue({ + data: { items: [previousEntity], total: 1 }, + isFetching: false, + isPlaceholderData: false, + }); + const props = { + engineId: 'engine-1', + queryId: 'query-1', + resourceId: 'resource-1', + durationSeconds: 1, + fsmTypes: {}, + isDark: false, + }; + const { rerender } = render(); + + mocks.returnedNumBins = undefined; + mocks.returnedTimelineIsStale = true; + rerender(); + + expect(mocks.useEntityList).toHaveBeenLastCalledWith( + expect.objectContaining({ minUsageSeconds: null }), + { enabled: false } + ); + expect(screen.queryByRole('status', { name: 'Loading entities' })).not.toBeInTheDocument(); + expect(screen.getByTestId('long-entities-gantt')).toBeInTheDocument(); }); it('can limit FSM states to those used on the associated resource', () => { @@ -98,12 +216,9 @@ describe('LongEntitiesRow', () => { }); it('renders a chart-shaped skeleton during the initial load', () => { - mocks.useInfiniteEntityList.mockReturnValue({ + mocks.useEntityList.mockReturnValue({ data: undefined, - fetchNextPage: mocks.fetchNextPage, - hasNextPage: false, isFetching: true, - isFetchingNextPage: false, isPlaceholderData: false, }); @@ -123,15 +238,12 @@ describe('LongEntitiesRow', () => { expect(screen.queryByText('Loading entities…')).not.toBeInTheDocument(); }); - it('loads the next page and appends its entities', () => { + it('increases the entity limit and keeps it across viewport changes', () => { const firstEntity = { id: 'entity-1' }; const secondEntity = { id: 'entity-2' }; - mocks.useInfiniteEntityList.mockReturnValue({ - data: { pages: [{ items: [firstEntity], total: 2 }] }, - fetchNextPage: mocks.fetchNextPage, - hasNextPage: true, + mocks.useEntityList.mockReturnValue({ + data: { items: [firstEntity], total: 2 }, isFetching: false, - isFetchingNextPage: false, isPlaceholderData: false, }); @@ -148,23 +260,26 @@ describe('LongEntitiesRow', () => { 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, + expect(mocks.useEntityList).toHaveBeenLastCalledWith( + expect.objectContaining({ maxItems: 200 }), + { enabled: true } + ); + + mocks.debouncedZoomRange = { start: 0.3, end: 0.7 }; + mocks.useEntityList.mockReturnValue({ + data: { items: [firstEntity, secondEntity], total: 2 }, isFetching: false, - isFetchingNextPage: false, isPlaceholderData: false, }); rerender(); + expect(mocks.useEntityList).toHaveBeenLastCalledWith( + expect.objectContaining({ + window: { start: 0.3, end: 0.7 }, + maxItems: 200, + }), + { enabled: true } + ); expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( [firstEntity, secondEntity], {}, @@ -174,14 +289,49 @@ describe('LongEntitiesRow', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it('keeps a loading button when more entities will remain', () => { + const firstEntity = { id: 'entity-1' }; + mocks.useEntityList.mockReturnValue({ + data: { items: [firstEntity], total: 250 }, + isFetching: false, + isPlaceholderData: false, + }); + + const props = { + engineId: 'engine-1', + queryId: 'query-1', + resourceId: 'resource-1', + durationSeconds: 1, + fsmTypes: {}, + isDark: false, + }; + const { rerender } = render(); + + mocks.useEntityList.mockReturnValue({ + data: { items: [firstEntity], total: 250 }, + isFetching: true, + isPlaceholderData: true, + }); + fireEvent.click(screen.getByRole('button', { name: 'Show more (1 of 250)' })); + + expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled(); + + const secondEntity = { id: 'entity-2' }; + mocks.useEntityList.mockReturnValue({ + data: { items: [firstEntity, secondEntity], total: 250 }, + isFetching: false, + isPlaceholderData: false, + }); + rerender(); + + expect(screen.getByRole('button', { name: 'Show more (2 of 250)' })).toBeEnabled(); + }); + 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, + mocks.useEntityList.mockReturnValue({ + data: { items: [previousEntity], total: 2 }, isFetching: true, - isFetchingNextPage: false, isPlaceholderData: true, }); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index afa714d5f..528fc1999 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -1,10 +1,17 @@ // 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 { useMemo, useRef, useState } from 'react'; +import { useEntityList } from '@quent/client'; +import { + useBulkInitialized, + useDebouncedZoomRange, + useLongEntityDensity, + useReturnedTimelineIsStale, + useReturnedTimelineNumBins, + useSelectedNodeIds, +} from '@quent/hooks'; +import { type FsmTypeDecl, MAX_TIMELINE_BINS } from '@quent/utils'; import { Button, LONG_ENTITIES_TIMELINE_HEIGHT, @@ -44,14 +51,31 @@ export function LongEntitiesRow({ }: LongEntitiesRowProps) { const selectedNodeIds = useSelectedNodeIds(); const debouncedZoomRange = useDebouncedZoomRange(); + const bulkInitialized = useBulkInitialized(); + const longEntityDensity = useLongEntityDensity(); + const returnedNumBins = useReturnedTimelineNumBins(resourceId); + const returnedTimelineIsStale = useReturnedTimelineIsStale(resourceId); + const previousMinUsageSeconds = useRef(null); + const [maxEntities, setMaxEntities] = useState(ENTITIES_PER_PAGE); 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( + const defaultNumBins = MAX_TIMELINE_BINS * 2; + const initializedAndNoBins = !returnedTimelineIsStale && bulkInitialized; + const numBins = returnedNumBins ?? (initializedAndNoBins ? defaultNumBins : undefined); + + // Retain the rendered threshold while the next viewport loads. + const minUsageSeconds = + numBins == null + ? null + : getLongEntitiesThreshold(zoomWindow.end - zoomWindow.start, numBins, longEntityDensity); + if (minUsageSeconds != null) previousMinUsageSeconds.current = minUsageSeconds; + const displayedMinUsageSeconds = minUsageSeconds ?? previousMinUsageSeconds.current; + + const { data, isFetching, isPlaceholderData } = useEntityList( { engineId, queryId, @@ -59,12 +83,13 @@ export function LongEntitiesRow({ operatorIds, minUsageSeconds, sortDir: 'Desc', - maxItems: ENTITIES_PER_PAGE, + maxItems: maxEntities, filter: { scope: { Resource: { resource_id: resourceId } } }, - } + }, + { enabled: numBins != null } ); - const entities = useMemo(() => data?.pages.flatMap(page => page.items) ?? [], [data]); + const entities = useMemo(() => data?.items ?? [], [data]); const entries = useMemo( () => buildLongEntityEntries( @@ -75,9 +100,12 @@ export function LongEntitiesRow({ ), [entities, fsmStateScope, fsmTypes, isDark, resourceId] ); - const totalEntities = data?.pages[data.pages.length - 1]?.total ?? entities.length; + const totalEntities = data?.total ?? entities.length; + const hasMoreEntities = entities.length < totalEntities; + const isLoadingMore = isPlaceholderData && entities.length < maxEntities; + const showMoreButton = hasMoreEntities && (!isLoadingMore || maxEntities < totalEntities); - if (!data && isFetching) { + if (displayedMinUsageSeconds == null || (!data && isFetching)) { return (
- {hasNextPage && !isPlaceholderData && ( + {showMoreButton && (
)}