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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/ui/src/entities/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FiniteStateMachine>,
pub items: Vec<EntityListItem>,
/// The count of entities matching the filter before paging.
pub total: u32,
}
13 changes: 9 additions & 4 deletions domains/query_engine/analyzer/src/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -111,8 +111,13 @@ where
};

let items = page_iter
.map(|(f, _)| FiniteStateMachine::try_from_fsm(f, epoch))
.collect::<Result<Vec<_>, _>>()?;
.map(|(f, usage_duration)| {
FiniteStateMachine::try_from_fsm(f, epoch).map(|entity| EntityListItem {
usage_duration_s: to_secs(usage_duration),
entity,
})
})
.collect::<Result<Vec<_>, quent_time::TimeError>>()?;

Ok(EntityListResponse { items, total })
}
Expand Down
3 changes: 2 additions & 1 deletion domains/query_engine/tests/fixed/tests/list_entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ fn request(
}

fn ids(resp: &EntityListResponse) -> Vec<Uuid> {
resp.items.iter().map(|fsm| fsm.id).collect()
resp.items.iter().map(|item| item.entity.id).collect()
}

#[test]
Expand All @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions examples/simulator/server/ts-bindings/EntityListItem.ts
Original file line number Diff line number Diff line change
@@ -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, };
4 changes: 2 additions & 2 deletions examples/simulator/server/ts-bindings/EntityListResponse.ts
Original file line number Diff line number Diff line change
@@ -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<FiniteStateMachine>,
export type EntityListResponse = { items: Array<EntityListItem>,
/**
* The count of entities matching the filter before paging.
*/
Expand Down
14 changes: 14 additions & 0 deletions ui/packages/@quent/client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
OperatorFilter,
EntityRef,
Engine,
EntityListRequest,
EntityListResponse,
} from '@quent/utils';

interface ApiFetchOptions {
Expand Down Expand Up @@ -104,3 +106,15 @@ export async function fetchBulkTimelines(
},
});
}

export async function fetchEntities(
engineId: string,
request: EntityListRequest<QueryFilter, OperatorFilter>
): Promise<EntityListResponse> {
return apiFetch<EntityListResponse>(`/engines/${engineId}/entities`, {
fetchOptions: {
method: 'POST',
body: JSON.stringify(request),
},
});
}
33 changes: 33 additions & 0 deletions ui/packages/@quent/client/src/entities.ts
Original file line number Diff line number Diff line change
@@ -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<QueryFilter, OperatorFilter>;
}

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));
3 changes: 3 additions & 0 deletions ui/packages/@quent/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
fetchListQueries,
fetchSingleTimeline,
fetchBulkTimelines,
fetchEntities,
} from './api';

// queryOptions factories
Expand All @@ -22,10 +23,12 @@ 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';
export { useEngines } from './engines';
export { useQueryGroups } from './queryGroups';
export { useQueries } from './queries';
export { useTimeline } from './timeline';
export { useEntities } from './entities';
2 changes: 2 additions & 0 deletions ui/packages/@quent/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
138 changes: 138 additions & 0 deletions ui/packages/@quent/components/src/ui/searchable-select.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('flex items-center gap-1.5 min-w-0', className)}>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">{label}</span>
<Popover
open={open}
onOpenChange={nextOpen => {
setOpen(nextOpen);
if (!nextOpen) setSearch('');
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-label={label}
aria-expanded={open}
className={cn(
'h-8 min-w-0 flex-1 justify-between gap-2 px-2 font-normal',
triggerClassName
)}
>
<span className="truncate text-xs">
{selected?.label ?? selected?.value ?? placeholder}
</span>
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start" side="bottom">
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
autoFocus
value={search}
onChange={event => setSearch(event.target.value)}
placeholder={searchPlaceholder}
aria-label={`Search ${label.toLowerCase()}`}
className="h-7 pl-7 pr-2 text-xs md:text-xs"
/>
</div>
<div className="max-h-56 space-y-0.5 overflow-auto" role="listbox" aria-label={label}>
<Option label={placeholder} selected={value === null} onSelect={() => select(null)} />
{filteredOptions.map(option => (
<Option
key={option.value}
label={option.label ?? option.value}
selected={option.value === value}
onSelect={() => select(option.value)}
/>
))}
{filteredOptions.length === 0 && (
<p className="py-2 text-center text-xs text-muted-foreground">{emptyMessage}</p>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}

function Option({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
role="option"
aria-selected={selected}
onClick={onSelect}
className={cn(
'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1 text-xs outline-none',
'transition-colors hover:bg-accent hover:text-accent-foreground',
'focus-visible:bg-accent focus-visible:text-accent-foreground'
)}
>
<Check className={cn('size-3.5 shrink-0', !selected && 'opacity-0')} />
<span className="truncate">{label}</span>
</button>
);
}
11 changes: 11 additions & 0 deletions ui/packages/@quent/utils/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Loading
Loading