diff --git a/crates/ui/src/entities/response.rs b/crates/ui/src/entities/response.rs index 36c7dd804..ed709a9d2 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 1f8a0d664..dac42def1 100644 --- a/domains/query_engine/analyzer/src/entities.rs +++ b/domains/query_engine/analyzer/src/entities.rs @@ -6,16 +6,16 @@ use std::collections::HashSet; use quent_analyzer::{ - AnalyzerResult, - fsm::{FsmUsages, collection::FsmCollection}, + AnalyzerResult, Span, + fsm::{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,14 +111,24 @@ 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 }) } /// The longest single usage span within the window on a scope resource, or any /// resource when `scope` is `None`. +/// +/// When `scope` is `None`, an entity may have no usages overlapping the +/// window at all yet still belong in the window — it's kept as long as its +/// overall lifecycle (first event to last event) overlaps the window, even if +/// no single event falls inside it (e.g. it started before and ended after). fn usage_metric<'a, F>( fsm: &'a F, scope: Option<&HashSet>, @@ -136,6 +146,15 @@ where match scope { Some(_) => longest, - None => Some(longest.unwrap_or(0)), + None => entity_overlaps_window(fsm, window).then(|| longest.unwrap_or(0)), } } + +/// Whether `fsm`'s overall lifecycle span (its first event to its last event) +/// overlaps `window` at all. +fn entity_overlaps_window(fsm: &F, window: SpanUnixNanoSec) -> bool +where + F: Fsm, +{ + fsm.span().is_ok_and(|span| span.intersects(&window)) +} diff --git a/domains/query_engine/tests/fixed/tests/list_entities.rs b/domains/query_engine/tests/fixed/tests/list_entities.rs index 0bb9d85ba..4ecbfc176 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] @@ -211,6 +212,95 @@ fn pagination_slices_the_ranked_set_with_stable_total() { assert_eq!(ids(&page2), MEMORY_W0_TASKS[6..8]); } +#[test] +fn unscoped_window_excludes_entities_whose_span_never_overlaps() { + let analyzer = fixed_analyzer(); + + // The window is relative to the query's own epoch (1.0s absolute in this + // fixture). No task's lifecycle (first event to last event) reaches + // [6.5, 7.0) relative (7.5-8.0s absolute): the latest event of any task is + // TASK_10/TASK_11's exit at 6.0s absolute (5.0s relative). With no scope + // and no min-usage filter, the window alone must still exclude entities + // whose lifecycle never overlaps it. + let request = EntityListRequest { + entry: EntityListEntry { + window: TimeWindow { + start: 6.5, + end: 7.0, + }, + ..entry(None, None, None, Vec::new()) + }, + app_params: QueryFilter { + query_id: fixed::QUERY, + }, + }; + let resp = analyzer.list_entities(request).unwrap(); + + assert_eq!(resp.total, 0); + assert!(resp.items.is_empty()); +} + +#[test] +fn unscoped_window_includes_entities_whose_span_overlaps() { + let analyzer = fixed_analyzer(); + + // The window is relative to the query's own epoch (its `init` transition, + // at 1.0s absolute in this fixture). TASK_0..3 run 2.0-3.0s absolute, i.e. + // [1.0, 2.0) relative; every other task's lifecycle starts at 3.0s + // absolute (2.0s relative) or later, so only TASK_0..3 overlap [1.0, 1.3). + let request = EntityListRequest { + entry: EntityListEntry { + window: TimeWindow { + start: 1.0, + end: 1.3, + }, + ..entry(None, None, None, Vec::new()) + }, + app_params: QueryFilter { + query_id: fixed::QUERY, + }, + }; + let resp = analyzer.list_entities(request).unwrap(); + + let mut got = ids(&resp); + got.sort(); + let mut want = vec![fixed::TASK_0, fixed::TASK_1, fixed::TASK_2, fixed::TASK_3]; + want.sort(); + assert_eq!(resp.total, 4); + assert_eq!(got, want); +} + +#[test] +fn unscoped_window_includes_entities_even_without_an_event_inside_it() { + let analyzer = fixed_analyzer(); + + // TASK_4..7 (PartialAggregate) run 3.0-4.0s absolute (2.0-3.0s relative). + // A window strictly between their `computing`/`sending` events and their + // `exit` contains none of their individual event timestamps, but their + // overall lifecycle still overlaps it, so they must still be included — + // this is the exact case the window filter previously got wrong. + let request = EntityListRequest { + entry: EntityListEntry { + window: TimeWindow { + start: 2.6, + end: 2.7, + }, + ..entry(None, None, None, Vec::new()) + }, + app_params: QueryFilter { + query_id: fixed::QUERY, + }, + }; + let resp = analyzer.list_entities(request).unwrap(); + + let mut got = ids(&resp); + got.sort(); + let mut want = vec![fixed::TASK_4, fixed::TASK_5, fixed::TASK_6, fixed::TASK_7]; + want.sort(); + assert_eq!(resp.total, 4); + assert_eq!(got, want); +} + #[test] fn operator_filter_restricts_to_one_operator_tasks() { let analyzer = fixed_analyzer(); diff --git a/ui/packages/@quent/client/src/entityList.ts b/ui/packages/@quent/client/src/entityList.ts index c0278751e..2a9d603cd 100644 --- a/ui/packages/@quent/client/src/entityList.ts +++ b/ui/packages/@quent/client/src/entityList.ts @@ -76,3 +76,30 @@ export const useEntityList = ( params: EntityListParams, options?: { staleTime?: number; enabled?: boolean } ) => useQuery(entityListQueryOptions(params, options)); + +// Raw-request variant: accepts a pre-built EntityListRequest directly. +// Used when the caller constructs the full request object itself. +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: () => fetchEntityList(engineId, request), + staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, + enabled: options?.enabled, + 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 d9e183f65..31e9f8d50 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -28,7 +28,7 @@ export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; export { dataFlowQueryOptions } from './dataFlow'; -export { entityListQueryOptions } from './entityList'; +export { entitiesQueryOptions, entityListQueryOptions } from './entityList'; export { canonicalizeNvtxRequest, canonicalizeNvtxSelections, @@ -46,6 +46,7 @@ export { useEngines } from './engines'; export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; +export { useEntities } from './entityList'; export { useDataFlow } from './dataFlow'; export { useEntityList } from './entityList'; export { useEngineContexts, useNvtxCatalog, useNvtxStream, useNvtxViewport } from './nvtx'; diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 3b4df66aa..fadec08dc 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -55,6 +55,15 @@ export { NavigationMenuIndicator, NavigationMenuViewport, } from './ui/navigation-menu'; +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from './ui/pagination'; export { Popover, PopoverTrigger, PopoverContent } from './ui/popover'; export { PointerTooltipPortal } from './ui/pointer-tooltip-portal'; export type { PointerPosition } from './ui/pointer-tooltip-portal'; @@ -82,12 +91,19 @@ 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 { RequiredMultiSelectField } from './ui/required-multi-select-field'; export type { RequiredMultiSelectFieldProps, RequiredMultiSelectOption, } from './ui/required-multi-select-field'; export { Skeleton } from './ui/skeleton'; +export { Slider } from './ui/slider'; +export { SliderField } from './ui/slider-field'; +export type { SliderFieldProps } from './ui/slider-field'; +export { RangeSliderField } from './ui/range-slider-field'; +export type { RangeSliderFieldProps } from './ui/range-slider-field'; export { TreeView } from './ui/tree-view'; export type { TreeDataItem } from './ui/tree-view'; export { TreeTable } from './ui/tree-table'; diff --git a/ui/packages/@quent/components/src/lib/sliderField.utils.ts b/ui/packages/@quent/components/src/lib/sliderField.utils.ts new file mode 100644 index 000000000..10d877a56 --- /dev/null +++ b/ui/packages/@quent/components/src/lib/sliderField.utils.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function parseOptionalNumber(value: string): number | 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; + } + const rawStep = span / 150; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const niceNormalized = normalized < 1.5 ? 1 : normalized < 3.5 ? 2 : normalized < 7.5 ? 5 : 10; + return niceNormalized * magnitude; +} + +export function formatStep(value: number, step: number): string { + const decimals = step > 0 && step < 1 ? Math.min(6, Math.ceil(-Math.log10(step))) : 0; + return value.toFixed(decimals); +} diff --git a/ui/packages/@quent/components/src/long-entities/utils.ts b/ui/packages/@quent/components/src/long-entities/utils.ts index 226014b80..31061b875 100644 --- a/ui/packages/@quent/components/src/long-entities/utils.ts +++ b/ui/packages/@quent/components/src/long-entities/utils.ts @@ -33,7 +33,7 @@ function buildSegments( colorFsm: (stateName: string) => string, resourceIdsForFilter?: ReadonlySet | null ): LongEntitySegment[] { - return fsm.transitions + return (fsm.transitions ?? []) .slice(0, -1) .map((transition, i): LongEntitySegment | null => { const next = fsm.transitions[i + 1]; diff --git a/ui/packages/@quent/components/src/ui/input.tsx b/ui/packages/@quent/components/src/ui/input.tsx index 96b38c79f..c7e914e3f 100644 --- a/ui/packages/@quent/components/src/ui/input.tsx +++ b/ui/packages/@quent/components/src/ui/input.tsx @@ -12,7 +12,7 @@ const Input = React.forwardRef>( ) { + return ( +