From 721ca08c7a5efca072e304cb1329b44892ca6e26 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Thu, 16 Jul 2026 13:50:50 +0200 Subject: [PATCH 01/75] feat(ui): add entity list explorer Co-authored-by: Codex --- crates/ui/src/entities/response.rs | 11 +- domains/query_engine/analyzer/src/entities.rs | 13 +- .../tests/fixed/tests/list_entities.rs | 3 +- .../server/ts-bindings/EntityListItem.ts | 7 + .../server/ts-bindings/EntityListResponse.ts | 4 +- ui/packages/@quent/client/src/api.ts | 14 + ui/packages/@quent/client/src/entities.ts | 33 + ui/packages/@quent/client/src/index.ts | 3 + ui/packages/@quent/utils/src/types/index.ts | 11 + .../entities-table/EntitiesTable.test.tsx | 186 +++++ .../entities-table/EntitiesTable.tsx | 703 ++++++++++++++++++ ...gine.$engineId.query.$queryId.entities.tsx | 16 + ...rofile.engine.$engineId.query.$queryId.tsx | 8 + 13 files changed, 1004 insertions(+), 8 deletions(-) create mode 100644 examples/simulator/server/ts-bindings/EntityListItem.ts create mode 100644 ui/packages/@quent/client/src/entities.ts create mode 100644 ui/src/components/entities-table/EntitiesTable.test.tsx create mode 100644 ui/src/components/entities-table/EntitiesTable.tsx create mode 100644 ui/src/routes/profile.engine.$engineId.query.$queryId.entities.tsx diff --git a/crates/ui/src/entities/response.rs b/crates/ui/src/entities/response.rs index 57d318a6e..5d0b01521 100644 --- a/crates/ui/src/entities/response.rs +++ b/crates/ui/src/entities/response.rs @@ -4,14 +4,23 @@ use serde::Serialize; use ts_rs::TS; +use quent_time::TimeSec; + use crate::FiniteStateMachine; +/// An entity and its longest matching resource usage. +#[derive(TS, Debug, Clone, Serialize)] +pub struct EntityListItem { + pub entity: FiniteStateMachine, + pub usage_duration_s: TimeSec, +} + /// A ranked, paged list of entities. #[derive(TS, Debug, Clone, Serialize)] pub struct EntityListResponse { // TODO(johanpel): generalize to other entity types, but only FSMs are // represented today. - pub items: Vec, + pub items: Vec, /// The count of entities matching the filter before paging. pub total: u32, } diff --git a/domains/query_engine/analyzer/src/entities.rs b/domains/query_engine/analyzer/src/entities.rs index f351cba34..d4f24268b 100644 --- a/domains/query_engine/analyzer/src/entities.rs +++ b/domains/query_engine/analyzer/src/entities.rs @@ -10,12 +10,12 @@ use quent_analyzer::{ fsm::{FsmUsages, collection::FsmCollection}, resource::Usage, }; -use quent_time::{TimeNanoSec, TimeUnixNanoSec, span::SpanUnixNanoSec, to_nanosecs}; +use quent_time::{TimeNanoSec, TimeUnixNanoSec, span::SpanUnixNanoSec, to_nanosecs, to_secs}; use quent_ui::{ FiniteStateMachine, entities::{ request::{EntityListFilter, EntitySortKey, Sort, SortDir}, - response::EntityListResponse, + response::{EntityListItem, EntityListResponse}, }, paginate::PageParams, }; @@ -111,8 +111,13 @@ where }; let items = page_iter - .map(|(f, _)| FiniteStateMachine::try_from_fsm(f, epoch)) - .collect::, _>>()?; + .map(|(f, usage_duration)| { + FiniteStateMachine::try_from_fsm(f, epoch).map(|entity| EntityListItem { + usage_duration_s: to_secs(usage_duration), + entity, + }) + }) + .collect::, quent_time::TimeError>>()?; Ok(EntityListResponse { items, total }) } diff --git a/domains/query_engine/tests/fixed/tests/list_entities.rs b/domains/query_engine/tests/fixed/tests/list_entities.rs index 5aa1deb7d..b2e5b400e 100644 --- a/domains/query_engine/tests/fixed/tests/list_entities.rs +++ b/domains/query_engine/tests/fixed/tests/list_entities.rs @@ -131,7 +131,7 @@ fn request( } fn ids(resp: &EntityListResponse) -> Vec { - resp.items.iter().map(|fsm| fsm.id).collect() + resp.items.iter().map(|item| item.entity.id).collect() } #[test] @@ -143,6 +143,7 @@ fn lists_all_tasks_on_a_resource_ranked_by_uuid_tiebreak() { assert_eq!(resp.total, 8); assert_eq!(ids(&resp), MEMORY_W0_TASKS); + assert!(resp.items.iter().all(|item| item.usage_duration_s == 0.75)); } #[test] diff --git a/examples/simulator/server/ts-bindings/EntityListItem.ts b/examples/simulator/server/ts-bindings/EntityListItem.ts new file mode 100644 index 000000000..8b0a25102 --- /dev/null +++ b/examples/simulator/server/ts-bindings/EntityListItem.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FiniteStateMachine } from "./FiniteStateMachine"; + +/** + * An entity and its longest matching resource usage. + */ +export type EntityListItem = { entity: FiniteStateMachine, usage_duration_s: number, }; diff --git a/examples/simulator/server/ts-bindings/EntityListResponse.ts b/examples/simulator/server/ts-bindings/EntityListResponse.ts index 3c24d2572..a1a7b0040 100644 --- a/examples/simulator/server/ts-bindings/EntityListResponse.ts +++ b/examples/simulator/server/ts-bindings/EntityListResponse.ts @@ -1,10 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FiniteStateMachine } from "./FiniteStateMachine"; +import type { EntityListItem } from "./EntityListItem"; /** * A ranked, paged list of entities. */ -export type EntityListResponse = { items: Array, +export type EntityListResponse = { items: Array, /** * The count of entities matching the filter before paging. */ diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 9306010a7..8844ea769 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -15,6 +15,8 @@ import type { OperatorFilter, EntityRef, Engine, + EntityListRequest, + EntityListResponse, } from '@quent/utils'; interface ApiFetchOptions { @@ -104,3 +106,15 @@ export async function fetchBulkTimelines( }, }); } + +export async function fetchEntities( + engineId: string, + request: EntityListRequest +): Promise { + return apiFetch(`/engines/${engineId}/entities`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); +} diff --git a/ui/packages/@quent/client/src/entities.ts b/ui/packages/@quent/client/src/entities.ts new file mode 100644 index 000000000..64c91a82f --- /dev/null +++ b/ui/packages/@quent/client/src/entities.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; +import type { EntityListRequest, QueryFilter, OperatorFilter } from '@quent/utils'; +import { fetchEntities } from './api'; +import { DEFAULT_STALE_TIME } from './constants'; + +interface EntitiesParams { + engineId: string; + request: EntityListRequest; +} + +interface EntitiesOptions { + staleTime?: number; + enabled?: boolean; +} + +export const entitiesQueryOptions = ( + { engineId, request }: EntitiesParams, + options?: EntitiesOptions +) => + queryOptions({ + queryKey: ['entities', engineId, request], + queryFn: () => fetchEntities(engineId, request), + staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, + enabled: options?.enabled, + // Keep the current page visible while the next page/filter result loads. + placeholderData: keepPreviousData, + }); + +export const useEntities = (params: EntitiesParams, options?: EntitiesOptions) => + useQuery(entitiesQueryOptions(params, options)); diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index 937b8538f..8f571d74a 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -13,6 +13,7 @@ export { fetchListQueries, fetchSingleTimeline, fetchBulkTimelines, + fetchEntities, } from './api'; // queryOptions factories @@ -22,6 +23,7 @@ export { queryGroupsQueryOptions } from './queryGroups'; export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; +export { entitiesQueryOptions } from './entities'; // Hooks export { useQueryBundle } from './queryBundle'; @@ -29,3 +31,4 @@ export { useEngines } from './engines'; export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; +export { useEntities } from './entities'; diff --git a/ui/packages/@quent/utils/src/types/index.ts b/ui/packages/@quent/utils/src/types/index.ts index c2f46d225..086dd9249 100644 --- a/ui/packages/@quent/utils/src/types/index.ts +++ b/ui/packages/@quent/utils/src/types/index.ts @@ -12,7 +12,14 @@ export type { Edge } from '../../../../../../examples/simulator/server/ts-bindin export type { Engine } from '../../../../../../examples/simulator/server/ts-bindings/Engine'; export type { EngineImplementationAttributes } from '../../../../../../examples/simulator/server/ts-bindings/EngineImplementationAttributes'; export type { EntityFilter } from '../../../../../../examples/simulator/server/ts-bindings/EntityFilter'; +export type { EntityListEntry } from '../../../../../../examples/simulator/server/ts-bindings/EntityListEntry'; +export type { EntityListFilter } from '../../../../../../examples/simulator/server/ts-bindings/EntityListFilter'; +export type { EntityListItem } from '../../../../../../examples/simulator/server/ts-bindings/EntityListItem'; +export type { EntityListRequest } from '../../../../../../examples/simulator/server/ts-bindings/EntityListRequest'; +export type { EntityListResponse } from '../../../../../../examples/simulator/server/ts-bindings/EntityListResponse'; export type { EntityRef } from '../../../../../../examples/simulator/server/ts-bindings/EntityRef'; +export type { EntityScope } from '../../../../../../examples/simulator/server/ts-bindings/EntityScope'; +export type { EntitySortKey } from '../../../../../../examples/simulator/server/ts-bindings/EntitySortKey'; export type { FiniteStateMachine } from '../../../../../../examples/simulator/server/ts-bindings/FiniteStateMachine'; export type { FsmStateTypeDecl } from '../../../../../../examples/simulator/server/ts-bindings/FsmStateTypeDecl'; export type { FsmTransition } from '../../../../../../examples/simulator/server/ts-bindings/FsmTransition'; @@ -23,6 +30,7 @@ export type { List } from '../../../../../../examples/simulator/server/ts-bindin export type { Operator } from '../../../../../../examples/simulator/server/ts-bindings/Operator'; export type { OperatorFilter } from '../../../../../../examples/simulator/server/ts-bindings/OperatorFilter'; export type { OperatorStatistics } from '../../../../../../examples/simulator/server/ts-bindings/OperatorStatistics'; +export type { PageParams } from '../../../../../../examples/simulator/server/ts-bindings/PageParams'; export type { Plan } from '../../../../../../examples/simulator/server/ts-bindings/Plan'; export type { PlanTree } from '../../../../../../examples/simulator/server/ts-bindings/PlanTree'; export type { Port } from '../../../../../../examples/simulator/server/ts-bindings/Port'; @@ -47,9 +55,12 @@ export type { ResourceTree } from '../../../../../../examples/simulator/server/t export type { ResourceTypeDecl } from '../../../../../../examples/simulator/server/ts-bindings/ResourceTypeDecl'; export type { SingleTimelineRequest } from '../../../../../../examples/simulator/server/ts-bindings/SingleTimelineRequest'; export type { SingleTimelineResponse } from '../../../../../../examples/simulator/server/ts-bindings/SingleTimelineResponse'; +export type { Sort } from '../../../../../../examples/simulator/server/ts-bindings/Sort'; +export type { SortDir } from '../../../../../../examples/simulator/server/ts-bindings/SortDir'; export type { SpanSec } from '../../../../../../examples/simulator/server/ts-bindings/SpanSec'; export type { Struct } from '../../../../../../examples/simulator/server/ts-bindings/Struct'; export type { TimelineConfig } from '../../../../../../examples/simulator/server/ts-bindings/TimelineConfig'; export type { TimelineRequest } from '../../../../../../examples/simulator/server/ts-bindings/TimelineRequest'; +export type { TimeWindow } from '../../../../../../examples/simulator/server/ts-bindings/TimeWindow'; export type { Value } from '../../../../../../examples/simulator/server/ts-bindings/Value'; export type { Worker } from '../../../../../../examples/simulator/server/ts-bindings/Worker'; diff --git a/ui/src/components/entities-table/EntitiesTable.test.tsx b/ui/src/components/entities-table/EntitiesTable.test.tsx new file mode 100644 index 000000000..ac4b36734 --- /dev/null +++ b/ui/src/components/entities-table/EntitiesTable.test.tsx @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore, Provider } from 'jotai'; +import { useSetSelectedNodeIds } from '@quent/hooks'; +import type { EntityRef, QueryBundle } from '@quent/utils'; +import { EntitiesTable } from './EntitiesTable'; + +const useEntities = vi.fn(); + +vi.mock('@quent/client', () => ({ + useEntities: (...args: unknown[]) => useEntities(...args), +})); + +const queryBundle = { + query_id: 'query-1', + duration_s: 10, + entities: { + operators: { + 'operator-1': { + id: 'operator-1', + instance_name: 'Operator One', + operator_type_name: 'Scan', + }, + }, + resources: { + 'resource-1': { + id: 'resource-1', + instance_name: 'GPU 1', + type_name: 'GPU', + }, + }, + fsm_types: { Task: {} }, + }, +} as unknown as QueryBundle; + +const fsm = { + id: 'entity-1', + type_name: 'Task', + instance_name: 'Entity 1', + transitions: [ + { + name: 'running', + timestamp: 0, + usages: [], + attributes: [], + derived_attributes: [], + }, + { + name: 'finished', + timestamp: 1, + usages: [], + attributes: [], + derived_attributes: [], + }, + ], +}; + +function DagSelectionControl() { + const setSelectedNodeIds = useSetSelectedNodeIds(); + return ( + + ); +} + +describe('EntitiesTable', () => { + beforeEach(() => { + vi.useFakeTimers(); + useEntities.mockReset(); + useEntities.mockReturnValue({ + data: { items: [{ entity: fsm, usage_duration_s: 0.75 }], total: 1 }, + isLoading: false, + isFetching: false, + isError: false, + error: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('normalizes page size before sending it to the API', () => { + render(); + + fireEvent.change(screen.getByLabelText('Page size'), { target: { value: '1.5' } }); + act(() => vi.advanceTimersByTime(300)); + + const params = useEntities.mock.lastCall?.[0]; + expect(params.request.entry.page.max).toBe(1); + }); + + it('clears selected entity details when a filter changes', () => { + render(); + + fireEvent.click(screen.getByText('Entity 1')); + expect(screen.getByText('running')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Min usage (s)'), { target: { value: '0.5' } }); + + expect(screen.queryByText('running')).not.toBeInTheDocument(); + expect(screen.getByText('Select an entity to view its states.')).toBeInTheDocument(); + }); + + it('preserves selected entity details for page-size changes', () => { + render(); + + fireEvent.click(screen.getByText('Entity 1')); + fireEvent.change(screen.getByLabelText('Page size'), { target: { value: '100' } }); + + expect(screen.getByText('running')).toBeInTheDocument(); + }); + + it('blocks invalid time windows before fetching', () => { + render(); + + fireEvent.change(screen.getByLabelText('Window start (s)'), { target: { value: '11' } }); + + expect(screen.getByRole('alert')).toHaveTextContent('Window start must not exceed window end.'); + expect(useEntities.mock.lastCall?.[1]).toEqual({ enabled: false }); + }); + + it('shows usage and state durations and supports keyboard row selection', () => { + render(); + + expect(screen.getByText('Longest usage')).toBeInTheDocument(); + expect(screen.getByText('750.00ms')).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole('row', { name: /Entity 1/ }), { key: 'Enter' }); + + expect(screen.getByText(/for 1.00s/)).toBeInTheDocument(); + }); + + it('searches operators and resets active filters', () => { + render(); + + fireEvent.click(screen.getByRole('combobox', { name: 'Operator' })); + fireEvent.change(screen.getByLabelText('Search operator'), { target: { value: 'one' } }); + fireEvent.click(screen.getByRole('option', { name: 'Operator One' })); + + expect(screen.getByText('1 active filter')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Reset filters' })); + act(() => vi.advanceTimersByTime(300)); + + const params = useEntities.mock.lastCall?.[0]; + expect(params.request.entry.application.operator_id).toBeNull(); + }); + + it('dims existing rows while replacement data is pending', () => { + useEntities.mockReturnValue({ + data: { items: [{ entity: fsm, usage_duration_s: 0.75 }], total: 1 }, + isLoading: false, + isFetching: true, + isError: false, + error: null, + }); + + render(); + + const tableContainer = screen.getByRole('table').closest('[aria-busy]'); + expect(tableContainer).toHaveAttribute('aria-busy', 'true'); + expect(tableContainer).toHaveClass('opacity-60'); + expect(screen.getByText('Entity 1')).toBeInTheDocument(); + }); + + it('uses the selected DAG operator as the entity filter', () => { + const store = createStore(); + render( + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Select DAG operator' })); + act(() => vi.advanceTimersByTime(300)); + + const params = useEntities.mock.lastCall?.[0]; + expect(params.request.entry.application.operator_id).toBe('operator-1'); + expect(screen.getByRole('combobox', { name: 'Operator' })).toHaveTextContent('Operator One'); + }); +}); diff --git a/ui/src/components/entities-table/EntitiesTable.tsx b/ui/src/components/entities-table/EntitiesTable.tsx new file mode 100644 index 000000000..87263af2f --- /dev/null +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -0,0 +1,703 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import { + Check, + ChevronFirst, + ChevronLast, + ChevronsUpDown, + LoaderCircle, + RotateCcw, +} from 'lucide-react'; +import { useEntities } from '@quent/client'; +import { useSelectedNodeIds } from '@quent/hooks'; +import { + Button, + Input, + Popover, + PopoverContent, + PopoverTrigger, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + SelectField, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@quent/components'; +import type { SelectFieldOption } from '@quent/components'; +import { formatAttributeValue, formatDuration } from '@quent/utils'; +import type { + Attribute, + EntityListRequest, + FiniteStateMachine, + QueryBundle, + QueryFilter, + OperatorFilter, + EntityRef, + SortDir, +} from '@quent/utils'; + +interface EntitiesTableProps { + engineId: string; + queryId: string; + queryBundle: QueryBundle; +} + +/** Filter state mirroring what `POST /engines/{id}/entities` accepts. */ +interface Filters { + operatorId: string | null; + entityType: string | null; + resourceId: string | null; + minUsageS: string; + windowStart: string; + windowEnd: string; + sortDir: SortDir; + pageSize: number | null; +} + +const SORT_DIR_OPTIONS: SelectFieldOption[] = [ + { value: 'Desc', label: 'Longest resource usage first' }, + { value: 'Asc', label: 'Shortest resource usage first' }, +]; + +const FILTER_DEBOUNCE_MS = 300; +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 500; + +/** First and last transition timestamps (seconds relative to the query epoch). */ +function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } { + let start = Infinity; + let end = -Infinity; + for (const t of fsm.transitions) { + if (t.timestamp < start) start = t.timestamp; + if (t.timestamp > end) end = t.timestamp; + } + return fsm.transitions.length === 0 ? { start: 0, end: 0 } : { start, end }; +} + +/** Parse a numeric text field, returning null when empty or invalid. */ +function parseOptionalNumber(value: string): number | null { + if (value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizePageSize(value: number | null): number { + if (value === null || !Number.isFinite(value)) return DEFAULT_PAGE_SIZE; + return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value))); +} + +function defaultFilters(durationS: number): Filters { + return { + operatorId: null, + entityType: null, + resourceId: null, + minUsageS: '', + windowStart: '0', + windowEnd: String(durationS), + sortDir: 'Desc', + pageSize: DEFAULT_PAGE_SIZE, + }; +} + +/** Value that only updates after `ms` of stability, keeping fast inputs off the fetch key. */ +function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value); + // Key on the serialization: `value` is a fresh object each render, so a + // referential dep would reset the timer every render. + const key = JSON.stringify(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, ms]); + return debounced; +} + +interface NumberFieldProps { + label: string; + value: string; + width: string; + min?: number; + onChange: (value: string) => void; +} + +function NumberField({ label, value, width, min, onChange }: NumberFieldProps) { + return ( + + ); +} + +interface PageSizeFieldProps { + value: number | null; + onChange: (value: number | null) => void; +} + +function PageSizeField({ value, onChange }: PageSizeFieldProps) { + return ( + + ); +} + +interface SearchableSelectProps { + label: string; + value: string | null; + options: SelectFieldOption[]; + placeholder: string; + onValueChange: (value: string | null) => void; +} + +function SearchableSelect({ + label, + value, + options, + placeholder, + onValueChange, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const selected = options.find(option => option.value === value); + const normalizedSearch = search.trim().toLocaleLowerCase(); + const filteredOptions = options.filter(option => + `${option.label ?? option.value} ${option.value}`.toLocaleLowerCase().includes(normalizedSearch) + ); + + const select = (nextValue: string | null) => { + onValueChange(nextValue); + setOpen(false); + setSearch(''); + }; + + return ( + + ); +} + +/** Key/value rows for a state's recorded or derived attributes. */ +function AttributeRows({ attrs, derived }: { attrs: Attribute[]; derived?: boolean }) { + return ( +
    + {attrs.map((a, k) => ( +
  • + {a.key} + {formatAttributeValue(a.key, a.value)} +
  • + ))} +
+ ); +} + +interface EntityDetailPanelProps { + fsm: FiniteStateMachine | null; + /** Resolve a resource ID to a human-readable label. */ + resourceLabel: (id: string) => string; +} + +/** Right-hand detail panel listing the state sequence of the selected FSM. */ +function EntityDetailPanel({ fsm, resourceLabel }: EntityDetailPanelProps) { + if (!fsm) { + return ( +
+ Select an entity to view its states. +
+ ); + } + return ( +
+
+
{fsm.instance_name}
+
{fsm.type_name}
+
{fsm.id}
+
+
    + {fsm.transitions.map((t, i) => ( +
  1. +
    + + {i + 1}. {t.name} + + + {t.timestamp.toFixed(3)}s + {fsm.transitions[i + 1] && ( + <> + {' '} + · for {formatDuration((fsm.transitions[i + 1].timestamp - t.timestamp) * 1000)} + + )} + +
    + {t.usages.length > 0 && ( +
      + {t.usages.map((u, j) => ( +
    • + {resourceLabel(u.resource)} + {u.capacities.map(([name, capacity], k) => ( + + {name} + {capacity != null ? `=${capacity}` : ''} + + ))} +
    • + ))} +
    + )} + {t.attributes.length > 0 && } + {t.derived_attributes.length > 0 && ( + + )} +
  2. + ))} +
+
+ ); +} + +export function EntitiesTable({ engineId, queryId, queryBundle }: EntitiesTableProps) { + const { entities, duration_s } = queryBundle; + const defaults = defaultFilters(duration_s); + const selectedNodeIds = useSelectedNodeIds(); + const dagOperatorId = selectedNodeIds.values().next().value ?? null; + + const [filters, setFilters] = useState(() => ({ + ...defaultFilters(duration_s), + operatorId: dagOperatorId, + })); + const [page, setPage] = useState(0); + // The selected FSM shown in the detail panel; kept by value so it survives paging. + const [selected, setSelected] = useState(null); + + useEffect(() => { + setFilters(prev => + prev.operatorId === dagOperatorId ? prev : { ...prev, operatorId: dagOperatorId } + ); + setPage(0); + setSelected(null); + }, [dagOperatorId]); + + // Changing any filter invalidates the current page offset. + const updateFilters = (patch: Partial, clearSelection = true) => { + setFilters(prev => ({ ...prev, ...patch })); + setPage(0); + if (clearSelection) setSelected(null); + }; + + const resetFilters = () => { + setFilters(defaults); + setPage(0); + setSelected(null); + }; + + const resourceLabel = (id: string) => { + const r = entities.resources[id]; + return r ? `${r.instance_name} (${r.type_name})` : id; + }; + + const operatorOptions = useMemo( + () => + Object.values(entities.operators) + .map(op => ({ + value: op.id, + label: op.instance_name ?? op.operator_type_name ?? op.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entities.operators] + ); + + const entityTypeOptions = useMemo( + () => + Object.keys(entities.fsm_types) + .sort() + .map(name => ({ value: name })), + [entities.fsm_types] + ); + + const resourceOptions = useMemo( + () => + Object.values(entities.resources) + .map(r => ({ + value: r.id, + label: `${r.instance_name} (${r.type_name})`, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entities.resources] + ); + + const pageSize = normalizePageSize(filters.pageSize); + const windowStart = parseOptionalNumber(filters.windowStart); + const windowEnd = parseOptionalNumber(filters.windowEnd); + const minUsageS = parseOptionalNumber(filters.minUsageS); + const validationErrors: string[] = []; + if (filters.windowStart.trim() !== '' && windowStart === null) + validationErrors.push('Window start must be a number.'); + if (filters.windowEnd.trim() !== '' && windowEnd === null) + validationErrors.push('Window end must be a number.'); + if (filters.minUsageS.trim() !== '' && minUsageS === null) + validationErrors.push('Minimum usage must be a number.'); + if (windowStart !== null && windowStart < 0) + validationErrors.push('Window start cannot be negative.'); + if (windowEnd !== null && windowEnd < 0) validationErrors.push('Window end cannot be negative.'); + if (windowStart !== null && windowEnd !== null && windowStart > windowEnd) { + validationErrors.push('Window start must not exceed window end.'); + } + if (minUsageS !== null && minUsageS < 0) + validationErrors.push('Minimum usage cannot be negative.'); + + const activeFilterCount = [ + filters.operatorId !== null, + filters.entityType !== null, + filters.resourceId !== null, + filters.minUsageS !== '', + filters.windowStart !== defaults.windowStart, + filters.windowEnd !== defaults.windowEnd, + ].filter(Boolean).length; + const hasNonDefaultSettings = + activeFilterCount > 0 || + filters.sortDir !== defaults.sortDir || + filters.pageSize !== defaults.pageSize; + + const request: EntityListRequest = { + entry: { + window: { + start: windowStart ?? 0, + end: windowEnd ?? duration_s, + }, + filter: { + scope: filters.resourceId ? { Resource: { resource_id: filters.resourceId } } : null, + entity_type_name: filters.entityType, + min_usage_s: minUsageS, + }, + sort: { key: 'UsageDuration', dir: filters.sortDir }, + page: { max: pageSize, page }, + application: { operator_id: filters.operatorId }, + }, + app_params: { query_id: queryId }, + }; + + // Debounce so typing in the numeric inputs does not fire a request per keystroke. + const debouncedRequest = useDebounced(request, FILTER_DEBOUNCE_MS); + const { data, isLoading, isFetching, isError, error } = useEntities( + { engineId, request: debouncedRequest }, + { enabled: validationErrors.length === 0 } + ); + const requestPending = isFetching || JSON.stringify(request) !== JSON.stringify(debouncedRequest); + + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const rows = useMemo( + () => + (data?.items ?? []).map(item => ({ + fsm: item.entity, + usageDurationS: item.usage_duration_s, + ...fsmSpan(item.entity), + })), + [data?.items] + ); + const visibleStart = total === 0 ? 0 : page * pageSize + 1; + const visibleEnd = total === 0 ? 0 : Math.min(total, visibleStart + rows.length - 1); + const paginationDisabled = requestPending || validationErrors.length > 0; + + return ( + + +
+
+ updateFilters({ operatorId: v })} + /> + updateFilters({ entityType: v })} + /> + updateFilters({ resourceId: v })} + /> + updateFilters({ minUsageS: v })} + /> + updateFilters({ windowStart: v })} + /> + updateFilters({ windowEnd: v })} + /> + + updateFilters({ sortDir: (v as SortDir | null) ?? 'Desc' }, false) + } + /> + updateFilters({ pageSize: v }, false)} + /> + + {activeFilterCount > 0 && ( + + {activeFilterCount} active {activeFilterCount === 1 ? 'filter' : 'filters'} + + )} + {requestPending && validationErrors.length === 0 && ( + + + Updating… + + )} + {validationErrors.length > 0 && ( +
+ {validationErrors.join(' ')} +
+ )} +
+ +
0 ? 'opacity-60' : 'opacity-100' + }`} + > + {isError ? ( +
+ Failed to load entities: {error instanceof Error ? error.message : 'unknown error'} +
+ ) : ( + + + + Instance + Type + States + Start + End + FSM span + Longest usage + ID + + + + {rows.map(({ fsm, start, end, usageDurationS }) => ( + setSelected(fsm)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelected(fsm); + } + }} + > + {fsm.instance_name} + {fsm.type_name} + + {fsm.transitions.length} + + {start.toFixed(3)}s + {end.toFixed(3)}s + + {formatDuration((end - start) * 1000)} + + + {formatDuration(usageDurationS * 1000)} + + + {fsm.id} + + + ))} + +
+ )} + {!isError && !isLoading && validationErrors.length === 0 && rows.length === 0 && ( +
+ No entities match the filters. +
+ )} + {isLoading &&
Loading…
} +
+ +
+ + {visibleStart}–{visibleEnd} of {total} {total === 1 ? 'entity' : 'entities'} + +
+ + + + Page {page + 1} / {pageCount} + + + +
+
+
+
+ + + + +
+ ); +} diff --git a/ui/src/routes/profile.engine.$engineId.query.$queryId.entities.tsx b/ui/src/routes/profile.engine.$engineId.query.$queryId.entities.tsx new file mode 100644 index 000000000..7ee452fd0 --- /dev/null +++ b/ui/src/routes/profile.engine.$engineId.query.$queryId.entities.tsx @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createFileRoute } from '@tanstack/react-router'; +import { EntitiesTable } from '@/components/entities-table/EntitiesTable'; +import { Route as QueryRoute } from './profile.engine.$engineId.query.$queryId'; + +export const Route = createFileRoute('/profile/engine/$engineId/query/$queryId/entities')({ + component: EntitiesTab, +}); + +function EntitiesTab() { + const { engineId, queryId } = Route.useParams(); + const queryBundle = QueryRoute.useLoaderData(); + return ; +} diff --git a/ui/src/routes/profile.engine.$engineId.query.$queryId.tsx b/ui/src/routes/profile.engine.$engineId.query.$queryId.tsx index bb32e5c99..480e00eae 100644 --- a/ui/src/routes/profile.engine.$engineId.query.$queryId.tsx +++ b/ui/src/routes/profile.engine.$engineId.query.$queryId.tsx @@ -47,6 +47,14 @@ function QueryLayout() { > Operators + + Entities +
From 01f23fad4eb5a2e3ce86c424eb9f8f956ae65133 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Thu, 16 Jul 2026 15:02:23 +0200 Subject: [PATCH 02/75] refactor(ui): split entity list components Co-authored-by: Codex --- ui/packages/@quent/components/src/index.ts | 2 + .../components/src/ui/searchable-select.tsx | 138 ++++ .../entities-table/EntitiesTable.tsx | 720 +----------------- .../entities-table/EntitiesToolbar.tsx | 191 +++++ .../entities-table/EntityDetailPanel.tsx | 90 +++ .../entities-table/EntityResults.tsx | 185 +++++ ui/src/components/entities-table/types.ts | 21 + .../entities-table/useEntityTable.ts | 162 ++++ ui/src/components/entities-table/utils.ts | 135 ++++ ui/src/hooks/useDebouncedValue.ts | 15 + 10 files changed, 979 insertions(+), 680 deletions(-) create mode 100644 ui/packages/@quent/components/src/ui/searchable-select.tsx create mode 100644 ui/src/components/entities-table/EntitiesToolbar.tsx create mode 100644 ui/src/components/entities-table/EntityDetailPanel.tsx create mode 100644 ui/src/components/entities-table/EntityResults.tsx create mode 100644 ui/src/components/entities-table/types.ts create mode 100644 ui/src/components/entities-table/useEntityTable.ts create mode 100644 ui/src/components/entities-table/utils.ts create mode 100644 ui/src/hooks/useDebouncedValue.ts diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 6180718b8..7e0b84cf8 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -65,6 +65,8 @@ export { } from './ui/select'; export { SelectField } from './ui/select-field'; export type { SelectFieldProps, SelectFieldOption } from './ui/select-field'; +export { SearchableSelect } from './ui/searchable-select'; +export type { SearchableSelectProps } from './ui/searchable-select'; export { Skeleton } from './ui/skeleton'; export { TreeView } from './ui/tree-view'; export type { TreeDataItem } from './ui/tree-view'; diff --git a/ui/packages/@quent/components/src/ui/searchable-select.tsx b/ui/packages/@quent/components/src/ui/searchable-select.tsx new file mode 100644 index 000000000..ac87c61da --- /dev/null +++ b/ui/packages/@quent/components/src/ui/searchable-select.tsx @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useState } from 'react'; +import { Check, ChevronDown, Search } from 'lucide-react'; +import { cn } from '@quent/utils'; +import { Button } from './button'; +import { Input } from './input'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import type { SelectFieldOption } from './select-field'; + +export interface SearchableSelectProps { + label: string; + options: SelectFieldOption[]; + value: string | null; + onValueChange: (value: string | null) => void; + placeholder: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + triggerClassName?: string; +} + +export function SearchableSelect({ + label, + options, + value, + onValueChange, + placeholder, + searchPlaceholder = `Search ${label.toLowerCase()}…`, + emptyMessage = 'No matches.', + className, + triggerClassName, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const selected = options.find(option => option.value === value); + const filteredOptions = useMemo(() => { + const needle = search.trim().toLowerCase(); + if (!needle) return options; + return options.filter(option => + `${option.label ?? option.value} ${option.value}`.toLowerCase().includes(needle) + ); + }, [options, search]); + + const select = (nextValue: string | null) => { + onValueChange(nextValue); + setOpen(false); + setSearch(''); + }; + + return ( +
+ {label} + { + setOpen(nextOpen); + if (!nextOpen) setSearch(''); + }} + > + + + + +
+ + setSearch(event.target.value)} + placeholder={searchPlaceholder} + aria-label={`Search ${label.toLowerCase()}`} + className="h-7 pl-7 pr-2 text-xs md:text-xs" + /> +
+
+
+
+
+
+ ); +} + +function Option({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/entities-table/EntitiesTable.tsx b/ui/src/components/entities-table/EntitiesTable.tsx index 87263af2f..260ad7ca2 100644 --- a/ui/src/components/entities-table/EntitiesTable.tsx +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -1,46 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useMemo, useState } from 'react'; -import { - Check, - ChevronFirst, - ChevronLast, - ChevronsUpDown, - LoaderCircle, - RotateCcw, -} from 'lucide-react'; -import { useEntities } from '@quent/client'; -import { useSelectedNodeIds } from '@quent/hooks'; -import { - Button, - Input, - Popover, - PopoverContent, - PopoverTrigger, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, - SelectField, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@quent/components'; -import type { SelectFieldOption } from '@quent/components'; -import { formatAttributeValue, formatDuration } from '@quent/utils'; -import type { - Attribute, - EntityListRequest, - FiniteStateMachine, - QueryBundle, - QueryFilter, - OperatorFilter, - EntityRef, - SortDir, -} from '@quent/utils'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@quent/components'; +import type { EntityRef, QueryBundle } from '@quent/utils'; +import { EntityDetailPanel } from './EntityDetailPanel'; +import { EntityResults } from './EntityResults'; +import { EntitiesToolbar } from './EntitiesToolbar'; +import { useEntityTable } from './useEntityTable'; interface EntitiesTableProps { engineId: string; @@ -48,655 +14,49 @@ interface EntitiesTableProps { queryBundle: QueryBundle; } -/** Filter state mirroring what `POST /engines/{id}/entities` accepts. */ -interface Filters { - operatorId: string | null; - entityType: string | null; - resourceId: string | null; - minUsageS: string; - windowStart: string; - windowEnd: string; - sortDir: SortDir; - pageSize: number | null; -} - -const SORT_DIR_OPTIONS: SelectFieldOption[] = [ - { value: 'Desc', label: 'Longest resource usage first' }, - { value: 'Asc', label: 'Shortest resource usage first' }, -]; - -const FILTER_DEBOUNCE_MS = 300; -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 500; - -/** First and last transition timestamps (seconds relative to the query epoch). */ -function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } { - let start = Infinity; - let end = -Infinity; - for (const t of fsm.transitions) { - if (t.timestamp < start) start = t.timestamp; - if (t.timestamp > end) end = t.timestamp; - } - return fsm.transitions.length === 0 ? { start: 0, end: 0 } : { start, end }; -} - -/** Parse a numeric text field, returning null when empty or invalid. */ -function parseOptionalNumber(value: string): number | null { - if (value.trim() === '') return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function normalizePageSize(value: number | null): number { - if (value === null || !Number.isFinite(value)) return DEFAULT_PAGE_SIZE; - return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value))); -} - -function defaultFilters(durationS: number): Filters { - return { - operatorId: null, - entityType: null, - resourceId: null, - minUsageS: '', - windowStart: '0', - windowEnd: String(durationS), - sortDir: 'Desc', - pageSize: DEFAULT_PAGE_SIZE, - }; -} - -/** Value that only updates after `ms` of stability, keeping fast inputs off the fetch key. */ -function useDebounced(value: T, ms: number): T { - const [debounced, setDebounced] = useState(value); - // Key on the serialization: `value` is a fresh object each render, so a - // referential dep would reset the timer every render. - const key = JSON.stringify(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), ms); - return () => clearTimeout(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, ms]); - return debounced; -} - -interface NumberFieldProps { - label: string; - value: string; - width: string; - min?: number; - onChange: (value: string) => void; -} - -function NumberField({ label, value, width, min, onChange }: NumberFieldProps) { - return ( - - ); -} - -interface PageSizeFieldProps { - value: number | null; - onChange: (value: number | null) => void; -} - -function PageSizeField({ value, onChange }: PageSizeFieldProps) { - return ( - - ); -} - -interface SearchableSelectProps { - label: string; - value: string | null; - options: SelectFieldOption[]; - placeholder: string; - onValueChange: (value: string | null) => void; -} - -function SearchableSelect({ - label, - value, - options, - placeholder, - onValueChange, -}: SearchableSelectProps) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const selected = options.find(option => option.value === value); - const normalizedSearch = search.trim().toLocaleLowerCase(); - const filteredOptions = options.filter(option => - `${option.label ?? option.value} ${option.value}`.toLocaleLowerCase().includes(normalizedSearch) - ); - - const select = (nextValue: string | null) => { - onValueChange(nextValue); - setOpen(false); - setSearch(''); - }; - - return ( - - ); -} - -/** Key/value rows for a state's recorded or derived attributes. */ -function AttributeRows({ attrs, derived }: { attrs: Attribute[]; derived?: boolean }) { - return ( -
    - {attrs.map((a, k) => ( -
  • - {a.key} - {formatAttributeValue(a.key, a.value)} -
  • - ))} -
- ); -} - -interface EntityDetailPanelProps { - fsm: FiniteStateMachine | null; - /** Resolve a resource ID to a human-readable label. */ - resourceLabel: (id: string) => string; -} - -/** Right-hand detail panel listing the state sequence of the selected FSM. */ -function EntityDetailPanel({ fsm, resourceLabel }: EntityDetailPanelProps) { - if (!fsm) { - return ( -
- Select an entity to view its states. -
- ); - } - return ( -
-
-
{fsm.instance_name}
-
{fsm.type_name}
-
{fsm.id}
-
-
    - {fsm.transitions.map((t, i) => ( -
  1. -
    - - {i + 1}. {t.name} - - - {t.timestamp.toFixed(3)}s - {fsm.transitions[i + 1] && ( - <> - {' '} - · for {formatDuration((fsm.transitions[i + 1].timestamp - t.timestamp) * 1000)} - - )} - -
    - {t.usages.length > 0 && ( -
      - {t.usages.map((u, j) => ( -
    • - {resourceLabel(u.resource)} - {u.capacities.map(([name, capacity], k) => ( - - {name} - {capacity != null ? `=${capacity}` : ''} - - ))} -
    • - ))} -
    - )} - {t.attributes.length > 0 && } - {t.derived_attributes.length > 0 && ( - - )} -
  2. - ))} -
-
- ); -} - -export function EntitiesTable({ engineId, queryId, queryBundle }: EntitiesTableProps) { - const { entities, duration_s } = queryBundle; - const defaults = defaultFilters(duration_s); - const selectedNodeIds = useSelectedNodeIds(); - const dagOperatorId = selectedNodeIds.values().next().value ?? null; - - const [filters, setFilters] = useState(() => ({ - ...defaultFilters(duration_s), - operatorId: dagOperatorId, - })); - const [page, setPage] = useState(0); - // The selected FSM shown in the detail panel; kept by value so it survives paging. - const [selected, setSelected] = useState(null); - - useEffect(() => { - setFilters(prev => - prev.operatorId === dagOperatorId ? prev : { ...prev, operatorId: dagOperatorId } - ); - setPage(0); - setSelected(null); - }, [dagOperatorId]); - - // Changing any filter invalidates the current page offset. - const updateFilters = (patch: Partial, clearSelection = true) => { - setFilters(prev => ({ ...prev, ...patch })); - setPage(0); - if (clearSelection) setSelected(null); - }; - - const resetFilters = () => { - setFilters(defaults); - setPage(0); - setSelected(null); - }; - - const resourceLabel = (id: string) => { - const r = entities.resources[id]; - return r ? `${r.instance_name} (${r.type_name})` : id; - }; - - const operatorOptions = useMemo( - () => - Object.values(entities.operators) - .map(op => ({ - value: op.id, - label: op.instance_name ?? op.operator_type_name ?? op.id, - })) - .sort((a, b) => a.label.localeCompare(b.label)), - [entities.operators] - ); - - const entityTypeOptions = useMemo( - () => - Object.keys(entities.fsm_types) - .sort() - .map(name => ({ value: name })), - [entities.fsm_types] - ); - - const resourceOptions = useMemo( - () => - Object.values(entities.resources) - .map(r => ({ - value: r.id, - label: `${r.instance_name} (${r.type_name})`, - })) - .sort((a, b) => a.label.localeCompare(b.label)), - [entities.resources] - ); - - const pageSize = normalizePageSize(filters.pageSize); - const windowStart = parseOptionalNumber(filters.windowStart); - const windowEnd = parseOptionalNumber(filters.windowEnd); - const minUsageS = parseOptionalNumber(filters.minUsageS); - const validationErrors: string[] = []; - if (filters.windowStart.trim() !== '' && windowStart === null) - validationErrors.push('Window start must be a number.'); - if (filters.windowEnd.trim() !== '' && windowEnd === null) - validationErrors.push('Window end must be a number.'); - if (filters.minUsageS.trim() !== '' && minUsageS === null) - validationErrors.push('Minimum usage must be a number.'); - if (windowStart !== null && windowStart < 0) - validationErrors.push('Window start cannot be negative.'); - if (windowEnd !== null && windowEnd < 0) validationErrors.push('Window end cannot be negative.'); - if (windowStart !== null && windowEnd !== null && windowStart > windowEnd) { - validationErrors.push('Window start must not exceed window end.'); - } - if (minUsageS !== null && minUsageS < 0) - validationErrors.push('Minimum usage cannot be negative.'); - - const activeFilterCount = [ - filters.operatorId !== null, - filters.entityType !== null, - filters.resourceId !== null, - filters.minUsageS !== '', - filters.windowStart !== defaults.windowStart, - filters.windowEnd !== defaults.windowEnd, - ].filter(Boolean).length; - const hasNonDefaultSettings = - activeFilterCount > 0 || - filters.sortDir !== defaults.sortDir || - filters.pageSize !== defaults.pageSize; - - const request: EntityListRequest = { - entry: { - window: { - start: windowStart ?? 0, - end: windowEnd ?? duration_s, - }, - filter: { - scope: filters.resourceId ? { Resource: { resource_id: filters.resourceId } } : null, - entity_type_name: filters.entityType, - min_usage_s: minUsageS, - }, - sort: { key: 'UsageDuration', dir: filters.sortDir }, - page: { max: pageSize, page }, - application: { operator_id: filters.operatorId }, - }, - app_params: { query_id: queryId }, - }; - - // Debounce so typing in the numeric inputs does not fire a request per keystroke. - const debouncedRequest = useDebounced(request, FILTER_DEBOUNCE_MS); - const { data, isLoading, isFetching, isError, error } = useEntities( - { engineId, request: debouncedRequest }, - { enabled: validationErrors.length === 0 } - ); - const requestPending = isFetching || JSON.stringify(request) !== JSON.stringify(debouncedRequest); - - const total = data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pageSize)); - const rows = useMemo( - () => - (data?.items ?? []).map(item => ({ - fsm: item.entity, - usageDurationS: item.usage_duration_s, - ...fsmSpan(item.entity), - })), - [data?.items] - ); - const visibleStart = total === 0 ? 0 : page * pageSize + 1; - const visibleEnd = total === 0 ? 0 : Math.min(total, visibleStart + rows.length - 1); - const paginationDisabled = requestPending || validationErrors.length > 0; +export function EntitiesTable(props: EntitiesTableProps) { + const table = useEntityTable(props); return (
-
- updateFilters({ operatorId: v })} - /> - updateFilters({ entityType: v })} - /> - updateFilters({ resourceId: v })} - /> - updateFilters({ minUsageS: v })} - /> - updateFilters({ windowStart: v })} - /> - updateFilters({ windowEnd: v })} - /> - - updateFilters({ sortDir: (v as SortDir | null) ?? 'Desc' }, false) - } - /> - updateFilters({ pageSize: v }, false)} - /> - - {activeFilterCount > 0 && ( - - {activeFilterCount} active {activeFilterCount === 1 ? 'filter' : 'filters'} - - )} - {requestPending && validationErrors.length === 0 && ( - - - Updating… - - )} - {validationErrors.length > 0 && ( -
- {validationErrors.join(' ')} -
- )} -
- -
0 ? 'opacity-60' : 'opacity-100' - }`} - > - {isError ? ( -
- Failed to load entities: {error instanceof Error ? error.message : 'unknown error'} -
- ) : ( - - - - Instance - Type - States - Start - End - FSM span - Longest usage - ID - - - - {rows.map(({ fsm, start, end, usageDurationS }) => ( - setSelected(fsm)} - onKeyDown={event => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setSelected(fsm); - } - }} - > - {fsm.instance_name} - {fsm.type_name} - - {fsm.transitions.length} - - {start.toFixed(3)}s - {end.toFixed(3)}s - - {formatDuration((end - start) * 1000)} - - - {formatDuration(usageDurationS * 1000)} - - - {fsm.id} - - - ))} - -
- )} - {!isError && !isLoading && validationErrors.length === 0 && rows.length === 0 && ( -
- No entities match the filters. -
- )} - {isLoading &&
Loading…
} -
- -
- - {visibleStart}–{visibleEnd} of {total} {total === 1 ? 'entity' : 'entities'} - -
- - - - Page {page + 1} / {pageCount} - - - -
-
+ + 0} + page={table.page} + pageCount={table.pageCount} + paginationDisabled={table.paginationDisabled} + total={table.total} + visibleStart={table.visibleStart} + visibleEnd={table.visibleEnd} + onSelect={table.setSelected} + onPageChange={table.setPage} + />
- +
); diff --git a/ui/src/components/entities-table/EntitiesToolbar.tsx b/ui/src/components/entities-table/EntitiesToolbar.tsx new file mode 100644 index 000000000..d31cd3763 --- /dev/null +++ b/ui/src/components/entities-table/EntitiesToolbar.tsx @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { LoaderCircle, RotateCcw } from 'lucide-react'; +import { + Button, + Input, + SearchableSelect, + SelectField, + type SelectFieldOption, +} from '@quent/components'; +import { cn } from '@quent/utils'; +import type { SortDir } from '@quent/utils'; +import type { EntityFilters } from './types'; +import { MAX_PAGE_SIZE, normalizePageSize } from './utils'; + +const SORT_DIR_OPTIONS: SelectFieldOption[] = [ + { value: 'Desc', label: 'Longest resource usage first' }, + { value: 'Asc', label: 'Shortest resource usage first' }, +]; + +interface EntitiesToolbarProps { + filters: EntityFilters; + operatorId: string | null; + operatorOptions: SelectFieldOption[]; + entityTypeOptions: SelectFieldOption[]; + resourceOptions: SelectFieldOption[]; + activeFilterCount: number; + hasNonDefaultSettings: boolean; + requestPending: boolean; + validationErrors: string[]; + onOperatorChange: (value: string | null) => void; + onFiltersChange: ( + patch: Partial, + options?: { preserveSelection?: boolean } + ) => void; + onReset: () => void; +} + +export function EntitiesToolbar({ + filters, + operatorId, + operatorOptions, + entityTypeOptions, + resourceOptions, + activeFilterCount, + hasNonDefaultSettings, + requestPending, + validationErrors, + onOperatorChange, + onFiltersChange, + onReset, +}: EntitiesToolbarProps) { + return ( +
+ + onFiltersChange({ entityType: value })} + /> + onFiltersChange({ resourceId: value })} + /> + onFiltersChange({ minUsageS: value })} + /> + onFiltersChange({ windowStart: value })} + /> + onFiltersChange({ windowEnd: value })} + /> + + onFiltersChange( + { sortDir: (value as SortDir | null) ?? 'Desc' }, + { preserveSelection: true } + ) + } + /> + onFiltersChange({ pageSize: value }, { preserveSelection: true })} + /> + + {activeFilterCount > 0 && ( + + {activeFilterCount} active {activeFilterCount === 1 ? 'filter' : 'filters'} + + )} + {requestPending && validationErrors.length === 0 && ( + + + Updating… + + )} + {validationErrors.length > 0 && ( +
+ {validationErrors.join(' ')} +
+ )} +
+ ); +} + +function NumberField({ + label, + value, + className, + onChange, +}: { + label: string; + value: string; + className?: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function PageSizeField({ + value, + onChange, +}: { + value: number | null; + onChange: (value: number | null) => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx new file mode 100644 index 000000000..f635c6626 --- /dev/null +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { formatAttributeValue, formatDuration } from '@quent/utils'; +import type { Attribute, FiniteStateMachine } from '@quent/utils'; + +interface EntityDetailPanelProps { + fsm: FiniteStateMachine | null; + resourceLabel: (id: string) => string; +} + +export function EntityDetailPanel({ fsm, resourceLabel }: EntityDetailPanelProps) { + if (!fsm) { + return ( +
+ Select an entity to view its states. +
+ ); + } + + return ( +
+
+
{fsm.instance_name}
+
{fsm.type_name}
+
{fsm.id}
+
+
    + {fsm.transitions.map((transition, index) => { + const nextTransition = fsm.transitions[index + 1]; + return ( +
  1. +
    + + {index + 1}. {transition.name} + + + {transition.timestamp.toFixed(3)}s + {nextTransition && ( + <> + {' '} + · for{' '} + {formatDuration((nextTransition.timestamp - transition.timestamp) * 1000)} + + )} + +
    + {transition.usages.length > 0 && ( +
      + {transition.usages.map((usage, usageIndex) => ( +
    • + {resourceLabel(usage.resource)} + {usage.capacities.map(([name, capacity], capacityIndex) => ( + + {name} + {capacity != null ? `=${capacity}` : ''} + + ))} +
    • + ))} +
    + )} + {transition.attributes.length > 0 && ( + + )} + {transition.derived_attributes.length > 0 && ( + + )} +
  2. + ); + })} +
+
+ ); +} + +function AttributeRows({ attributes, derived }: { attributes: Attribute[]; derived?: boolean }) { + return ( +
    + {attributes.map((attribute, index) => ( +
  • + {attribute.key} + + {formatAttributeValue(attribute.key, attribute.value)} + +
  • + ))} +
+ ); +} diff --git a/ui/src/components/entities-table/EntityResults.tsx b/ui/src/components/entities-table/EntityResults.tsx new file mode 100644 index 000000000..88651517b --- /dev/null +++ b/ui/src/components/entities-table/EntityResults.tsx @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ChevronFirst, ChevronLast } from 'lucide-react'; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@quent/components'; +import { cn, formatDuration } from '@quent/utils'; +import type { FiniteStateMachine } from '@quent/utils'; +import type { EntityTableRow } from './types'; + +interface EntityResultsProps { + rows: EntityTableRow[]; + selected: FiniteStateMachine | null; + isError: boolean; + isLoading: boolean; + error: unknown; + requestPending: boolean; + hasValidationErrors: boolean; + page: number; + pageCount: number; + paginationDisabled: boolean; + total: number; + visibleStart: number; + visibleEnd: number; + onSelect: (fsm: FiniteStateMachine) => void; + onPageChange: (page: number) => void; +} + +export function EntityResults({ + rows, + selected, + isError, + isLoading, + error, + requestPending, + hasValidationErrors, + page, + pageCount, + paginationDisabled, + total, + visibleStart, + visibleEnd, + onSelect, + onPageChange, +}: EntityResultsProps) { + return ( + <> +
0 ? 'opacity-60' : 'opacity-100' + )} + > + {isError ? ( +
+ Failed to load entities: {error instanceof Error ? error.message : 'unknown error'} +
+ ) : ( + + + + Instance + Type + States + Start + End + FSM span + Longest usage + ID + + + + {rows.map(row => ( + + ))} + +
+ )} + {!isError && !isLoading && !hasValidationErrors && rows.length === 0 && ( +
No entities match the filters.
+ )} + {isLoading &&
Loading…
} +
+ +
+ + {visibleStart}–{visibleEnd} of {total} {total === 1 ? 'entity' : 'entities'} + +
+ + + + Page {page + 1} / {pageCount} + + + +
+
+ + ); +} + +function EntityRow({ + row, + selected, + onSelect, +}: { + row: EntityTableRow; + selected: boolean; + onSelect: (fsm: FiniteStateMachine) => void; +}) { + const select = () => onSelect(row.fsm); + + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + select(); + } + }} + > + {row.fsm.instance_name} + {row.fsm.type_name} + {row.fsm.transitions.length} + {row.start.toFixed(3)}s + {row.end.toFixed(3)}s + + {formatDuration((row.end - row.start) * 1000)} + + + {formatDuration(row.usageDurationS * 1000)} + + {row.fsm.id} + + ); +} diff --git a/ui/src/components/entities-table/types.ts b/ui/src/components/entities-table/types.ts new file mode 100644 index 000000000..2b1fb88a8 --- /dev/null +++ b/ui/src/components/entities-table/types.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FiniteStateMachine, SortDir } from '@quent/utils'; + +export interface EntityFilters { + entityType: string | null; + resourceId: string | null; + minUsageS: string; + windowStart: string; + windowEnd: string; + sortDir: SortDir; + pageSize: number | null; +} + +export interface EntityTableRow { + fsm: FiniteStateMachine; + start: number; + end: number; + usageDurationS: number; +} diff --git a/ui/src/components/entities-table/useEntityTable.ts b/ui/src/components/entities-table/useEntityTable.ts new file mode 100644 index 000000000..817706ab0 --- /dev/null +++ b/ui/src/components/entities-table/useEntityTable.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEntities } from '@quent/client'; +import { useSelectedNodeIds } from '@quent/hooks'; +import type { SelectFieldOption } from '@quent/components'; +import type { EntityRef, FiniteStateMachine, QueryBundle } from '@quent/utils'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { EntityFilters } from './types'; +import { + activeEntityFilterCount, + buildEntityRequest, + defaultEntityFilters, + entityRows, + hasNonDefaultEntitySettings, + normalizePageSize, + validateEntityFilters, +} from './utils'; + +const FILTER_DEBOUNCE_MS = 300; + +interface UseEntityTableParams { + engineId: string; + queryId: string; + queryBundle: QueryBundle; +} + +interface ManualOperatorOverride { + dagOperatorId: string | null; + value: string | null; +} + +export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTableParams) { + const { entities, duration_s: durationS } = queryBundle; + const selectedNodeIds = useSelectedNodeIds(); + const dagOperatorId = selectedNodeIds.values().next().value ?? null; + const defaults = useMemo(() => defaultEntityFilters(durationS), [durationS]); + const [filters, setFilters] = useState(() => defaultEntityFilters(durationS)); + const [manualOperatorOverride, setManualOperatorOverride] = + useState(null); + const [page, setPage] = useState(0); + const [selected, setSelected] = useState(null); + const operatorId = + manualOperatorOverride?.dagOperatorId === dagOperatorId + ? manualOperatorOverride.value + : dagOperatorId; + + useEffect(() => { + setManualOperatorOverride(null); + setPage(0); + setSelected(null); + }, [dagOperatorId]); + + const updateFilters = useCallback( + (patch: Partial, options?: { preserveSelection?: boolean }) => { + setFilters(previous => ({ ...previous, ...patch })); + setPage(0); + if (!options?.preserveSelection) setSelected(null); + }, + [] + ); + + const updateOperator = useCallback( + (value: string | null) => { + setManualOperatorOverride({ dagOperatorId, value }); + setPage(0); + setSelected(null); + }, + [dagOperatorId] + ); + + const resetFilters = useCallback(() => { + setFilters(defaults); + setManualOperatorOverride({ dagOperatorId, value: null }); + setPage(0); + setSelected(null); + }, [dagOperatorId, defaults]); + + const operatorOptions = useMemo( + () => + Object.values(entities.operators) + .map(operator => ({ + value: operator.id, + label: operator.instance_name ?? operator.operator_type_name ?? operator.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entities.operators] + ); + const entityTypeOptions = useMemo( + () => + Object.keys(entities.fsm_types) + .sort() + .map(name => ({ value: name })), + [entities.fsm_types] + ); + const resourceOptions = useMemo( + () => + Object.values(entities.resources) + .map(resource => ({ + value: resource.id, + label: `${resource.instance_name} (${resource.type_name})`, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entities.resources] + ); + const resourceLabel = useCallback( + (id: string) => { + const resource = entities.resources[id]; + return resource ? `${resource.instance_name} (${resource.type_name})` : id; + }, + [entities.resources] + ); + + const validationErrors = useMemo(() => validateEntityFilters(filters), [filters]); + const request = useMemo( + () => buildEntityRequest({ filters, operatorId, page, queryId, durationS }), + [durationS, filters, operatorId, page, queryId] + ); + const debouncedRequest = useDebouncedValue(request, FILTER_DEBOUNCE_MS); + const query = useEntities( + { engineId, request: debouncedRequest }, + { enabled: validationErrors.length === 0 } + ); + const requestPending = query.isFetching || request !== debouncedRequest; + const rows = useMemo(() => entityRows(query.data), [query.data]); + const pageSize = normalizePageSize(filters.pageSize); + const total = query.data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const visibleStart = total === 0 ? 0 : page * pageSize + 1; + const visibleEnd = total === 0 ? 0 : Math.min(total, visibleStart + rows.length - 1); + const activeFilterCount = activeEntityFilterCount(filters, defaults, operatorId); + + return { + activeFilterCount, + entityTypeOptions, + error: query.error, + filters, + hasNonDefaultSettings: hasNonDefaultEntitySettings(filters, defaults, activeFilterCount), + isError: query.isError, + isLoading: query.isLoading, + operatorId, + operatorOptions, + page, + pageCount, + paginationDisabled: requestPending || validationErrors.length > 0, + requestPending, + resetFilters, + resourceLabel, + resourceOptions, + rows, + selected, + setPage, + setSelected, + total, + updateFilters, + updateOperator, + validationErrors, + visibleEnd, + visibleStart, + }; +} diff --git a/ui/src/components/entities-table/utils.ts b/ui/src/components/entities-table/utils.ts new file mode 100644 index 000000000..85ef8cd1d --- /dev/null +++ b/ui/src/components/entities-table/utils.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + EntityListRequest, + EntityListResponse, + FiniteStateMachine, + OperatorFilter, + QueryFilter, +} from '@quent/utils'; +import type { EntityFilters, EntityTableRow } from './types'; + +export const DEFAULT_PAGE_SIZE = 50; +export const MAX_PAGE_SIZE = 500; + +export function defaultEntityFilters(durationS: number): EntityFilters { + return { + entityType: null, + resourceId: null, + minUsageS: '', + windowStart: '0', + windowEnd: String(durationS), + sortDir: 'Desc', + pageSize: DEFAULT_PAGE_SIZE, + }; +} + +export function normalizePageSize(value: number | null): number { + if (value === null || !Number.isFinite(value)) return DEFAULT_PAGE_SIZE; + return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value))); +} + +export function validateEntityFilters(filters: EntityFilters): string[] { + const windowStart = parseOptionalNumber(filters.windowStart); + const windowEnd = parseOptionalNumber(filters.windowEnd); + const minUsageS = parseOptionalNumber(filters.minUsageS); + const errors: string[] = []; + + if (filters.windowStart.trim() !== '' && windowStart === null) + errors.push('Window start must be a number.'); + if (filters.windowEnd.trim() !== '' && windowEnd === null) + errors.push('Window end must be a number.'); + if (filters.minUsageS.trim() !== '' && minUsageS === null) + errors.push('Minimum usage must be a number.'); + if (windowStart !== null && windowStart < 0) errors.push('Window start cannot be negative.'); + if (windowEnd !== null && windowEnd < 0) errors.push('Window end cannot be negative.'); + if (windowStart !== null && windowEnd !== null && windowStart > windowEnd) + errors.push('Window start must not exceed window end.'); + if (minUsageS !== null && minUsageS < 0) errors.push('Minimum usage cannot be negative.'); + + return errors; +} + +export function buildEntityRequest({ + filters, + operatorId, + page, + queryId, + durationS, +}: { + filters: EntityFilters; + operatorId: string | null; + page: number; + queryId: string; + durationS: number; +}): EntityListRequest { + return { + entry: { + window: { + start: parseOptionalNumber(filters.windowStart) ?? 0, + end: parseOptionalNumber(filters.windowEnd) ?? durationS, + }, + filter: { + scope: filters.resourceId ? { Resource: { resource_id: filters.resourceId } } : null, + entity_type_name: filters.entityType, + min_usage_s: parseOptionalNumber(filters.minUsageS), + }, + sort: { key: 'UsageDuration', dir: filters.sortDir }, + page: { max: normalizePageSize(filters.pageSize), page }, + application: { operator_id: operatorId }, + }, + app_params: { query_id: queryId }, + }; +} + +export function entityRows(data: EntityListResponse | undefined): EntityTableRow[] { + return (data?.items ?? []).map(item => ({ + fsm: item.entity, + usageDurationS: item.usage_duration_s, + ...fsmSpan(item.entity), + })); +} + +export function activeEntityFilterCount( + filters: EntityFilters, + defaults: EntityFilters, + operatorId: string | null +): number { + return [ + operatorId !== null, + filters.entityType !== null, + filters.resourceId !== null, + filters.minUsageS !== '', + filters.windowStart !== defaults.windowStart, + filters.windowEnd !== defaults.windowEnd, + ].filter(Boolean).length; +} + +export function hasNonDefaultEntitySettings( + filters: EntityFilters, + defaults: EntityFilters, + activeFilterCount: number +): boolean { + return ( + activeFilterCount > 0 || + filters.sortDir !== defaults.sortDir || + filters.pageSize !== defaults.pageSize + ); +} + +function parseOptionalNumber(value: string): number | null { + if (value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } { + let start = Infinity; + let end = -Infinity; + for (const transition of fsm.transitions) { + if (transition.timestamp < start) start = transition.timestamp; + if (transition.timestamp > end) end = transition.timestamp; + } + return fsm.transitions.length === 0 ? { start: 0, end: 0 } : { start, end }; +} diff --git a/ui/src/hooks/useDebouncedValue.ts b/ui/src/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..154216aab --- /dev/null +++ b/ui/src/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useState } from 'react'; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebouncedValue(value), delayMs); + return () => clearTimeout(timeout); + }, [delayMs, value]); + + return debouncedValue; +} From 8bec1f950d2d150b61b8a6330b8c20ef756bc23e Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 23 Jul 2026 08:46:40 -0600 Subject: [PATCH 03/75] feat(ui): per-resource long-entities Gantt; drop timeline entity marks Auto-load a long-entities Gantt under each leaf resource (injected synthetic row in QueryResourceTree), filtered by the same getLongEntitiesThreshold cutoff the timelines use. Remove the entity marks overlay from the resource timelines (and the now-dead "Hide tasks" toggle / hideTasks atom), and stop requesting long_fsms in the timeline bulk/single fetches. --- ui/packages/@quent/components/src/index.ts | 7 +- .../components/src/lib/timeline.utils.ts | 4 +- .../src/timeline/ResourceTimeline.tsx | 37 ++------ .../src/timeline/TimelineToolbar.tsx | 21 +---- .../@quent/hooks/src/atoms/timeline.ts | 3 - ui/packages/@quent/hooks/src/index.ts | 2 - .../hooks/src/timeline/useTimelineAtoms.ts | 3 - ui/src/components/LongEntitiesRow.tsx | 89 +++++++++++++++++++ ui/src/components/QueryResourceTree.tsx | 52 ++++++++++- 9 files changed, 152 insertions(+), 66 deletions(-) create mode 100644 ui/src/components/LongEntitiesRow.tsx diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 9fc0303af..b02e089c2 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -243,7 +243,12 @@ export type { GroupIndexDef, RowWithGroupKeys } from './pivot-table/utils'; export { LongEntitiesGantt } from './long-entities/LongEntitiesGantt'; export type { LongEntitiesGanttProps } from './long-entities/LongEntitiesGantt'; export type { LongEntityEntry, LongEntitySegment } from './long-entities/types'; -export { buildLongEntityEntries } from './long-entities/utils'; +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.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index 702450908..bb4f1f0bc 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -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/timeline/ResourceTimeline.tsx b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx index c8c7a8dd9..808b2983a 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,7 +283,6 @@ export function ResourceTimeline({ timestamps={timestamps ?? []} durationSeconds={durationSeconds} showTooltip={showTooltip} - marks={effectiveMarks} isDark={isDark} yAxisLabel={effectiveYAxisLabel} onHoverChange={handleHoverChange} @@ -318,7 +292,6 @@ export function ResourceTimeline({ ownerId={ownerId} series={series} timestamps={timestamps ?? []} - marks={effectiveMarks} /> )} diff --git a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx index a57803254..ce0c2c59d 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx @@ -2,19 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { Maximize2, Settings } from 'lucide-react'; -import { - useHideTasks, - useSetHideTasks, - useSetZoomRange, - useSetDebouncedZoomRange, -} from '@quent/hooks'; +import { useSetZoomRange, useSetDebouncedZoomRange } from '@quent/hooks'; import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'; import { QueryToolbar } from './QueryToolbar'; /** Toolbar for the timeline view: shows active operator filter, zoom reset, and settings. */ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number }) { - const hideTasks = useHideTasks(); - const setHideTasks = useSetHideTasks(); const setZoomRange = useSetZoomRange(); const setDebouncedZoomRange = useSetDebouncedZoomRange(); @@ -46,17 +39,7 @@ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number } - - - + No settings yet. ); 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.tsx b/ui/src/components/LongEntitiesRow.tsx new file mode 100644 index 000000000..7ff0d6682 --- /dev/null +++ b/ui/src/components/LongEntitiesRow.tsx @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import { useEntityList } from '@quent/client'; +import { useSelectedNodeIds } from '@quent/hooks'; +import type { FsmTypeDecl } from '@quent/utils'; +import { + DEFAULT_TIMELINE_HEIGHT, + LongEntitiesGantt, + buildLongEntityEntries, + getLongEntitiesThreshold, +} from '@quent/components'; + +/** Max entities fetched per resource; longest-usage-first, so this keeps the top N. */ +const MAX_ENTITIES = 200; + +type LongEntitiesRowProps = { + engineId: string; + queryId: string; + /** The resource this row's entities are scoped to. */ + resourceId: string; + startTime: bigint; + durationSeconds: number; + fsmTypes: { [key in string]?: FsmTypeDecl }; + isDark: boolean; +}; + +/** + * 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, + startTime, + durationSeconds, + fsmTypes, + isDark, +}: LongEntitiesRowProps) { + const selectedNodeIds = useSelectedNodeIds(); + // The entity endpoint filters by a single operator; honor the DAG selection + // only when exactly one operator is picked, otherwise show all. + const operatorId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0]! : null; + + const { data, isFetching } = useEntityList({ + engineId, + queryId, + window: { start: 0, end: durationSeconds }, + operatorId, + // Match the resource timelines' "long entity" cutoff so the Gantt shows the + // same set of long-running entities. + minUsageSeconds: getLongEntitiesThreshold(durationSeconds), + sortDir: 'Desc', + maxItems: MAX_ENTITIES, + filter: { scope: { Resource: { resource_id: resourceId } } }, + }); + + const entries = useMemo( + () => + data + ? buildLongEntityEntries(data.items, startTime, fsmTypes, isDark ? 'dark' : 'light') + : [], + [data, startTime, fsmTypes, isDark] + ); + + if (!data && isFetching) { + return ( +
+ Loading long entities… +
+ ); + } + + return ( + + ); +} diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 1a33c65d5..5b446f8f5 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,30 @@ 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'], + }; +} + +/** + * Inject an expandable long-entities row under each leaf resource so its + * timeline can reveal a compact Gantt of the resource's longest entities. + * Groups (which aggregate multiple resources) are left untouched. + */ +function injectLongEntitiesRows(item: TreeTableItem): TreeTableItem { + const transformedChildren = item.children?.map(injectLongEntitiesRows); + if (item.type !== EntityTypeKey.Resource) { + return transformedChildren ? { ...item, children: transformedChildren } : { ...item }; + } + const longEntitiesRow = createLongEntitiesRow(item.id); + const children = [longEntitiesRow, ...(transformedChildren ?? [])]; + return { ...item, children }; +} + interface QueryResourceTreeProps { engineId: string; queryBundle: QueryBundle; @@ -186,7 +216,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr ); const treeData = useMemo( - () => [injectOperatorTimelineRows(rootItem, workerIdsFromPlanTree)], + () => [injectLongEntitiesRows(injectOperatorTimelineRows(rootItem, workerIdsFromPlanTree))], [rootItem, workerIdsFromPlanTree] ); @@ -213,7 +243,8 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr ), render: ({ item }: { item: TreeTableItem; level: number }) => { switch (item.type) { - case OPERATOR_TIMELINE_ROW_TYPE: { + case OPERATOR_TIMELINE_ROW_TYPE: + case LONG_ENTITIES_ROW_TYPE: { return null; } default: { @@ -273,6 +304,21 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr /> ); } + case LONG_ENTITIES_ROW_TYPE: { + const resourceId = resourceIdFromLongEntitiesRowId(item.id); + if (resourceId == null) return null; + return ( + + ); + } default: { return ( Date: Thu, 23 Jul 2026 09:52:39 -0600 Subject: [PATCH 04/75] refactor: always include entities row under leaves, move label to top and state to body --- .../src/long-entities/LongEntitiesGantt.tsx | 94 ++++++++++++++----- ui/src/components/QueryResourceTree.tsx | 19 ++-- 2 files changed, 79 insertions(+), 34 deletions(-) diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 080fcad34..64f948a7d 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useMemo } from 'react'; - import { MARK_AREA_BORDER_OPACITY, MARK_AREA_FILL_OPACITY, - MARK_LABEL_TEXT_COLOR, + useTimelineEchartsTheme, } from '../timeline/timelineEchartsTheme'; +import { useCallback, useMemo } from 'react'; + import { withOpacity } from '@quent/utils'; import type { LongEntityEntry } from './types'; import { GanttChart, type GanttRenderItem } from '../gantt-chart/GanttChart'; @@ -15,9 +15,16 @@ import { clipRectByRect } from '../gantt-chart/utils'; const DEFAULT_HEIGHT = 120; const MAX_HEIGHT = 400; -const BAR_FONT_SIZE = 9; +const STATE_FONT_SIZE = 9; +const TASK_FONT_SIZE = 10; +/** Task-name line drawn above each bar. */ +const TASK_LABEL_HEIGHT = 12; const BAR_HEIGHT = 16; -const BAR_GAP = 2; +/** Vertical gap between stacked rows. */ +const ROW_GAP = 4; +const ROW_HEIGHT = TASK_LABEL_HEIGHT + BAR_HEIGHT + ROW_GAP; +/** Radius applied only to the outer corners of each entity's segment run. */ +const CORNER_RADIUS = 3; const SERIES_NAME = 'long-entity-segment'; /** Flat segment datum: one ECharts custom-series item per state span. */ @@ -41,6 +48,7 @@ export function LongEntitiesGantt({ height = DEFAULT_HEIGHT, isDark, }: LongEntitiesGanttProps) { + const { textColor } = useTimelineEchartsTheme(isDark); // One custom-series datum per segment, tagged with its parent entry/segment. const customSeriesData = useMemo(() => { const data: SegmentDatum[] = []; @@ -71,8 +79,9 @@ 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; + // Center the task-label + bar cluster within the row band; bar sits below the label. + const clusterTop = startPoint[1] - (TASK_LABEL_HEIGHT + BAR_HEIGHT) / 2; + const barTop = clusterTop + TASK_LABEL_HEIGHT; const width = Math.max(1, endPoint[0] - startPoint[0]); const coord = params.coordSys as { x?: number; y?: number; width?: number; height?: number }; @@ -80,14 +89,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,35 +115,58 @@ 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 + // State name centered inside each segment box (skipped when too narrow to read). + const stateChildren = + clippedShape.width > 10 + ? [ + { + type: 'text' as const, + style: { + text: segment.stateName, + x: clippedShape.x + clippedShape.width / 2, + y: clippedShape.y + clippedShape.height / 2, + textAlign: 'center' as const, + textVerticalAlign: 'middle' as const, + fontSize: STATE_FONT_SIZE, + fill: textColor, + overflow: 'truncate' as const, + width: Math.max(0, clippedShape.width - 6), + }, + }, + ] + : []; + + // Task name above the bar, drawn once (first segment) spanning the whole entity. + const entityRight = api.coord([entry.endMs, rowIndex])[0]; + const labelLeft = clippedShape.x; + const labelRight = clipBound + ? Math.min(entityRight, clipBound.x + clipBound.width) + : entityRight; + const labelWidth = Math.max(0, labelRight - labelLeft); + const taskChildren = + isFirst && labelWidth > 4 ? [ { type: 'text' as const, style: { text: entry.label, - x: textX, - y: textY, + x: labelLeft + 1, + y: clusterTop + TASK_LABEL_HEIGHT / 2, + textAlign: 'left' 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: TASK_FONT_SIZE, + fontWeight: 500 as const, + fill: textColor, overflow: 'truncate' as const, - width: Math.max(0, clippedShape.width - 8), + width: Math.max(0, labelWidth - 2), }, }, ] : []; - return { type: 'group' as const, children: [rect, ...labelChildren] }; + return { type: 'group' as const, children: [rect, ...stateChildren, ...taskChildren] }; }, - [entries, customSeriesData] + [entries, customSeriesData, textColor] ); return ( @@ -133,7 +175,7 @@ export function LongEntitiesGantt({ durationSeconds={durationSeconds} height={height} maxHeight={MAX_HEIGHT} - rowHeight={BAR_HEIGHT} + rowHeight={ROW_HEIGHT} isDark={isDark} seriesName={SERIES_NAME} renderItem={renderItem} diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 5b446f8f5..f701a6447 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -91,17 +91,20 @@ function createLongEntitiesRow(resourceId: string): TreeTableItem { } /** - * Inject an expandable long-entities row under each leaf resource so its - * timeline can reveal a compact Gantt of the resource's longest entities. - * Groups (which aggregate multiple resources) are left untouched. + * 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 { - const transformedChildren = item.children?.map(injectLongEntitiesRows); - if (item.type !== EntityTypeKey.Resource) { - return transformedChildren ? { ...item, children: transformedChildren } : { ...item }; + 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)); + } } - const longEntitiesRow = createLongEntitiesRow(item.id); - const children = [longEntitiesRow, ...(transformedChildren ?? [])]; return { ...item, children }; } From 684e9680dd67abd4798814e3cbc45741df0b8081 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 20:43:50 -0600 Subject: [PATCH 05/75] feature: operator gantt chart tooltip, sync hover line --- .../src/long-entities/LongEntitiesGantt.tsx | 47 ++++++++-- .../src/long-entities/utils.test.ts | 37 +++++++- .../components/src/long-entities/utils.ts | 13 +++ .../operator-timeline/OperatorGanttChart.tsx | 17 ++++ .../src/operator-timeline/utils.test.ts | 32 +++++++ .../components/src/operator-timeline/utils.ts | 10 ++ .../src/timeline/TimelineTooltip.test.tsx | 22 ++++- .../src/timeline/TimelineTooltip.tsx | 33 +++++-- .../@quent/components/src/ui/color-circle.tsx | 14 +++ .../components/src/ui/gantt-tooltip.tsx | 93 +++++++++++++++++++ 10 files changed, 300 insertions(+), 18 deletions(-) create mode 100644 ui/packages/@quent/components/src/ui/color-circle.tsx create mode 100644 ui/packages/@quent/components/src/ui/gantt-tooltip.tsx diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 64f948a7d..0ac431e27 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -1,27 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useCallback, useMemo } from 'react'; + import { MARK_AREA_BORDER_OPACITY, MARK_AREA_FILL_OPACITY, useTimelineEchartsTheme, } from '../timeline/timelineEchartsTheme'; -import { useCallback, useMemo } from 'react'; - +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/gantt-tooltip'; +import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; const DEFAULT_HEIGHT = 120; const MAX_HEIGHT = 400; const STATE_FONT_SIZE = 9; const TASK_FONT_SIZE = 10; -/** Task-name line drawn above each bar. */ -const TASK_LABEL_HEIGHT = 12; -const BAR_HEIGHT = 16; +/** Task-name line drawn above each bar (~2px around the text). */ +const TASK_LABEL_HEIGHT = TASK_FONT_SIZE + 4; +const BAR_HEIGHT = STATE_FONT_SIZE + 4; /** Vertical gap between stacked rows. */ -const ROW_GAP = 4; +const ROW_GAP = 2; const ROW_HEIGHT = TASK_LABEL_HEIGHT + BAR_HEIGHT + ROW_GAP; /** Radius applied only to the outer corners of each entity's segment run. */ const CORNER_RADIUS = 3; @@ -49,6 +54,7 @@ export function LongEntitiesGantt({ 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[] = []; @@ -63,6 +69,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) => { @@ -180,6 +214,7 @@ export function LongEntitiesGantt({ seriesName={SERIES_NAME} renderItem={renderItem} emptyMessage="No long 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..1df28f0cf 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, @@ -126,3 +126,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], 0n, {}, '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], 0n, {}, '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..f92ffec29 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.ts @@ -88,3 +88,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/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/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..02784ec02 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 { ColorCircle } from '../ui/color-circle'; 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)} @@ -207,13 +206,7 @@ function ActiveMarksSection({ marks }: { marks: ActiveMark[] }) { {marks.map((m, i) => (
    - + {m.label} {m.stateName}
    @@ -342,6 +335,26 @@ function OverlayBarTooltip({ ); } +/** ResourceTimeline entity-mark tooltip, reusable by entity Gantt charts. */ +export function EntityTooltipContent({ + timestamp, + windowMs, + activeMarks, +}: { + timestamp: number; + windowMs: number; + activeMarks: ActiveMark[]; +}) { + return ( +
    + + {formatDurationForWindow(timestamp, windowMs)} + + +
    + ); +} + export function TooltipContent({ timestamp, series, diff --git a/ui/packages/@quent/components/src/ui/color-circle.tsx b/ui/packages/@quent/components/src/ui/color-circle.tsx new file mode 100644 index 000000000..b00f76552 --- /dev/null +++ b/ui/packages/@quent/components/src/ui/color-circle.tsx @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from '@quent/utils'; + +export function ColorCircle({ color, className }: { color: string; className?: string }) { + 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..0283d4e2f --- /dev/null +++ b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx @@ -0,0 +1,93 @@ +// 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'; + +import { ColorCircle } from './color-circle'; +import { DataText } from './data-text'; +import type { GanttHover } from '../gantt-chart/hover'; + +const POINTER_OFFSET = 12; +const VIEWPORT_MARGIN = 4; + +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} + + )} +
    • + ))} +
    +
    +
    + ); +} + +export function PointerTooltipPortal({ + hover, + children, +}: { + hover: GanttHover | null; + children: ReactNode; +}) { + if (!hover) return null; + return {children}; +} + +function PositionedPointerTooltip({ hover, children }: { hover: GanttHover; 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 + ); +} From 950e8969010a7917fd9e5738381c232728118caa Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Tue, 28 Jul 2026 15:24:03 -0600 Subject: [PATCH 06/75] refactor: update entities gantt chart for echarts 6 paradigm (seconds elapsed) --- .../components/src/operator-timeline/types.ts | 2 +- .../components/src/timeline/TimelineTooltip.tsx | 1 + ui/src/components/LongEntitiesRow.tsx | 16 +++++----------- ui/src/components/QueryResourceTree.tsx | 1 - 4 files changed, 7 insertions(+), 13 deletions(-) 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/timeline/TimelineTooltip.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx index 02784ec02..d66ec622d 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx @@ -341,6 +341,7 @@ export function EntityTooltipContent({ windowMs, activeMarks, }: { + /** Elapsed ms from query start. */ timestamp: number; windowMs: number; activeMarks: ActiveMark[]; diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index 7ff0d6682..8b1d56e39 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -9,18 +9,18 @@ import { DEFAULT_TIMELINE_HEIGHT, LongEntitiesGantt, buildLongEntityEntries, - getLongEntitiesThreshold, } from '@quent/components'; /** Max entities fetched per resource; longest-usage-first, so this keeps the top N. */ const MAX_ENTITIES = 200; +// Stress-test the Gantt with nearly every task. +const MIN_USAGE_SECONDS = 0.001; type LongEntitiesRowProps = { engineId: string; queryId: string; /** The resource this row's entities are scoped to. */ resourceId: string; - startTime: bigint; durationSeconds: number; fsmTypes: { [key in string]?: FsmTypeDecl }; isDark: boolean; @@ -35,7 +35,6 @@ export function LongEntitiesRow({ engineId, queryId, resourceId, - startTime, durationSeconds, fsmTypes, isDark, @@ -50,9 +49,7 @@ export function LongEntitiesRow({ queryId, window: { start: 0, end: durationSeconds }, operatorId, - // Match the resource timelines' "long entity" cutoff so the Gantt shows the - // same set of long-running entities. - minUsageSeconds: getLongEntitiesThreshold(durationSeconds), + minUsageSeconds: MIN_USAGE_SECONDS, sortDir: 'Desc', maxItems: MAX_ENTITIES, filter: { scope: { Resource: { resource_id: resourceId } } }, @@ -60,10 +57,8 @@ export function LongEntitiesRow({ const entries = useMemo( () => - data - ? buildLongEntityEntries(data.items, startTime, fsmTypes, isDark ? 'dark' : 'light') - : [], - [data, startTime, fsmTypes, isDark] + data ? buildLongEntityEntries(data.items, fsmTypes, isDark ? 'dark' : 'light') : [], + [data, fsmTypes, isDark] ); if (!data && isFetching) { @@ -80,7 +75,6 @@ export function LongEntitiesRow({ return ( Date: Tue, 28 Jul 2026 15:37:10 -0600 Subject: [PATCH 07/75] refactor: shrink long entities max height --- .../@quent/components/src/long-entities/LongEntitiesGantt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 0ac431e27..2c3c7a00c 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -19,7 +19,7 @@ import { PointerTooltipPortal } from '../ui/gantt-tooltip'; import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; const DEFAULT_HEIGHT = 120; -const MAX_HEIGHT = 400; +const MAX_HEIGHT = 150; const STATE_FONT_SIZE = 9; const TASK_FONT_SIZE = 10; /** Task-name line drawn above each bar (~2px around the text). */ From 511c85c7772c97c2443319a347890150dde299aa Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 30 Jul 2026 11:39:56 -0600 Subject: [PATCH 08/75] refactor: user facing messaging -> entities --- .../@quent/components/src/long-entities/LongEntitiesGantt.tsx | 2 +- ui/src/components/LongEntitiesRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 2c3c7a00c..062a12f3c 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -213,7 +213,7 @@ export function LongEntitiesGantt({ isDark={isDark} seriesName={SERIES_NAME} renderItem={renderItem} - emptyMessage="No long entities" + emptyMessage="No entities" renderTooltip={renderTooltip} /> ); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index 8b1d56e39..a2ee4fe25 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -67,7 +67,7 @@ export function LongEntitiesRow({ className="flex items-center px-2 text-sm text-muted-foreground" style={{ height: DEFAULT_TIMELINE_HEIGHT }} > - Loading long entities… + Loading entities…
    ); } From 0620be17b5a10be26aeff21c3e37e5d78ae3371e Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 30 Jul 2026 14:20:50 -0600 Subject: [PATCH 09/75] test(components): align entity hover tests with elapsed time Use the shared three-argument builder API after moving elapsed-time normalization earlier in the stack. --- ui/packages/@quent/components/src/long-entities/utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1df28f0cf..a5fec247d 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.test.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.test.ts @@ -137,7 +137,7 @@ describe('getLongEntitySegmentsAtTimestamp', () => { const second = makeFsm('second', [transition('queued', 0.5), transition('done', 2)], { instance_name: 'task-2', }); - const entries = buildLongEntityEntries([first, second], 0n, {}, 'light'); + const entries = buildLongEntityEntries([first, second], {}, 'light'); expect( getLongEntitySegmentsAtTimestamp(entries, 1_500).map(({ entry, segment }) => [ @@ -156,7 +156,7 @@ describe('getLongEntitySegmentsAtTimestamp', () => { transition('running', 1), transition('exit', 2), ]); - const entries = buildLongEntityEntries([fsm], 0n, {}, 'light'); + const entries = buildLongEntityEntries([fsm], {}, 'light'); const [{ segment }] = getLongEntitySegmentsAtTimestamp(entries, 1_000); expect(segment.stateName).toBe('running'); }); From b25023ccfca6a3d384f32975cc84f995c628a8ec Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 30 Jul 2026 14:26:14 -0600 Subject: [PATCH 10/75] chore(ui): canonicalize entity UI license headers Keep the tooltip and row files compliant with the repository copyright hook. --- ui/packages/@quent/components/src/ui/color-circle.tsx | 2 +- ui/packages/@quent/components/src/ui/gantt-tooltip.tsx | 2 +- ui/src/components/LongEntitiesRow.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/@quent/components/src/ui/color-circle.tsx b/ui/packages/@quent/components/src/ui/color-circle.tsx index b00f76552..8376522ae 100644 --- a/ui/packages/@quent/components/src/ui/color-circle.tsx +++ b/ui/packages/@quent/components/src/ui/color-circle.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { cn } from '@quent/utils'; diff --git a/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx index 0283d4e2f..fc465a4b5 100644 --- a/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx +++ b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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'; diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index a2ee4fe25..c4045c5c3 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { useMemo } from 'react'; From 8efe45c0f93f295e6c49a7a041bf989c5842afe4 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 3 Aug 2026 10:11:57 -0600 Subject: [PATCH 11/75] Constant height swimlane rows, keep previous data --- ui/packages/@quent/client/src/api.ts | 13 +++++ .../@quent/client/src/entityList.test.ts | 30 ++++++++++ ui/packages/@quent/client/src/entityList.ts | 11 ++-- ui/packages/@quent/components/src/index.ts | 5 +- .../src/long-entities/LongEntitiesGantt.tsx | 7 +-- ui/src/components/LongEntitiesRow.test.tsx | 55 +++++++++++++++++++ ui/src/components/LongEntitiesRow.tsx | 30 +++++----- 7 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 ui/packages/@quent/client/src/entityList.test.ts create mode 100644 ui/src/components/LongEntitiesRow.test.tsx diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 9c224225a..1549f4e11 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -120,6 +120,19 @@ export async function fetchBulkTimelines( }); } +/** Fetch a ranked, paged list of a query's entities. */ +export async function fetchEntityList( + engineId: string, + request: EntityListRequest +): Promise { + return apiFetch(`/engines/${engineId}/entities`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); +} + /** * Fetch a ranked, paged list of a query's entities (longest resource-usage * span first). Backs the long-entities Gantt view. 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..61649e12c --- /dev/null +++ b/ui/packages/@quent/client/src/entityList.test.ts @@ -0,0 +1,30 @@ +// 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 { 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, + }); + + 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 }), + }), + }), + ]); + expect(options.placeholderData).toBe(keepPreviousData); + }); +}); diff --git a/ui/packages/@quent/client/src/entityList.ts b/ui/packages/@quent/client/src/entityList.ts index e029204ab..36c8dc4fc 100644 --- a/ui/packages/@quent/client/src/entityList.ts +++ b/ui/packages/@quent/client/src/entityList.ts @@ -1,7 +1,7 @@ // 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 { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; import type { EntityListRequest, EntityScope, @@ -18,8 +18,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). */ @@ -33,7 +33,7 @@ interface EntityListParams { function buildRequest({ queryId, window, - operatorId = null, + operatorIds = [], filter, minUsageSeconds = null, sortKey = 'UsageDuration', @@ -50,7 +50,7 @@ function buildRequest({ }, sort: { key: sortKey, dir: sortDir }, page: maxItems != null ? { page: 0, max: maxItems } : null, - application: { operator_ids: operatorId == null ? [] : [operatorId] }, + application: { operator_ids: operatorIds }, }, app_params: { query_id: queryId }, }; @@ -66,6 +66,7 @@ export const entityListQueryOptions = ( queryFn: () => fetchEntityList(params.engineId, request), staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, enabled: options?.enabled ?? true, + placeholderData: keepPreviousData, }); }; export const useEntityList = ( diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index b02e089c2..3839f9e2c 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -240,7 +240,10 @@ 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 { diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 062a12f3c..6370c221a 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -18,8 +18,7 @@ import { getLongEntitySegmentsAtTimestamp } from './utils'; import { PointerTooltipPortal } from '../ui/gantt-tooltip'; import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; -const DEFAULT_HEIGHT = 120; -const MAX_HEIGHT = 150; +export const LONG_ENTITIES_TIMELINE_HEIGHT = 110; const STATE_FONT_SIZE = 9; const TASK_FONT_SIZE = 10; /** Task-name line drawn above each bar (~2px around the text). */ @@ -50,7 +49,7 @@ export interface LongEntitiesGanttProps { export function LongEntitiesGantt({ entries, durationSeconds, - height = DEFAULT_HEIGHT, + height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, }: LongEntitiesGanttProps) { const { textColor } = useTimelineEchartsTheme(isDark); @@ -208,7 +207,7 @@ export function LongEntitiesGantt({ data={customSeriesData} durationSeconds={durationSeconds} height={height} - maxHeight={MAX_HEIGHT} + maxHeight={height} rowHeight={ROW_HEIGHT} isDark={isDark} seriesName={SERIES_NAME} diff --git a/ui/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx new file mode 100644 index 000000000..7c846c9cd --- /dev/null +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LongEntitiesRow } from './LongEntitiesRow'; + +const mocks = vi.hoisted(() => ({ + getLongEntitiesThreshold: vi.fn((_windowSeconds: number) => 0.06), + longEntitiesGantt: vi.fn((_props: { height: number }) => null), + useEntityList: vi.fn(() => ({ data: undefined, isFetching: false })), +})); + +vi.mock('@quent/client', () => ({ + useEntityList: mocks.useEntityList, +})); + +vi.mock('@quent/hooks', () => ({ + useDebouncedZoomRange: () => ({ start: 0.2, end: 0.6 }), + useSelectedNodeIds: () => new Set(['operator-1']), +})); + +vi.mock('@quent/components', () => ({ + LONG_ENTITIES_TIMELINE_HEIGHT: 110, + LongEntitiesGantt: mocks.longEntitiesGantt, + buildLongEntityEntries: () => [], + getLongEntitiesThreshold: mocks.getLongEntitiesThreshold, +})); + +describe('LongEntitiesRow', () => { + it('filters the entity request using the visible timeline window', () => { + render( + + ); + + expect(mocks.getLongEntitiesThreshold.mock.calls[0]?.[0]).toBeCloseTo(0.4); + expect(mocks.useEntityList).toHaveBeenCalledWith( + expect.objectContaining({ + window: { start: 0.2, end: 0.6 }, + operatorIds: ['operator-1'], + minUsageSeconds: 0.06, + }) + ); + expect(mocks.longEntitiesGantt.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ height: 110 }) + ); + }); +}); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index c4045c5c3..133ebb6ec 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -3,18 +3,17 @@ import { useMemo } from 'react'; import { useEntityList } from '@quent/client'; -import { useSelectedNodeIds } from '@quent/hooks'; +import { useDebouncedZoomRange, useSelectedNodeIds } from '@quent/hooks'; import type { FsmTypeDecl } from '@quent/utils'; import { - DEFAULT_TIMELINE_HEIGHT, + LONG_ENTITIES_TIMELINE_HEIGHT, LongEntitiesGantt, buildLongEntityEntries, + getLongEntitiesThreshold, } from '@quent/components'; /** Max entities fetched per resource; longest-usage-first, so this keeps the top N. */ const MAX_ENTITIES = 200; -// Stress-test the Gantt with nearly every task. -const MIN_USAGE_SECONDS = 0.001; type LongEntitiesRowProps = { engineId: string; @@ -40,24 +39,27 @@ export function LongEntitiesRow({ isDark, }: LongEntitiesRowProps) { const selectedNodeIds = useSelectedNodeIds(); - // The entity endpoint filters by a single operator; honor the DAG selection - // only when exactly one operator is picked, otherwise show all. - const operatorId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0]! : null; + const debouncedZoomRange = useDebouncedZoomRange(); + const operatorIds = useMemo(() => [...selectedNodeIds], [selectedNodeIds]); + const window = + debouncedZoomRange.end > debouncedZoomRange.start + ? debouncedZoomRange + : { start: 0, end: durationSeconds }; + const minUsageSeconds = getLongEntitiesThreshold(window.end - window.start); const { data, isFetching } = useEntityList({ engineId, queryId, - window: { start: 0, end: durationSeconds }, - operatorId, - minUsageSeconds: MIN_USAGE_SECONDS, + window, + operatorIds, + minUsageSeconds, sortDir: 'Desc', maxItems: MAX_ENTITIES, filter: { scope: { Resource: { resource_id: resourceId } } }, }); const entries = useMemo( - () => - data ? buildLongEntityEntries(data.items, fsmTypes, isDark ? 'dark' : 'light') : [], + () => (data ? buildLongEntityEntries(data.items, fsmTypes, isDark ? 'dark' : 'light') : []), [data, fsmTypes, isDark] ); @@ -65,7 +67,7 @@ export function LongEntitiesRow({ return (
    Loading entities…
    @@ -76,7 +78,7 @@ export function LongEntitiesRow({ ); From ada35dc94cef03621e1bc0af475bbaedbc3903e7 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 3 Aug 2026 11:23:58 -0600 Subject: [PATCH 12/75] Move labels to inside boxes, tweak long entity threshold --- .../components/src/lib/timeline.utils.test.ts | 12 ++-- .../components/src/lib/timeline.utils.ts | 4 +- .../src/long-entities/LongEntitiesGantt.tsx | 56 ++++--------------- 3 files changed, 18 insertions(+), 54 deletions(-) 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..407bda166 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(0.00001); }); - 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(0.000005); + expect(getLongEntitiesThreshold(400)).toBe(0.00002); }); 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 bb4f1f0bc..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); diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 6370c221a..7dfe5ecb1 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -18,17 +18,14 @@ import { getLongEntitySegmentsAtTimestamp } from './utils'; import { PointerTooltipPortal } from '../ui/gantt-tooltip'; import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; -export const LONG_ENTITIES_TIMELINE_HEIGHT = 110; -const STATE_FONT_SIZE = 9; -const TASK_FONT_SIZE = 10; -/** Task-name line drawn above each bar (~2px around the text). */ -const TASK_LABEL_HEIGHT = TASK_FONT_SIZE + 4; -const BAR_HEIGHT = STATE_FONT_SIZE + 4; +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 = 2; -const ROW_HEIGHT = TASK_LABEL_HEIGHT + BAR_HEIGHT + ROW_GAP; +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 = 3; +const CORNER_RADIUS = 2; const SERIES_NAME = 'long-entity-segment'; /** Flat segment datum: one ECharts custom-series item per state span. */ @@ -112,9 +109,7 @@ export function LongEntitiesGantt({ const startPoint = api.coord([startMs, rowIndex]); const endPoint = api.coord([endMs, rowIndex]); - // Center the task-label + bar cluster within the row band; bar sits below the label. - const clusterTop = startPoint[1] - (TASK_LABEL_HEIGHT + BAR_HEIGHT) / 2; - const barTop = clusterTop + TASK_LABEL_HEIGHT; + 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 }; @@ -148,19 +143,18 @@ export function LongEntitiesGantt({ }, }; - // State name centered inside each segment box (skipped when too narrow to read). - const stateChildren = + const labelChildren = clippedShape.width > 10 ? [ { type: 'text' as const, style: { - text: segment.stateName, + 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: STATE_FONT_SIZE, + fontSize: LABEL_FONT_SIZE, fill: textColor, overflow: 'truncate' as const, width: Math.max(0, clippedShape.width - 6), @@ -169,35 +163,7 @@ export function LongEntitiesGantt({ ] : []; - // Task name above the bar, drawn once (first segment) spanning the whole entity. - const entityRight = api.coord([entry.endMs, rowIndex])[0]; - const labelLeft = clippedShape.x; - const labelRight = clipBound - ? Math.min(entityRight, clipBound.x + clipBound.width) - : entityRight; - const labelWidth = Math.max(0, labelRight - labelLeft); - const taskChildren = - isFirst && labelWidth > 4 - ? [ - { - type: 'text' as const, - style: { - text: entry.label, - x: labelLeft + 1, - y: clusterTop + TASK_LABEL_HEIGHT / 2, - textAlign: 'left' as const, - textVerticalAlign: 'middle' as const, - fontSize: TASK_FONT_SIZE, - fontWeight: 500 as const, - fill: textColor, - overflow: 'truncate' as const, - width: Math.max(0, labelWidth - 2), - }, - }, - ] - : []; - - return { type: 'group' as const, children: [rect, ...stateChildren, ...taskChildren] }; + return { type: 'group' as const, children: [rect, ...labelChildren] }; }, [entries, customSeriesData, textColor] ); From 45c30f823e237ea8b18ab49647145859cc08b68f Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 3 Aug 2026 11:29:55 -0600 Subject: [PATCH 13/75] Label entity/operator rows --- ui/src/components/QueryResourceTree.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 784e98475..5baed8754 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -90,6 +90,15 @@ function createLongEntitiesRow(resourceId: string): TreeTableItem { }; } +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) @@ -246,9 +255,11 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr ), render: ({ item }: { item: TreeTableItem; level: number }) => { switch (item.type) { - case OPERATOR_TIMELINE_ROW_TYPE: + case OPERATOR_TIMELINE_ROW_TYPE: { + return Operators; + } case LONG_ENTITIES_ROW_TYPE: { - return null; + return Entities; } default: { const selectedType = From 082284db2f6893ebffb98aed018267094384ed47 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 3 Aug 2026 12:19:08 -0600 Subject: [PATCH 14/75] Simple pagination for entities gantt charts --- .../@quent/client/src/entityList.test.ts | 24 +++- ui/packages/@quent/client/src/entityList.ts | 40 +++++- ui/packages/@quent/client/src/index.ts | 4 +- ui/src/components/LongEntitiesRow.test.tsx | 115 ++++++++++++++++-- ui/src/components/LongEntitiesRow.tsx | 66 ++++++---- 5 files changed, 215 insertions(+), 34 deletions(-) diff --git a/ui/packages/@quent/client/src/entityList.test.ts b/ui/packages/@quent/client/src/entityList.test.ts index 61649e12c..95f8fc27e 100644 --- a/ui/packages/@quent/client/src/entityList.test.ts +++ b/ui/packages/@quent/client/src/entityList.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'; import { keepPreviousData } from '@tanstack/react-query'; -import { entityListQueryOptions } from './entityList'; +import type { EntityListResponse } from '@quent/utils'; +import { entityListInfiniteQueryOptions, entityListQueryOptions } from './entityList'; describe('entityListQueryOptions', () => { it('copies selected operator IDs into the entity-list request', () => { @@ -13,6 +14,8 @@ describe('entityListQueryOptions', () => { window: { start: 0, end: 1 }, operatorIds: ['operator-1'], minUsageSeconds: 0.15, + maxItems: 20, + page: 2, }); expect(options.queryKey).toEqual([ @@ -22,9 +25,28 @@ describe('entityListQueryOptions', () => { 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 36c8dc4fc..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 { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query'; import type { EntityListRequest, EntityScope, @@ -28,6 +34,8 @@ 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({ @@ -39,6 +47,7 @@ function buildRequest({ sortKey = 'UsageDuration', sortDir = 'Desc', maxItems = null, + page = 0, }: EntityListParams): EntityListRequest { return { entry: { @@ -49,7 +58,7 @@ function buildRequest({ min_usage_s: minUsageSeconds, }, sort: { key: sortKey, dir: sortDir }, - page: maxItems != null ? { page: 0, max: maxItems } : null, + page: maxItems != null ? { page, max: maxItems } : null, application: { operator_ids: operatorIds }, }, app_params: { query_id: queryId }, @@ -73,3 +82,30 @@ 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/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx index 7c846c9cd..3d9e7af5a 100644 --- a/ui/src/components/LongEntitiesRow.test.tsx +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -1,18 +1,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import type { ButtonHTMLAttributes } 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: { height: number }) => null), - useEntityList: vi.fn(() => ({ data: undefined, isFetching: false })), + longEntitiesGantt: vi.fn((_props: { entries: unknown[]; height: number }) => null), + useInfiniteEntityList: vi.fn(), })); vi.mock('@quent/client', () => ({ - useEntityList: mocks.useEntityList, + useInfiniteEntityList: mocks.useInfiniteEntityList, })); vi.mock('@quent/hooks', () => ({ @@ -21,13 +24,31 @@ vi.mock('@quent/hooks', () => ({ })); vi.mock('@quent/components', () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), LONG_ENTITIES_TIMELINE_HEIGHT: 110, - LongEntitiesGantt: mocks.longEntitiesGantt, - buildLongEntityEntries: () => [], + LongEntitiesGantt: (props: { entries: unknown[]; height: number }) => { + mocks.longEntitiesGantt(props); + return
    ; + }, + 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.useEntityList).toHaveBeenCalledWith( + expect(mocks.useInfiniteEntityList).toHaveBeenCalledWith( expect.objectContaining({ window: { start: 0.2, end: 0.6 }, operatorIds: ['operator-1'], minUsageSeconds: 0.06, + maxItems: 200, }) ); expect(mocks.longEntitiesGantt.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ height: 110 }) ); }); + + 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' + ); + 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'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index 133ebb6ec..a54336483 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -2,18 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo } from 'react'; -import { useEntityList } from '@quent/client'; +import { useInfiniteEntityList } from '@quent/client'; import { useDebouncedZoomRange, useSelectedNodeIds } from '@quent/hooks'; import type { FsmTypeDecl } from '@quent/utils'; import { + Button, LONG_ENTITIES_TIMELINE_HEIGHT, LongEntitiesGantt, buildLongEntityEntries, getLongEntitiesThreshold, } from '@quent/components'; -/** Max entities fetched per resource; longest-usage-first, so this keeps the top N. */ -const MAX_ENTITIES = 200; +const ENTITIES_PER_PAGE = 100; type LongEntitiesRowProps = { engineId: string; @@ -47,21 +47,25 @@ export function LongEntitiesRow({ : { start: 0, end: durationSeconds }; const minUsageSeconds = getLongEntitiesThreshold(window.end - window.start); - const { data, isFetching } = useEntityList({ - engineId, - queryId, - window, - operatorIds, - minUsageSeconds, - sortDir: 'Desc', - maxItems: MAX_ENTITIES, - filter: { scope: { Resource: { resource_id: resourceId } } }, - }); + const { data, fetchNextPage, hasNextPage, isFetching, isPlaceholderData } = useInfiniteEntityList( + { + engineId, + queryId, + window, + 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( - () => (data ? buildLongEntityEntries(data.items, fsmTypes, isDark ? 'dark' : 'light') : []), - [data, fsmTypes, isDark] + () => buildLongEntityEntries(entities, fsmTypes, isDark ? 'dark' : 'light'), + [entities, fsmTypes, isDark] ); + const totalEntities = data?.pages[data.pages.length - 1]?.total ?? entities.length; if (!data && isFetching) { return ( @@ -75,11 +79,31 @@ export function LongEntitiesRow({ } return ( - +
    + + + {hasNextPage && !isPlaceholderData && ( +
    + +
    + )} +
    ); } From bc5a38c62da91881f44916e45b7ffdb280056937 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 3 Aug 2026 12:21:02 -0600 Subject: [PATCH 15/75] Skeleton loader instead of text --- ui/src/components/LongEntitiesRow.test.tsx | 31 ++++++++++++++++++++-- ui/src/components/LongEntitiesRow.tsx | 9 +++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ui/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx index 3d9e7af5a..808710c5c 100644 --- a/ui/src/components/LongEntitiesRow.test.tsx +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ButtonHTMLAttributes } from 'react'; +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'; @@ -32,6 +32,7 @@ vi.mock('@quent/components', () => ({ mocks.longEntitiesGantt(props); return
    ; }, + Skeleton: (props: HTMLAttributes) =>
    , buildLongEntityEntries: mocks.buildLongEntityEntries, getLongEntitiesThreshold: mocks.getLongEntitiesThreshold, })); @@ -67,7 +68,7 @@ describe('LongEntitiesRow', () => { window: { start: 0.2, end: 0.6 }, operatorIds: ['operator-1'], minUsageSeconds: 0.06, - maxItems: 200, + maxItems: 100, }) ); expect(mocks.longEntitiesGantt.mock.calls[0]?.[0]).toEqual( @@ -75,6 +76,32 @@ describe('LongEntitiesRow', () => { ); }); + 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' }; diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index a54336483..17e29c3a9 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -9,6 +9,7 @@ import { Button, LONG_ENTITIES_TIMELINE_HEIGHT, LongEntitiesGantt, + Skeleton, buildLongEntityEntries, getLongEntitiesThreshold, } from '@quent/components'; @@ -70,10 +71,14 @@ export function LongEntitiesRow({ if (!data && isFetching) { return (
    - Loading entities… + + +
    ); } From 4e9885148d0484f553a2012858d0d89f91167391 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 5 Aug 2026 12:19:08 -0600 Subject: [PATCH 16/75] fix: add xs button variant, linting/test fixes --- ui/packages/@quent/client/src/api.ts | 13 ------------- .../components/src/lib/timeline.utils.test.ts | 6 +++--- .../components/src/timeline/ResourceTimeline.tsx | 6 +----- ui/packages/@quent/components/src/ui/button.tsx | 1 + 4 files changed, 5 insertions(+), 21 deletions(-) diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 1549f4e11..9c224225a 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -120,19 +120,6 @@ export async function fetchBulkTimelines( }); } -/** Fetch a ranked, paged list of a query's entities. */ -export async function fetchEntityList( - engineId: string, - request: EntityListRequest -): Promise { - return apiFetch(`/engines/${engineId}/entities`, { - fetchOptions: { - method: 'POST', - body: JSON.stringify(request), - }, - }); -} - /** * Fetch a ranked, paged list of a query's entities (longest resource-usage * span first). Backs the long-entities Gantt view. 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 407bda166..d6a2966d8 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.test.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.test.ts @@ -120,12 +120,12 @@ describe('nanosToMs', () => { describe('getLongEntitiesThreshold', () => { it('returns the bin-scaled threshold for a 200-second window', () => { - expect(getLongEntitiesThreshold(200)).toBe(0.00001); + expect(getLongEntitiesThreshold(200)).toBe(2); }); it('scales linearly with the visible window', () => { - expect(getLongEntitiesThreshold(100)).toBe(0.000005); - expect(getLongEntitiesThreshold(400)).toBe(0.00002); + 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/timeline/ResourceTimeline.tsx b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx index 808b2983a..6b1ab24ff 100644 --- a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx +++ b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx @@ -288,11 +288,7 @@ export function ResourceTimeline({ onHoverChange={handleHoverChange} /> {showTooltip && ( - + )}
    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', From b5652f98e0e99c3606acdb1a0626668c7656d362 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 5 Aug 2026 12:26:34 -0600 Subject: [PATCH 17/75] refactor: move timeline settings to new component, but remove from toolbar while no settings --- ui/packages/@quent/components/src/index.ts | 1 + .../src/timeline/TimelineSettingsPopover.tsx | 23 +++++++++++++++++++ .../src/timeline/TimelineToolbar.tsx | 19 ++------------- 3 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 ui/packages/@quent/components/src/timeline/TimelineSettingsPopover.tsx diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 3839f9e2c..c880f6ad9 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -146,6 +146,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'; 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 ce0c2c59d..3255b2958 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineToolbar.tsx @@ -1,12 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Maximize2, Settings } from 'lucide-react'; +import { Maximize2 } from 'lucide-react'; import { useSetZoomRange, useSetDebouncedZoomRange } from '@quent/hooks'; -import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'; 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 setZoomRange = useSetZoomRange(); const setDebouncedZoomRange = useSetDebouncedZoomRange(); @@ -27,20 +26,6 @@ export function TimelineToolbar({ durationSeconds }: { durationSeconds: number } Reset zoom - -
    - - - - - - No settings yet. - ); } From 83593c1724db4f6ea197e5a1bd3d7a3c2c1b993d Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 5 Aug 2026 13:51:03 -0600 Subject: [PATCH 18/75] refactor: consolidate colordot, colorcircle; only show entities that have same fsm states as timeline; portal tooltip to new re-usable component --- .../@quent/components/src/dag/ColorDot.tsx | 7 -- .../components/src/dag/DataFlowMatrix.tsx | 6 +- ui/packages/@quent/components/src/index.ts | 2 + .../src/long-entities/LongEntitiesGantt.tsx | 2 +- .../src/long-entities/utils.test.ts | 41 ++++++++++++ .../components/src/long-entities/utils.ts | 14 +++- .../src/timeline/TimelineTooltip.tsx | 6 +- .../@quent/components/src/ui/color-circle.tsx | 14 ---- .../@quent/components/src/ui/color-swatch.tsx | 24 +++++++ .../components/src/ui/gantt-tooltip.tsx | 56 +--------------- .../src/ui/pointer-tooltip-portal.tsx | 64 +++++++++++++++++++ ui/src/components/LongEntitiesRow.test.tsx | 31 ++++++++- ui/src/components/LongEntitiesRow.tsx | 13 +++- 13 files changed, 192 insertions(+), 88 deletions(-) delete mode 100644 ui/packages/@quent/components/src/dag/ColorDot.tsx delete mode 100644 ui/packages/@quent/components/src/ui/color-circle.tsx create mode 100644 ui/packages/@quent/components/src/ui/color-swatch.tsx create mode 100644 ui/packages/@quent/components/src/ui/pointer-tooltip-portal.tsx 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/index.ts b/ui/packages/@quent/components/src/index.ts index c880f6ad9..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 { diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 7dfe5ecb1..958f4df67 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -15,7 +15,7 @@ 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/gantt-tooltip'; +import { PointerTooltipPortal } from '../ui/pointer-tooltip-portal'; import { EntityTooltipContent, type ActiveMark } from '../timeline/TimelineTooltip'; export const LONG_ENTITIES_TIMELINE_HEIGHT = 75; 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 a5fec247d..8a45938f5 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.test.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.test.ts @@ -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), diff --git a/ui/packages/@quent/components/src/long-entities/utils.ts b/ui/packages/@quent/components/src/long-entities/utils.ts index f92ffec29..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; diff --git a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx index d66ec622d..a210aebcf 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineTooltip.tsx @@ -8,7 +8,7 @@ import { cn, type DynamicAttribute, } from '@quent/utils'; -import { ColorCircle } from '../ui/color-circle'; +import { ColorSwatch } from '../ui/color-swatch'; import { DataText } from '../ui/data-text'; /** A timeline mark under the hover cursor, as shown in the tooltip. */ @@ -44,7 +44,7 @@ const TooltipSeriesStat = ({ }) => { return (
  • - {series.color && } + {series.color && } {series.name} {fmt(series.value ?? 0)} @@ -206,7 +206,7 @@ function ActiveMarksSection({ marks }: { marks: ActiveMark[] }) { {marks.map((m, i) => (
    - + {m.label} {m.stateName}
    diff --git a/ui/packages/@quent/components/src/ui/color-circle.tsx b/ui/packages/@quent/components/src/ui/color-circle.tsx deleted file mode 100644 index 8376522ae..000000000 --- a/ui/packages/@quent/components/src/ui/color-circle.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { cn } from '@quent/utils'; - -export function ColorCircle({ color, className }: { color: string; className?: string }) { - return ( - - ); -} 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 index fc465a4b5..2bb4ebd69 100644 --- a/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx +++ b/ui/packages/@quent/components/src/ui/gantt-tooltip.tsx @@ -1,16 +1,11 @@ // 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'; - -import { ColorCircle } from './color-circle'; +import { ColorSwatch } from './color-swatch'; import { DataText } from './data-text'; +import { PointerTooltipPortal } from './pointer-tooltip-portal'; import type { GanttHover } from '../gantt-chart/hover'; -const POINTER_OFFSET = 12; -const VIEWPORT_MARGIN = 4; - export interface GanttTooltipItem { id: string; color: string; @@ -32,7 +27,7 @@ export function GanttTooltipPortal({
      {items.map(item => (
    • - + {item.name} {item.detail && ( @@ -46,48 +41,3 @@ export function GanttTooltipPortal({ ); } - -export function PointerTooltipPortal({ - hover, - children, -}: { - hover: GanttHover | null; - children: ReactNode; -}) { - if (!hover) return null; - return {children}; -} - -function PositionedPointerTooltip({ hover, children }: { hover: GanttHover; 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/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/src/components/LongEntitiesRow.test.tsx b/ui/src/components/LongEntitiesRow.test.tsx index 808710c5c..28a74617a 100644 --- a/ui/src/components/LongEntitiesRow.test.tsx +++ b/ui/src/components/LongEntitiesRow.test.tsx @@ -76,6 +76,27 @@ describe('LongEntitiesRow', () => { ); }); + 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, @@ -147,7 +168,8 @@ describe('LongEntitiesRow', () => { expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( [firstEntity, secondEntity], {}, - 'light' + 'light', + null ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); @@ -175,7 +197,12 @@ describe('LongEntitiesRow', () => { ); expect(screen.queryByText('Loading entities…')).not.toBeInTheDocument(); - expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith([previousEntity], {}, 'light'); + expect(mocks.buildLongEntityEntries).toHaveBeenLastCalledWith( + [previousEntity], + {}, + 'light', + null + ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); }); diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index 17e29c3a9..807cd7b39 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -24,6 +24,8 @@ type LongEntitiesRowProps = { 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'; }; /** @@ -38,6 +40,7 @@ export function LongEntitiesRow({ durationSeconds, fsmTypes, isDark, + fsmStateScope = 'all', }: LongEntitiesRowProps) { const selectedNodeIds = useSelectedNodeIds(); const debouncedZoomRange = useDebouncedZoomRange(); @@ -63,8 +66,14 @@ export function LongEntitiesRow({ const entities = useMemo(() => data?.pages.flatMap(page => page.items) ?? [], [data]); const entries = useMemo( - () => buildLongEntityEntries(entities, fsmTypes, isDark ? 'dark' : 'light'), - [entities, fsmTypes, isDark] + () => + 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; From 147e20d271f2328ad8a2e3910faeb3fbae4314f2 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Wed, 5 Aug 2026 14:52:59 -0600 Subject: [PATCH 19/75] refactor(ui): preserve API ordering before inserting new entities, less jumping around when zooming/panning --- .../components/src/gantt-chart/utils.test.ts | 22 +++++++--- .../components/src/gantt-chart/utils.ts | 42 ++++++++++++++----- 2 files changed, 47 insertions(+), 17 deletions(-) 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..cfa731f4e 100644 --- a/ui/packages/@quent/components/src/gantt-chart/utils.test.ts +++ b/ui/packages/@quent/components/src/gantt-chart/utils.test.ts @@ -56,7 +56,7 @@ describe('stackIntervalsIntoRows', () => { expect(entries.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); @@ -64,11 +64,21 @@ describe('stackIntervalsIntoRows', () => { expect([a.rowIndex, b.rowIndex, c.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]; + it('uses input order as the packing priority', () => { + const rankedFirst = span(5, 10); + const rankedSecond = span(0, 6); + const entries = [rankedFirst, rankedSecond]; expect(stackIntervalsIntoRows(entries)).toBe(entries); - expect(entries.map(entry => entry.rowIndex)).toEqual([0, 0]); + expect(entries.map(entry => entry.rowIndex)).toEqual([0, 1]); + }); + + it('does not move existing entries when new entries are appended', () => { + const existing = [span(5, 10), span(0, 6), span(10, 20)]; + stackIntervalsIntoRows(existing); + 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..bd6ba411f 100644 --- a/ui/packages/@quent/components/src/gantt-chart/utils.ts +++ b/ui/packages/@quent/components/src/gantt-chart/utils.ts @@ -20,25 +20,45 @@ export function clipRectByRect(target: GanttRect, bounds: GanttRect): GanttRect return undefined; } -/** Greedily pack intervals into non-overlapping rows. */ +type PackedInterval = { startMs: number; endMs: 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; +} + +/** 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: T[]): T[] { if (entries.length === 0) return entries; - const sorted = [...entries].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs); - const rowEndMs: number[] = []; - - for (const entry of sorted) { + const rows: PackedInterval[][] = []; + 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); - } + + if (row === rows.length) rows.push([]); + rows[row]!.splice(insertionIndex, 0, entry); entry.rowIndex = row; } From 2d30577cf9057d319410a46894da8da96f85af95 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 7 Aug 2026 13:14:56 -0500 Subject: [PATCH 20/75] Utilize thin scrollbar --- ui/src/components/entities-table/EntityDetailPanel.tsx | 3 ++- ui/src/components/entities-table/EntityResults.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index 8b1019441..985f5e5e9 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { thinScrollbarClass } from '@quent/components'; import { formatAttributeValue, formatDuration } from '@quent/utils'; import type { DynamicAttribute, FiniteStateMachine } from '@quent/utils'; @@ -25,7 +26,7 @@ export function EntityDetailPanel({ fsm, resourceLabel }: EntityDetailPanelProps
      {fsm.type_name}
      {fsm.id}
    -
      +
        {fsm.transitions.map((transition, index) => { const nextTransition = fsm.transitions[index + 1]; return ( diff --git a/ui/src/components/entities-table/EntityResults.tsx b/ui/src/components/entities-table/EntityResults.tsx index 88651517b..a61ad2c8d 100644 --- a/ui/src/components/entities-table/EntityResults.tsx +++ b/ui/src/components/entities-table/EntityResults.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { ChevronFirst, ChevronLast } from 'lucide-react'; @@ -10,6 +10,7 @@ import { TableHead, TableHeader, TableRow, + thinScrollbarClass, } from '@quent/components'; import { cn, formatDuration } from '@quent/utils'; import type { FiniteStateMachine } from '@quent/utils'; @@ -56,6 +57,7 @@ export function EntityResults({ aria-busy={requestPending} className={cn( 'flex-1 min-h-0 overflow-auto transition-opacity duration-150', + thinScrollbarClass, requestPending && rows.length > 0 ? 'opacity-60' : 'opacity-100' )} > From e7e181a1aef5e67a4371cb3fa03c54aba47b83f4 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Fri, 7 Aug 2026 14:31:49 -0500 Subject: [PATCH 21/75] Make table header sticky; add jump to page input box --- .../entities-table/EntityResults.tsx | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/ui/src/components/entities-table/EntityResults.tsx b/ui/src/components/entities-table/EntityResults.tsx index a61ad2c8d..38d8e7c54 100644 --- a/ui/src/components/entities-table/EntityResults.tsx +++ b/ui/src/components/entities-table/EntityResults.tsx @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useState } from 'react'; import { ChevronFirst, ChevronLast } from 'lucide-react'; import { Button, + Input, Table, TableBody, TableCell, @@ -66,8 +68,8 @@ export function EntityResults({ Failed to load entities: {error instanceof Error ? error.message : 'unknown error'}
  • ) : ( - - +
    + Instance Type @@ -119,9 +121,12 @@ export function EntityResults({ > Previous - - Page {page + 1} / {pageCount} - + + +
    + +
    + , + document.body + ); +} From 0e5d4f0e08ac555dcb233503ba99f1e0fdfc8052 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 11:01:43 -0500 Subject: [PATCH 29/75] Visual upgrades - highlight high states, apply coloring, format capacity values, revised header --- .../entities-table/EntityDetailPanel.tsx | 103 +++++++++++++++--- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index dec26d555..fa019a350 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -1,9 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; import { thinScrollbarClass } from '@quent/components'; -import { formatAttributeValue, formatDuration, unwrapTaggedValue } from '@quent/utils'; +import { + formatAttributeValue, + formatDuration, + formatBytes, + getColorForKey, + unwrapTaggedValue, +} from '@quent/utils'; import type { DynamicAttribute, FiniteStateMachine } from '@quent/utils'; +import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; interface EntityDetailPanelProps { fsm: FiniteStateMachine | null; @@ -11,7 +20,20 @@ interface EntityDetailPanelProps { operatorLabel: (id: string) => string; } +function isBytesStat(name: string): boolean { + return ( + name.includes('_bytes') || + name.endsWith('_byte') || + name.startsWith('bytes_') || + name === 'bytes' + ); +} + export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityDetailPanelProps) { + const { theme } = useTheme(); + const paletteTheme = theme === THEME_DARK ? ('dark' as const) : ('light' as const); + const [copied, setCopied] = useState(false); + if (!fsm) { return (
    @@ -20,33 +42,77 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD ); } + const firstTs = fsm.transitions[0]?.timestamp ?? 0; + const lastTs = fsm.transitions[fsm.transitions.length - 1]?.timestamp ?? firstTs; + const totalSpanMs = (lastTs - firstTs) * 1000; + + function copyId() { + void navigator.clipboard.writeText(fsm!.id); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + return (
    -
    -
    {fsm.instance_name}
    -
    {fsm.type_name}
    -
    {fsm.id}
    + {/* Compact header: name + type badge on one line, UUID + copy on second */} +
    +
    + {fsm.instance_name} + + {fsm.type_name} + +
    +
    + + {fsm.id} + + +
    +
      {fsm.transitions.map((transition, index) => { const nextTransition = fsm.transitions[index + 1]; + const durationMs = nextTransition + ? (nextTransition.timestamp - transition.timestamp) * 1000 + : null; + const isBottleneck = + durationMs != null && totalSpanMs > 0 && durationMs / totalSpanMs > 0.5; + const stateColor = getColorForKey(transition.name, paletteTheme); + return ( -
    1. -
      +
    2. + {/* State name + duration (prominent) + absolute timestamp (secondary) */} +
      {index + 1}. {transition.name} - - {transition.timestamp.toFixed(3)}s - {nextTransition && ( - <> - {' '} - · for{' '} - {formatDuration((nextTransition.timestamp - transition.timestamp) * 1000)} - +
      + {durationMs != null && ( + + {formatDuration(durationMs)} + )} - + + @{transition.timestamp.toFixed(3)}s + +
      + {transition.usages.length > 0 && (
        {transition.usages.map((usage, usageIndex) => ( @@ -55,7 +121,9 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD {usage.capacities.map(([name, capacity], capacityIndex) => ( {name} - {capacity != null ? `=${capacity}` : ''} + {capacity != null + ? `=${isBytesStat(name) ? formatBytes(capacity) : String(capacity)}` + : ''} ))} @@ -83,7 +151,6 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD ); } - function AttributeRows({ attributes, derived, From 92c9f1d0a7ab298b2fc16fd10e2e792109334dfd Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 11:17:11 -0500 Subject: [PATCH 30/75] Add duration bars to each FSM state --- .../entities-table/EntityDetailPanel.tsx | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index fa019a350..a50924d81 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -46,6 +46,48 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD const lastTs = fsm.transitions[fsm.transitions.length - 1]?.timestamp ?? firstTs; const totalSpanMs = (lastTs - firstTs) * 1000; + // Precompute per-transition durations (null for the final state) + const durations = fsm.transitions.map((t, i) => { + const next = fsm.transitions[i + 1]; + return next ? (next.timestamp - t.timestamp) * 1000 : null; + }); + + // Find the state that consumed the most time + let dominantState: { name: string; pct: number; color: string } | null = null; + if (totalSpanMs > 0) { + let maxMs = 0; + let maxIdx = -1; + durations.forEach((d, i) => { + if (d != null && d > maxMs) { + maxMs = d; + maxIdx = i; + } + }); + if (maxIdx >= 0) { + const name = fsm.transitions[maxIdx]!.name; + dominantState = { + name, + pct: (maxMs / totalSpanMs) * 100, + color: getColorForKey(name, paletteTheme), + }; + } + } + + // Find data volume from derived attributes (last bytes-stat with a numeric value) + let dataVolume: string | null = null; + for (let i = fsm.transitions.length - 1; i >= 0; i--) { + for (const attr of fsm.transitions[i]!.derived_attributes) { + if (isBytesStat(attr.key) && attr.value != null) { + const raw = unwrapTaggedValue(attr.value); + if (typeof raw === 'number' || typeof raw === 'bigint') { + dataVolume = formatBytes(raw); + break; + } + } + } + if (dataVolume) break; + } + function copyId() { void navigator.clipboard.writeText(fsm!.id); setCopied(true); @@ -76,15 +118,37 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD
    + {/* Summary strip */} +
    +
    + Total span + {formatDuration(totalSpanMs)} +
    + {dominantState && ( +
    + Dominant state + + {dominantState.name} · {dominantState.pct.toFixed(1)}% + +
    + )} + {dataVolume && ( +
    + Data volume + {dataVolume} +
    + )} +
    +
      {fsm.transitions.map((transition, index) => { - const nextTransition = fsm.transitions[index + 1]; - const durationMs = nextTransition - ? (nextTransition.timestamp - transition.timestamp) * 1000 - : null; + const durationMs = durations[index] ?? null; const isBottleneck = durationMs != null && totalSpanMs > 0 && durationMs / totalSpanMs > 0.5; const stateColor = getColorForKey(transition.name, paletteTheme); + const pct = durationMs != null && totalSpanMs > 0 + ? Math.min(100, (durationMs / totalSpanMs) * 100) + : null; return (
    + {/* Proportional duration bar */} + {pct != null && ( +
    +
    +
    + )} + {transition.usages.length > 0 && (
      {transition.usages.map((usage, usageIndex) => ( From 407e83c2738278712eed16d9016a721713fec68c Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 12:42:32 -0500 Subject: [PATCH 31/75] Create fsm memory capacity chart component and export --- .../src/fsm-chart/FsmCapacityChart.tsx | 140 ++++++++++++++++++ .../@quent/components/src/fsm-chart/index.ts | 5 + 2 files changed, 145 insertions(+) create mode 100644 ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx create mode 100644 ui/packages/@quent/components/src/fsm-chart/index.ts diff --git a/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx new file mode 100644 index 000000000..d5b45df4e --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/FsmCapacityChart.tsx @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import EChartsReactCore from 'echarts-for-react/lib/core'; +import type { FsmTransition } from '@quent/utils'; +import { formatBytes } from '@quent/utils'; +import { echarts } from '../lib/echarts'; +import { useChartResize } from '../lib/useChartResize'; +import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; + +const CHART_HEIGHT = 90; + +function isBytesStat(name: string): boolean { + return ( + name.includes('_bytes') || + name.endsWith('_byte') || + name.startsWith('bytes_') || + name === 'bytes' + ); +} + +interface CapacitySeries { + label: string; + // Full-length array aligned to transitions — null where no reading exists + data: Array; +} + +export interface FsmCapacityChartProps { + transitions: FsmTransition[]; + isDark: boolean; + resourceLabel: (id: string) => string; +} + +export function FsmCapacityChart({ transitions, isDark, resourceLabel }: FsmCapacityChartProps) { + const { themeName } = useTimelineEchartsTheme(isDark); + const { handleChartReady } = useChartResize(); + + const { series, stateLabels } = useMemo(() => { + const n = transitions.length; + const stateLabels = transitions.map((t, i) => `${i + 1}. ${t.name}`); + + // Build per-resource full-length arrays (null = no reading at that state) + const dataMap = new Map>(); + const labelMap = new Map(); + + transitions.forEach((t, i) => { + t.usages.forEach(usage => { + const resourceName = resourceLabel(usage.resource); + usage.capacities.forEach(([name, cap]) => { + if (cap == null || !isBytesStat(name)) return; + const key = `${usage.resource} ${name}`; + if (!dataMap.has(key)) { + dataMap.set(key, Array(n).fill(null)); + labelMap.set(key, name === 'capacity_bytes' ? resourceName : `${resourceName} ${name}`); + } + dataMap.get(key)![i] = Number(cap); + }); + }); + }); + + // Only show resources with readings in at least 2 states + const series: CapacitySeries[] = [...dataMap.entries()] + .filter(([, data]) => data.filter(v => v !== null).length >= 2) + .map(([key, data]) => ({ label: labelMap.get(key) ?? key, data })); + + return { series, stateLabels }; + }, [transitions, resourceLabel]); + + const option = useMemo( + () => ({ + animation: false, + grid: { left: 52, right: 8, top: 8, bottom: 36 }, + xAxis: { + type: 'category' as const, + data: stateLabels, + boundaryGap: false, + axisLabel: { + show: true, + fontSize: 9, + interval: 0, + // Show only the state number to save space; full name is in the tooltip + formatter: (_val: string, idx: number) => String(idx + 1), + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + axisLabel: { + show: true, + fontSize: 9, + formatter: (v: number) => formatBytes(v, 0), + }, + splitLine: { show: true, lineStyle: { opacity: 0.25 } }, + minInterval: 1, + }, + tooltip: { + trigger: 'axis' as const, + formatter: (params: Array<{ seriesName: string; value: number | null; dataIndex: number }>) => { + const idx = params[0]?.dataIndex ?? 0; + const stateName = transitions[idx]?.name ?? ''; + const lines = params + .filter(p => p.value != null) + .map(p => `${p.seriesName}: ${formatBytes(p.value!)}`); + if (lines.length === 0) return ''; + return [`${idx + 1}. ${stateName}`, ...lines].join('
      '); + }, + }, + series: series.map(s => ({ + type: 'line' as const, + name: s.label, + data: s.data, + connectNulls: false, + step: 'end' as const, + symbol: 'circle', + symbolSize: 5, + lineStyle: { width: 1.5 }, + })), + }), + [series, stateLabels, transitions] + ); + + if (series.length === 0) return null; + + return ( +
      + +
      + ); +} diff --git a/ui/packages/@quent/components/src/fsm-chart/index.ts b/ui/packages/@quent/components/src/fsm-chart/index.ts new file mode 100644 index 000000000..656719665 --- /dev/null +++ b/ui/packages/@quent/components/src/fsm-chart/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { FsmCapacityChart } from './FsmCapacityChart'; +export type { FsmCapacityChartProps } from './FsmCapacityChart'; From a7301638147f11e026c4a4fd7af28b65208fb2cc Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 12:43:35 -0500 Subject: [PATCH 32/75] Render capacity chart in entity details panel --- ui/packages/@quent/components/src/index.ts | 4 ++++ ui/src/components/entities-table/EntityDetailPanel.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 48ccea499..f325c5ebd 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -244,6 +244,10 @@ export { } from './pivot-table/utils'; export type { GroupIndexDef, RowWithGroupKeys } from './pivot-table/utils'; +// ─── FSM chart components ───────────────────────────────────────────────────── +export { FsmCapacityChart } from './fsm-chart/FsmCapacityChart'; +export type { FsmCapacityChartProps } from './fsm-chart/FsmCapacityChart'; + // ─── Long-entities components ───────────────────────────────────────────────── export { LongEntitiesGantt, diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index a50924d81..a0d2ea4a3 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Check, Copy } from 'lucide-react'; -import { thinScrollbarClass } from '@quent/components'; +import { thinScrollbarClass, FsmCapacityChart } from '@quent/components'; import { formatAttributeValue, formatDuration, @@ -140,6 +140,12 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD )}
    + +
      {fsm.transitions.map((transition, index) => { const durationMs = durations[index] ?? null; From 1e2b9088ecaf12183d35b8bfa8c16bf6b2eae9c1 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 14:19:01 -0500 Subject: [PATCH 33/75] Align colors with gant; highlight selected task; clear selection with empty space click --- .../components/src/gantt-chart/GanttChart.tsx | 13 ++++++++++++- .../src/long-entities/LongEntitiesGantt.tsx | 15 ++++++++++++++- ui/src/components/EntityDetailDrawer.tsx | 3 +++ ui/src/components/LongEntitiesRow.tsx | 6 ++++++ ui/src/components/QueryResourceTree.tsx | 10 ++++++++++ .../components/entities-table/EntitiesTable.tsx | 10 ++++++++++ .../entities-table/EntityDetailPanel.tsx | 7 +++++-- .../components/entities-table/useEntityTable.ts | 1 + 8 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx index 6e2828f79..58b32b343 100644 --- a/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx +++ b/ui/packages/@quent/components/src/gantt-chart/GanttChart.tsx @@ -47,6 +47,8 @@ export interface GanttChartProps { onEvents?: EChartsEvents; gridSpacing?: GanttGridSpacing; renderTooltip?: (hover: GanttHover | null) => ReactNode; + /** Called when the user clicks the chart background (not a series item). */ + onBackgroundClick?: () => void; } export function GanttChart({ @@ -63,6 +65,7 @@ export function GanttChart({ onEvents, gridSpacing, renderTooltip, + onBackgroundClick, }: GanttChartProps) { const { themeName } = useTimelineEchartsTheme(isDark); const [hover, setHover] = useState(null); @@ -115,15 +118,23 @@ export function GanttChart({ wrapperRef.current ?? undefined ); const detachHover = renderTooltip ? observeGanttHover(instance, setHover) : undefined; + + // zrender fires click for ALL clicks; target is null when background is clicked + type ZrEvent = { target: unknown }; + const zr = (instance as unknown as { getZr: () => { on: (e: string, h: (ev: ZrEvent) => void) => void; off: (e: string, h: (ev: ZrEvent) => void) => void } }).getZr?.(); + const handleZrClick = (e: ZrEvent) => { if (!e.target) onBackgroundClick?.(); }; + zr?.on('click', handleZrClick); + const cleanup = () => { unregisterAxisPointerSync(instance); detachWheelNavigation(); detachHover?.(); + zr?.off('click', handleZrClick); if (chartCleanupRef.current === cleanup) chartCleanupRef.current = null; }; chartCleanupRef.current = cleanup; }, - [attachWheelNavigation, renderTooltip] + [attachWheelNavigation, renderTooltip, onBackgroundClick] ); const { handleChartReady, instanceRef } = useChartConnect({ diff --git a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx index 629af0435..9d13c5623 100644 --- a/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx +++ b/ui/packages/@quent/components/src/long-entities/LongEntitiesGantt.tsx @@ -42,6 +42,10 @@ export interface LongEntitiesGanttProps { /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; onEntityClick?: (entry: LongEntityEntry) => void; + /** When set, dims all entity bars except the one with this entity ID. */ + selectedEntityId?: string; + /** Called when the user clicks the chart background (not an entity bar). */ + onBackgroundClick?: () => void; } export function LongEntitiesGantt({ @@ -50,6 +54,8 @@ export function LongEntitiesGantt({ height = LONG_ENTITIES_TIMELINE_HEIGHT, isDark, onEntityClick, + selectedEntityId, + onBackgroundClick, }: LongEntitiesGanttProps) { const { textColor } = useTimelineEchartsTheme(isDark); const zoomRange = useZoomRange(); @@ -123,6 +129,10 @@ export function LongEntitiesGantt({ const clippedShape = clipBound ? clipRectByRect(rectShape, clipBound) : rectShape; if (!clippedShape) return null; + const hasSelection = selectedEntityId != null; + const isSelected = hasSelection && entry.entityId === selectedEntityId; + const opacity = hasSelection && !isSelected ? 0.3 : 1; + const color = segment.color; const isFirst = datum!.segmentIndex === 0; const isLast = datum!.segmentIndex === entry.segments.length - 1; @@ -142,6 +152,7 @@ export function LongEntitiesGantt({ fill: withOpacity(color, MARK_AREA_FILL_OPACITY), stroke: withOpacity(color, MARK_AREA_BORDER_OPACITY), lineWidth: 1, + opacity, }, }; @@ -160,6 +171,7 @@ export function LongEntitiesGantt({ fill: textColor, overflow: 'truncate' as const, width: Math.max(0, clippedShape.width - 6), + opacity, }, }, ] @@ -167,7 +179,7 @@ export function LongEntitiesGantt({ return { type: 'group' as const, children: [rect, ...labelChildren] }; }, - [entries, customSeriesData, textColor] + [entries, customSeriesData, textColor, selectedEntityId] ); const onEvents = useMemo(() => { @@ -197,6 +209,7 @@ export function LongEntitiesGantt({ renderTooltip={renderTooltip} cursor={onEntityClick ? 'pointer' : undefined} onEvents={onEvents} + onBackgroundClick={onBackgroundClick} /> ); } diff --git a/ui/src/components/EntityDetailDrawer.tsx b/ui/src/components/EntityDetailDrawer.tsx index f91f6a1a7..9ccb8af44 100644 --- a/ui/src/components/EntityDetailDrawer.tsx +++ b/ui/src/components/EntityDetailDrawer.tsx @@ -13,6 +13,7 @@ interface EntityDetailDrawerProps { resourceLabel: (id: string) => string; operatorLabel: (id: string) => string; onClose: () => void; + stateColorFn?: (name: string) => string; } export function EntityDetailDrawer({ @@ -20,6 +21,7 @@ export function EntityDetailDrawer({ resourceLabel, operatorLabel, onClose, + stateColorFn, }: EntityDetailDrawerProps) { useEffect(() => { if (!fsm) return; @@ -49,6 +51,7 @@ export function EntityDetailDrawer({ fsm={fsm} resourceLabel={resourceLabel} operatorLabel={operatorLabel} + stateColorFn={stateColorFn} /> , diff --git a/ui/src/components/LongEntitiesRow.tsx b/ui/src/components/LongEntitiesRow.tsx index a8226d84d..f64ee4ccb 100644 --- a/ui/src/components/LongEntitiesRow.tsx +++ b/ui/src/components/LongEntitiesRow.tsx @@ -28,6 +28,8 @@ type LongEntitiesRowProps = { /** Defaults to all states; resource scope keeps states used on this row's resource. */ fsmStateScope?: 'all' | 'resource'; onEntitySelect?: (fsm: FiniteStateMachine) => void; + selectedEntityId?: string; + onBackgroundClick?: () => void; }; /** @@ -44,6 +46,8 @@ export function LongEntitiesRow({ isDark, fsmStateScope = 'all', onEntitySelect, + selectedEntityId, + onBackgroundClick, }: LongEntitiesRowProps) { const selectedNodeIds = useSelectedNodeIds(); const debouncedZoomRange = useDebouncedZoomRange(); @@ -112,6 +116,8 @@ export function LongEntitiesRow({ height={LONG_ENTITIES_TIMELINE_HEIGHT} isDark={isDark} onEntityClick={onEntitySelect ? handleEntityClick : undefined} + selectedEntityId={selectedEntityId} + onBackgroundClick={onBackgroundClick} /> {hasNextPage && !isPlaceholderData && ( diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 30843d89d..41fd7e13f 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -48,6 +48,7 @@ import { import { LongEntitiesRow } from '@/components/LongEntitiesRow'; import { EntityDetailDrawer } from '@/components/EntityDetailDrawer'; import type { FiniteStateMachine } from '@quent/utils'; +import { createFsmTypeColorFn } from '@quent/utils'; function getRootResourceGroupId(resourceTree: ResourceTree): string | null { if (!('ResourceGroup' in resourceTree)) return null; @@ -138,6 +139,11 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr const [drawerFsm, setDrawerFsm] = useState(null); const closeDrawer = useCallback(() => setDrawerFsm(null), []); + const stateColorFn = useMemo( + () => createFsmTypeColorFn(entities.fsm_types, isDark ? 'dark' : 'light'), + [entities.fsm_types, isDark] + ); + const resourceLabel = useCallback( (id: string) => { const r = entities.resources[id]; @@ -350,6 +356,8 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr fsmTypes={entities.fsm_types} isDark={isDark} onEntitySelect={setDrawerFsm} + selectedEntityId={drawerFsm?.id} + onBackgroundClick={closeDrawer} /> ); } @@ -386,6 +394,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr handleZoomChange, operatorEntriesByWorker, setDrawerFsm, + drawerFsm, ]); return ( @@ -410,6 +419,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr resourceLabel={resourceLabel} operatorLabel={operatorLabel} onClose={closeDrawer} + stateColorFn={stateColorFn} /> ); diff --git a/ui/src/components/entities-table/EntitiesTable.tsx b/ui/src/components/entities-table/EntitiesTable.tsx index 3151e884f..538fc02d6 100644 --- a/ui/src/components/entities-table/EntitiesTable.tsx +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@quent/components'; import type { EntityRef, QueryBundle } from '@quent/utils'; +import { createFsmTypeColorFn } from '@quent/utils'; +import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; import { EntityDetailPanel } from './EntityDetailPanel'; import { EntityResults } from './EntityResults'; import { EntitiesToolbar } from './EntitiesToolbar'; @@ -16,6 +19,12 @@ interface EntitiesTableProps { export function EntitiesTable(props: EntitiesTableProps) { const table = useEntityTable(props); + const { theme } = useTheme(); + const isDark = theme === THEME_DARK; + const stateColorFn = useMemo( + () => createFsmTypeColorFn(table.fsmTypes, isDark ? 'dark' : 'light'), + [table.fsmTypes, isDark] + ); return ( @@ -60,6 +69,7 @@ export function EntitiesTable(props: EntitiesTableProps) { fsm={table.selected} resourceLabel={table.resourceLabel} operatorLabel={table.operatorLabel} + stateColorFn={stateColorFn} /> diff --git a/ui/src/components/entities-table/EntityDetailPanel.tsx b/ui/src/components/entities-table/EntityDetailPanel.tsx index a0d2ea4a3..62bd60a3c 100644 --- a/ui/src/components/entities-table/EntityDetailPanel.tsx +++ b/ui/src/components/entities-table/EntityDetailPanel.tsx @@ -18,6 +18,7 @@ interface EntityDetailPanelProps { fsm: FiniteStateMachine | null; resourceLabel: (id: string) => string; operatorLabel: (id: string) => string; + stateColorFn?: (name: string) => string; } function isBytesStat(name: string): boolean { @@ -29,7 +30,7 @@ function isBytesStat(name: string): boolean { ); } -export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityDetailPanelProps) { +export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel, stateColorFn }: EntityDetailPanelProps) { const { theme } = useTheme(); const paletteTheme = theme === THEME_DARK ? ('dark' as const) : ('light' as const); const [copied, setCopied] = useState(false); @@ -151,7 +152,9 @@ export function EntityDetailPanel({ fsm, resourceLabel, operatorLabel }: EntityD const durationMs = durations[index] ?? null; const isBottleneck = durationMs != null && totalSpanMs > 0 && durationMs / totalSpanMs > 0.5; - const stateColor = getColorForKey(transition.name, paletteTheme); + const stateColor = stateColorFn + ? stateColorFn(transition.name) + : getColorForKey(transition.name, paletteTheme); const pct = durationMs != null && totalSpanMs > 0 ? Math.min(100, (durationMs / totalSpanMs) * 100) : null; diff --git a/ui/src/components/entities-table/useEntityTable.ts b/ui/src/components/entities-table/useEntityTable.ts index 17bf90f85..497e7b12c 100644 --- a/ui/src/components/entities-table/useEntityTable.ts +++ b/ui/src/components/entities-table/useEntityTable.ts @@ -160,6 +160,7 @@ export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTabl return { activeFilterCount, entityTypeOptions, + fsmTypes: entities.fsm_types, error: query.error, filters, hasNonDefaultSettings: hasNonDefaultEntitySettings(filters, defaults, activeFilterCount), From b133551df3575bed43b4746d3c4b9b0fb917983e Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Mon, 10 Aug 2026 14:26:49 -0500 Subject: [PATCH 34/75] Add QueryToolbar to entities table --- ui/src/components/entities-table/EntitiesTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/components/entities-table/EntitiesTable.tsx b/ui/src/components/entities-table/EntitiesTable.tsx index 538fc02d6..68c51ec35 100644 --- a/ui/src/components/entities-table/EntitiesTable.tsx +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo } from 'react'; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@quent/components'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup, QueryToolbar } from '@quent/components'; import type { EntityRef, QueryBundle } from '@quent/utils'; import { createFsmTypeColorFn } from '@quent/utils'; import { useTheme, THEME_DARK } from '@/contexts/ThemeContext'; @@ -30,6 +30,7 @@ export function EntitiesTable(props: EntitiesTableProps) {
      + Date: Mon, 10 Aug 2026 15:35:18 -0500 Subject: [PATCH 35/75] make select labels more consistent; improve field wrapping structure --- .../components/src/ui/searchable-select.tsx | 22 +- .../entities-table/EntitiesToolbar.tsx | 210 +++++++++++------- 2 files changed, 142 insertions(+), 90 deletions(-) diff --git a/ui/packages/@quent/components/src/ui/searchable-select.tsx b/ui/packages/@quent/components/src/ui/searchable-select.tsx index ac87c61da..4d39c95d2 100644 --- a/ui/packages/@quent/components/src/ui/searchable-select.tsx +++ b/ui/packages/@quent/components/src/ui/searchable-select.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { useMemo, useState } from 'react'; @@ -10,7 +10,8 @@ import { Popover, PopoverContent, PopoverTrigger } from './popover'; import type { SelectFieldOption } from './select-field'; export interface SearchableSelectProps { - label: string; + label?: string; + ariaLabel?: string; options: SelectFieldOption[]; value: string | null; onValueChange: (value: string | null) => void; @@ -23,11 +24,12 @@ export interface SearchableSelectProps { export function SearchableSelect({ label, + ariaLabel, options, value, onValueChange, placeholder, - searchPlaceholder = `Search ${label.toLowerCase()}…`, + searchPlaceholder = `Search ${(ariaLabel ?? label ?? placeholder).toLowerCase()}…`, emptyMessage = 'No matches.', className, triggerClassName, @@ -49,9 +51,13 @@ export function SearchableSelect({ setSearch(''); }; + const accessibleLabel = ariaLabel ?? label; + return ( -
      - {label} +
      + {label && ( + {label} + )} { @@ -64,7 +70,7 @@ export function SearchableSelect({ variant="outline" size="sm" role="combobox" - aria-label={label} + aria-label={accessibleLabel} aria-expanded={open} className={cn( 'h-8 min-w-0 flex-1 justify-between gap-2 px-2 font-normal', @@ -86,11 +92,11 @@ export function SearchableSelect({ value={search} onChange={event => setSearch(event.target.value)} placeholder={searchPlaceholder} - aria-label={`Search ${label.toLowerCase()}`} + aria-label={`Search ${(accessibleLabel ?? placeholder).toLowerCase()}`} className="h-7 pl-7 pr-2 text-xs md:text-xs" />
      -
      +
    + + + Instance + Type + States + Sequence + Start + End + FSM span + + ID + + + + {rows.map(row => ( + + ))} + +
    + )} + {!isError && !isLoading && !hasValidationErrors && rows.length === 0 && ( +
    No entities match the filters.
    + )} + {isLoading &&
    Loading…
    } + + + {requestPending && rows.length > 0 && ( +
    + + + Updating… +
    - ) : ( - - - - Instance - Type - States - Sequence - Start - End - FSM span - - ID - - - - {rows.map(row => ( - - ))} - -
    - )} - {!isError && !isLoading && !hasValidationErrors && rows.length === 0 && ( -
    No entities match the filters.
    )} - {isLoading &&
    Loading…
    }
    From 6e4994cb6f7406a49f206436de19551522bdbd08 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 27 Aug 2026 12:48:32 -0500 Subject: [PATCH 73/75] refactor: move helpers to separate folder, rename to match convention --- .../{ui/slider-field-helpers.ts => lib/sliderField.utils.ts} | 0 ui/packages/@quent/components/src/ui/range-slider-field.tsx | 2 +- ui/packages/@quent/components/src/ui/slider-field.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename ui/packages/@quent/components/src/{ui/slider-field-helpers.ts => lib/sliderField.utils.ts} (100%) diff --git a/ui/packages/@quent/components/src/ui/slider-field-helpers.ts b/ui/packages/@quent/components/src/lib/sliderField.utils.ts similarity index 100% rename from ui/packages/@quent/components/src/ui/slider-field-helpers.ts rename to ui/packages/@quent/components/src/lib/sliderField.utils.ts diff --git a/ui/packages/@quent/components/src/ui/range-slider-field.tsx b/ui/packages/@quent/components/src/ui/range-slider-field.tsx index 14ee1d236..9e382472c 100644 --- a/ui/packages/@quent/components/src/ui/range-slider-field.tsx +++ b/ui/packages/@quent/components/src/ui/range-slider-field.tsx @@ -4,7 +4,7 @@ import { cn } from '@quent/utils'; import { Input } from './input'; import { Slider } from './slider'; -import { clamp, formatStep, niceSliderStep, parseOptionalNumber } from './slider-field-helpers'; +import { clamp, formatStep, niceSliderStep, parseOptionalNumber } from '../lib/sliderField.utils'; export interface RangeSliderFieldProps { label: string; diff --git a/ui/packages/@quent/components/src/ui/slider-field.tsx b/ui/packages/@quent/components/src/ui/slider-field.tsx index 0276c477c..abfac8779 100644 --- a/ui/packages/@quent/components/src/ui/slider-field.tsx +++ b/ui/packages/@quent/components/src/ui/slider-field.tsx @@ -4,7 +4,7 @@ import { cn } from '@quent/utils'; import { Input } from './input'; import { Slider } from './slider'; -import { clamp, formatStep, niceSliderStep, parseOptionalNumber } from './slider-field-helpers'; +import { clamp, formatStep, niceSliderStep, parseOptionalNumber } from '../lib/sliderField.utils'; export interface SliderFieldProps { label: string; From 12e90d706cd28dc85ad8fdbd8b42e9524b37ad19 Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 27 Aug 2026 15:45:14 -0500 Subject: [PATCH 74/75] refactor: use max of longest-running entity for window slider max --- .../entities-table/EntitiesTable.test.tsx | 10 ++++++++++ .../entities-table/EntitiesTable.tsx | 1 + .../entities-table/EntitiesToolbar.tsx | 5 ++++- .../entities-table/useEntityTable.ts | 20 ++++++++++++++++++- ui/src/components/entities-table/utils.ts | 2 +- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/ui/src/components/entities-table/EntitiesTable.test.tsx b/ui/src/components/entities-table/EntitiesTable.test.tsx index e595dbd9f..0c4cde5b2 100644 --- a/ui/src/components/entities-table/EntitiesTable.test.tsx +++ b/ui/src/components/entities-table/EntitiesTable.test.tsx @@ -19,9 +19,11 @@ function renderTable( } const useEntities = vi.fn(); +const useEntityList = vi.fn(); vi.mock('@quent/client', () => ({ useEntities: (...args: unknown[]) => useEntities(...args), + useEntityList: (...args: unknown[]) => useEntityList(...args), })); const queryBundle = { @@ -88,6 +90,14 @@ describe('EntitiesTable', () => { isError: false, error: null, }); + useEntityList.mockReset(); + useEntityList.mockReturnValue({ + data: { items: [{ entity: fsm, usage_duration_s: 0.25 }], total: 1 }, + isLoading: false, + isFetching: false, + isError: false, + error: null, + }); }); afterEach(() => { diff --git a/ui/src/components/entities-table/EntitiesTable.tsx b/ui/src/components/entities-table/EntitiesTable.tsx index 59d7b6558..373a4d1e6 100644 --- a/ui/src/components/entities-table/EntitiesTable.tsx +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -39,6 +39,7 @@ export function EntitiesTable(props: EntitiesTableProps) {
    @@ -100,7 +103,7 @@ export function EntitiesToolbar({ endLabel="Window end (s)" className="w-56" min={0} - max={sliderMax} + max={windowSliderMax} startValue={filters.windowStart} endValue={filters.windowEnd} invalidStart={invalidFilterFields.has('windowStart')} diff --git a/ui/src/components/entities-table/useEntityTable.ts b/ui/src/components/entities-table/useEntityTable.ts index 72091fefc..41525fbdd 100644 --- a/ui/src/components/entities-table/useEntityTable.ts +++ b/ui/src/components/entities-table/useEntityTable.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useEntities } from '@quent/client'; +import { useEntities, useEntityList } from '@quent/client'; import { useSelectedNodeIds } from '@quent/hooks'; import type { SelectFieldOption } from '@quent/components'; import type { EntityRef, FiniteStateMachine, QueryBundle, SortDir } from '@quent/utils'; @@ -13,6 +13,7 @@ import { buildEntityRequest, defaultEntityFilters, entityRows, + fsmSpan, hasNonDefaultEntitySettings, normalizePageSize, validateEntityFilters, @@ -36,6 +37,22 @@ export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTabl const selectedNodeIds = useSelectedNodeIds(); const dagOperatorId = selectedNodeIds.values().next().value ?? null; const defaults = useMemo(() => defaultEntityFilters(durationS), [durationS]); + // The "Window (s)" slider is bounded by the query duration, which is often far longer than + // when entities actually occur. Use the longest-running entity's end time as a tighter, + // more useful max so the slider isn't mostly dead space. + const longestEntityQuery = useEntityList({ + engineId, + queryId, + window: { start: 0, end: durationS }, + sortKey: 'UsageDuration', + sortDir: 'Desc', + maxItems: 1, + }); + const windowMaxS = useMemo(() => { + const longestEntity = longestEntityQuery.data?.items[0]?.entity; + if (!longestEntity) return durationS; + return Math.min(durationS, Math.max(0, fsmSpan(longestEntity).end)); + }, [longestEntityQuery.data, durationS]); const [filters, setFilters] = useState(() => defaultEntityFilters(durationS)); const [manualOperatorOverride, setManualOperatorOverride] = useState(null); @@ -160,6 +177,7 @@ export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTabl filters: { values: filters, durationS, + windowMaxS, validationErrors, invalidFilterFields, hasNonDefaultSettings: hasNonDefaultEntitySettings(filters, defaults, activeFilterCount), diff --git a/ui/src/components/entities-table/utils.ts b/ui/src/components/entities-table/utils.ts index f8be1dba2..de8c33960 100644 --- a/ui/src/components/entities-table/utils.ts +++ b/ui/src/components/entities-table/utils.ts @@ -158,7 +158,7 @@ export function parseOptionalNumber(value: string): number | null { return Number.isFinite(parsed) ? parsed : null; } -function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } { +export function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } { let start = Infinity; let end = -Infinity; for (const transition of fsm.transitions) { From 7220230334862e90b6882853141c4de632b1ca1c Mon Sep 17 00:00:00 2001 From: Chris Matzenbach Date: Thu, 27 Aug 2026 15:56:08 -0500 Subject: [PATCH 75/75] linting --- .../components/src/lib/sliderField.utils.ts | 8 ++++++-- .../components/src/ui/range-slider-field.tsx | 8 ++++++-- .../components/src/ui/searchable-select.tsx | 8 ++++++-- .../components/entities-table/EntityResults.tsx | 12 +++++++++--- .../components/entities-table/useEntityTable.ts | 8 ++++++-- ui/src/components/entities-table/utils.ts | 16 ++++++++++++---- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/ui/packages/@quent/components/src/lib/sliderField.utils.ts b/ui/packages/@quent/components/src/lib/sliderField.utils.ts index 887d6853d..10d877a56 100644 --- a/ui/packages/@quent/components/src/lib/sliderField.utils.ts +++ b/ui/packages/@quent/components/src/lib/sliderField.utils.ts @@ -6,14 +6,18 @@ export function clamp(value: number, min: number, max: number): number { } export function parseOptionalNumber(value: string): number | null { - if (value.trim() === '') return null; + if (value.trim() === '') { + return null; + } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } /** Picks a "nice" step (1/2/5 * 10^n) so a slider spanning `span` units has roughly 100-200 stops. */ export function niceSliderStep(span: number): number { - if (!Number.isFinite(span) || span <= 0) return 0.1; + if (!Number.isFinite(span) || span <= 0) { + return 0.1; + } const rawStep = span / 150; const magnitude = 10 ** Math.floor(Math.log10(rawStep)); const normalized = rawStep / magnitude; diff --git a/ui/packages/@quent/components/src/ui/range-slider-field.tsx b/ui/packages/@quent/components/src/ui/range-slider-field.tsx index 9e382472c..38f2f3ec0 100644 --- a/ui/packages/@quent/components/src/ui/range-slider-field.tsx +++ b/ui/packages/@quent/components/src/ui/range-slider-field.tsx @@ -60,8 +60,12 @@ export function RangeSliderField({ const [start, end] = next as number[]; // Only the dragged thumb's value is reformatted from the slider — leave the other // field's string untouched so a precisely-typed value isn't rounded to the slider step. - if (start !== sliderValue[0]) onStartChange(formatStep(start!, step)); - if (end !== sliderValue[1]) onEndChange(formatStep(end!, step)); + if (start !== sliderValue[0]) { + onStartChange(formatStep(start!, step)); + } + if (end !== sliderValue[1]) { + onEndChange(formatStep(end!, step)); + } }} className="px-0.5" /> diff --git a/ui/packages/@quent/components/src/ui/searchable-select.tsx b/ui/packages/@quent/components/src/ui/searchable-select.tsx index 23727447a..214d9887d 100644 --- a/ui/packages/@quent/components/src/ui/searchable-select.tsx +++ b/ui/packages/@quent/components/src/ui/searchable-select.tsx @@ -39,7 +39,9 @@ export function SearchableSelect({ const selected = options.find(option => option.value === value); const filteredOptions = useMemo(() => { const needle = search.trim().toLowerCase(); - if (!needle) return options; + if (!needle) { + return options; + } return options.filter(option => `${option.label ?? option.value} ${option.value}`.toLowerCase().includes(needle) ); @@ -62,7 +64,9 @@ export function SearchableSelect({ open={open} onOpenChange={nextOpen => { setOpen(nextOpen); - if (!nextOpen) setSearch(''); + if (!nextOpen) { + setSearch(''); + } }} > diff --git a/ui/src/components/entities-table/EntityResults.tsx b/ui/src/components/entities-table/EntityResults.tsx index 0fa06a02d..781eede3f 100644 --- a/ui/src/components/entities-table/EntityResults.tsx +++ b/ui/src/components/entities-table/EntityResults.tsx @@ -60,10 +60,14 @@ function navLinkProps(disabled: boolean, onClick: () => void) { className: cn(disabled && 'pointer-events-none opacity-50'), onClick: (event: MouseEvent) => { event.preventDefault(); - if (!disabled) onClick(); + if (!disabled) { + onClick(); + } }, onKeyDown: (event: KeyboardEvent) => { - if (disabled) return; + if (disabled) { + return; + } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); @@ -288,7 +292,9 @@ function PageJump({ const displayValue = draft ?? String(page + 1); function commit() { - if (draft === null) return; + if (draft === null) { + return; + } const parsed = parseInt(draft, 10); if (Number.isFinite(parsed)) { onPageChange(Math.min(pageCount - 1, Math.max(0, parsed - 1))); diff --git a/ui/src/components/entities-table/useEntityTable.ts b/ui/src/components/entities-table/useEntityTable.ts index 41525fbdd..41de8ae17 100644 --- a/ui/src/components/entities-table/useEntityTable.ts +++ b/ui/src/components/entities-table/useEntityTable.ts @@ -50,7 +50,9 @@ export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTabl }); const windowMaxS = useMemo(() => { const longestEntity = longestEntityQuery.data?.items[0]?.entity; - if (!longestEntity) return durationS; + if (!longestEntity) { + return durationS; + } return Math.min(durationS, Math.max(0, fsmSpan(longestEntity).end)); }, [longestEntityQuery.data, durationS]); const [filters, setFilters] = useState(() => defaultEntityFilters(durationS)); @@ -73,7 +75,9 @@ export function useEntityTable({ engineId, queryId, queryBundle }: UseEntityTabl (patch: Partial, options?: { preserveSelection?: boolean }) => { setFilters(previous => ({ ...previous, ...patch })); setPage(0); - if (!options?.preserveSelection) setSelected(null); + if (!options?.preserveSelection) { + setSelected(null); + } }, [] ); diff --git a/ui/src/components/entities-table/utils.ts b/ui/src/components/entities-table/utils.ts index de8c33960..2965c5386 100644 --- a/ui/src/components/entities-table/utils.ts +++ b/ui/src/components/entities-table/utils.ts @@ -30,7 +30,9 @@ export function defaultEntityFilters(durationS: number): EntityFilters { } export function normalizePageSize(value: number | null): number { - if (value === null || !Number.isFinite(value)) return DEFAULT_PAGE_SIZE; + if (value === null || !Number.isFinite(value)) { + return DEFAULT_PAGE_SIZE; + } return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value))); } @@ -153,7 +155,9 @@ export function hasNonDefaultEntitySettings( } export function parseOptionalNumber(value: string): number | null { - if (value.trim() === '') return null; + if (value.trim() === '') { + return null; + } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } @@ -162,8 +166,12 @@ export function fsmSpan(fsm: FiniteStateMachine): { start: number; end: number } let start = Infinity; let end = -Infinity; for (const transition of fsm.transitions) { - if (transition.timestamp < start) start = transition.timestamp; - if (transition.timestamp > end) end = transition.timestamp; + if (transition.timestamp < start) { + start = transition.timestamp; + } + if (transition.timestamp > end) { + end = transition.timestamp; + } } return fsm.transitions.length === 0 ? { start: 0, end: 0 } : { start, end }; }