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/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/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..260ad7ca2 --- /dev/null +++ b/ui/src/components/entities-table/EntitiesTable.tsx @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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; + queryId: string; + queryBundle: QueryBundle; +} + +export function EntitiesTable(props: EntitiesTableProps) { + const table = useEntityTable(props); + + return ( + + +
+ + 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; +} 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 +