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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions ui/packages/@quent/client/src/entityList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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();
});
});
35 changes: 1 addition & 34 deletions ui/packages/@quent/client/src/entityList.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
4 changes: 2 additions & 2 deletions ui/packages/@quent/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
18 changes: 15 additions & 3 deletions ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ export interface GanttChartProps<T extends GanttDatum> {
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;
}

Expand All @@ -62,6 +64,8 @@ export function GanttChart<T extends GanttDatum>({
cursor,
onEvents,
gridSpacing,
contentPaddingBottom = 0,
animateHeight = false,
renderTooltip,
}: GanttChartProps<T>) {
const { themeName } = useTimelineEchartsTheme(isDark);
Expand All @@ -79,7 +83,7 @@ export function GanttChart<T extends GanttDatum>({
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(
Expand Down Expand Up @@ -141,7 +145,15 @@ export function GanttChart<T extends GanttDatum>({

return (
<>
<HiddenScroll ref={wrapperRef} className="relative" style={{ height: wrapperHeight }}>
<HiddenScroll
ref={wrapperRef}
className={
animateHeight
? 'relative transition-[height] duration-150 ease-out motion-reduce:transition-none'
: 'relative'
}
style={{ height: wrapperHeight }}
>
<EChartsReactCore
echarts={echarts}
theme={themeName}
Expand Down
3 changes: 1 addition & 2 deletions ui/packages/@quent/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,8 @@ export {
buildTimelineMarks,
collectVisibleEntries,
getAdaptiveNumBins,
getFsmTypeName,
getLongEntitiesThreshold,
getLongFsms,
getResourceTypeName,
getTimelineConfig,
getTimelineXAxisIntervalMs,
mergeOverlaySeries,
Expand All @@ -130,6 +128,7 @@ export {
transformResourceTree,
} from './lib/timeline.utils';
export type { AxisPointerSyncOptions } from './lib/timeline.utils';
export { getFsmTypeName, getResourceTypeName } from '@quent/utils';

// ─── Services – query-plan ────────────────────────────────────────────────────
export {
Expand Down
24 changes: 19 additions & 5 deletions ui/packages/@quent/components/src/lib/timeline.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,31 @@ describe('nanosToMs', () => {
// ---- 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);
});
});

Expand Down
30 changes: 12 additions & 18 deletions ui/packages/@quent/components/src/lib/timeline.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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<OperatorFilter> | 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<OperatorFilter>): 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<OperatorFilter>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <div>{props.emptyMessage}</div>;
},
}));

describe('LongEntitiesGantt', () => {
it('explains the active threshold when no entities match', () => {
render(
<LongEntitiesGantt entries={[]} durationSeconds={1} minUsageSeconds={0.06} isDark={false} />
);

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(
<LongEntitiesGantt
entries={entries}
durationSeconds={1}
minUsageSeconds={0.06}
isDark={false}
/>
);

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