diff --git a/src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx b/src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx index 250b1e1fb..1f57ea943 100644 --- a/src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx +++ b/src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx @@ -14,24 +14,15 @@ //SPDX-License-Identifier: Apache-2.0 -/** - * Occupancy Page Content (Client Component) - * - * Layout: Toolbar → Summary cards → Collapsible-row table - * - * Data source: GET /api/task?summary=true → aggregated by user or pool. - * All aggregation is client-side (shim) until backend ships group_by pagination (Issue #23). - */ - "use client"; import { useMemo, useCallback, useState } from "react"; import { useQueryState, parseAsStringLiteral } from "nuqs"; -import { TaskGroupStatus } from "@/lib/api/generated"; import { InlineErrorBoundary } from "@/components/error/inline-error-boundary"; import { usePage } from "@/components/chrome/page-context"; import { useResultsCount } from "@/components/filter-bar/hooks/use-results-count"; import { useDefaultFilter } from "@/components/filter-bar/hooks/use-default-filter"; +import { TASK_STATE_CATEGORIES } from "@/lib/task-group-status-presets"; import { OccupancyToolbar } from "@/features/occupancy/components/occupancy-toolbar"; import { OccupancySummary } from "@/features/occupancy/components/occupancy-summary"; import { OccupancyDataTable } from "@/features/occupancy/components/occupancy-data-table"; @@ -39,24 +30,12 @@ import { useOccupancyData } from "@/features/occupancy/hooks/use-occupancy-data" import { useOccupancyTableStore } from "@/features/occupancy/stores/occupancy-table-store"; import type { OccupancyGroupBy, OccupancySortBy } from "@/lib/api/adapter/occupancy"; -// ============================================================================= -// GroupBy parser for URL state -// ============================================================================= - -const GROUP_BY_VALUES = ["user", "pool"] as const; +const GROUP_BY_VALUES = ["pool", "user"] as const; const parseAsGroupBy = parseAsStringLiteral(GROUP_BY_VALUES); -// ============================================================================= -// Component -// ============================================================================= - export function OccupancyPageContent() { usePage({ title: "Occupancy" }); - // ========================================================================== - // URL State - // ========================================================================== - const [groupBy, setGroupBy] = useQueryState( "groupBy", parseAsGroupBy.withDefault("pool").withOptions({ shallow: true, history: "replace", clearOnDefault: true }), @@ -64,21 +43,13 @@ export function OccupancyPageContent() { const { effectiveChips: searchChips, handleChipsChange: setSearchChips } = useDefaultFilter({ field: "status", - defaultValue: TaskGroupStatus.RUNNING, + defaultValue: TASK_STATE_CATEGORIES.running, }); - // ========================================================================== - // Sort state from table store - // ========================================================================== - const sortState = useOccupancyTableStore((s) => s.sort); const sortBy: OccupancySortBy = (sortState?.column as OccupancySortBy) ?? "gpu"; const order: "asc" | "desc" = sortState?.direction ?? "desc"; - // ========================================================================== - // Data - // ========================================================================== - const { groups, totals, isLoading, error, refetch, truncated } = useOccupancyData({ groupBy, sortBy, @@ -86,10 +57,6 @@ export function OccupancyPageContent() { searchChips, }); - // ========================================================================== - // Toolbar props - // ========================================================================== - const resultsCount = useResultsCount({ total: groups.length, filteredTotal: groups.length, @@ -103,11 +70,7 @@ export function OccupancyPageContent() { [setGroupBy], ); - // ========================================================================== - // Expand/collapse state — lifted here so toolbar can drive expand-all/collapse-all. - // Reset when groupBy changes (stale keys from old view are meaningless). - // ========================================================================== - + // Expand/collapse state resets when groupBy changes (stale keys are meaningless) const [expandedState, setExpandedState] = useState<{ groupBy: OccupancyGroupBy; keys: Set }>({ groupBy, keys: new Set(), @@ -140,13 +103,8 @@ export function OccupancyPageContent() { const allExpanded = groups.length > 0 && expandedKeys.size === groups.length; - // ========================================================================== - // Render - // ========================================================================== - return (
- {/* Toolbar */}
- {/* KPI summary cards */}
- {/* Scale limit warning */} {truncated && (
Results may be incomplete — reached the 10,000 row fetch limit. Backend group_by pagination (Issue #23) is @@ -189,7 +145,6 @@ export function OccupancyPageContent() {
)} - {/* Main table */}
(defaultValue == null ? [] : Array.isArray(defaultValue) ? defaultValue : [defaultValue]), + [defaultValue], + ); + const [optOut, setOptOut] = useQueryState( optOutParam, parseAsBoolean.withDefault(false).withOptions({ @@ -61,20 +69,40 @@ export function useDefaultFilter({ const hasDefaultInUrl = useMemo(() => searchChips.some((c) => c.field === field), [searchChips, field]); - const shouldPrePopulate = !optOut && !hasDefaultInUrl && !!defaultValue; + // When both optOut and explicit chips are present, explicit chips win. + // Compute this synchronously so callers never see a transient contradictory state. + const effectiveOptOut = optOut && !hasDefaultInUrl; + + const shouldPrePopulate = !optOut && !hasDefaultInUrl && normalizedDefaults.length > 0; const effectiveChips = useMemo((): SearchChip[] => { if (!shouldPrePopulate) return searchChips; - const chipLabel = label ?? `${field}: ${defaultValue}`; - return [...searchChips, { field, value: defaultValue!, label: chipLabel }]; - }, [searchChips, shouldPrePopulate, field, defaultValue, label]); + const prefix = label ?? field; + const defaults: SearchChip[] = normalizedDefaults.map((v) => ({ + field, + value: v, + label: normalizedDefaults.length > 1 ? `${prefix}: ${v}` : (label ?? `${field}: ${v}`), + })); + return [...searchChips, ...defaults]; + }, [searchChips, shouldPrePopulate, field, normalizedDefaults, label]); + + // If the URL has the opt-out flag set but also has explicit chips for this field, + // the two are contradictory. The explicit chips win — clear the opt-out so downstream + // consumers (e.g. showAllUsers) don't ignore the manual filter. + useEffect(() => { + if (optOut && hasDefaultInUrl) void setOptOut(false); + }, [optOut, hasDefaultInUrl, setOptOut]); useEffect(() => { if (!shouldPrePopulate) return; - const chipLabel = label ?? `${field}: ${defaultValue}`; - const defaultChip: SearchChip = { field, value: defaultValue!, label: chipLabel }; - void setSearchChips([...searchChips, defaultChip], { history: "replace" }); - }, [shouldPrePopulate, searchChips, field, defaultValue, label, setSearchChips]); + const prefix = label ?? field; + const defaults: SearchChip[] = normalizedDefaults.map((v) => ({ + field, + value: v, + label: normalizedDefaults.length > 1 ? `${prefix}: ${v}` : (label ?? `${field}: ${v}`), + })); + void setSearchChips([...searchChips, ...defaults], { history: "replace" }); + }, [shouldPrePopulate, searchChips, field, normalizedDefaults, label, setSearchChips]); const handleChipsChange = useCallback( (newChips: SearchChip[]) => { @@ -91,5 +119,5 @@ export function useDefaultFilter({ [effectiveChips, field, optOut, setOptOut, setSearchChips], ); - return { effectiveChips, handleChipsChange, optOut }; + return { effectiveChips, handleChipsChange, optOut: effectiveOptOut }; } diff --git a/src/ui/src/components/filter-bar/hooks/use-url-chips.ts b/src/ui/src/components/filter-bar/hooks/use-url-chips.ts index bed7f7cb6..c86e8c5a2 100644 --- a/src/ui/src/components/filter-bar/hooks/use-url-chips.ts +++ b/src/ui/src/components/filter-bar/hooks/use-url-chips.ts @@ -17,7 +17,7 @@ "use client"; import { useMemo, useCallback } from "react"; -import { useQueryState, parseAsArrayOf, parseAsString } from "nuqs"; +import { useQueryState, createMultiParser } from "nuqs"; import type { SearchChip } from "@/stores/types"; import { parseUrlChips } from "@/lib/url-utils"; @@ -25,14 +25,25 @@ export interface SetSearchChipsOptions { history?: "push" | "replace"; } -/** - * URL-synced search chips. Parses "field:value" from repeated URL params (?f=field:value) - * into SearchChip[] and writes changes back to the URL for shareable filtered views. - */ +// Uses repeated query params (?f=pool:X&f=user:Y) for filter chips. +// type:"multi" makes nuqs call searchParams.getAll(), collecting repeated params. +// Each param value is one chip string — no secondary separator that could corrupt +// values containing commas. +const parseAsChipStrings = createMultiParser({ + parse: (values: readonly string[]) => values.filter(Boolean), + serialize: (values: readonly string[]) => Array.from(values), + eq: (a: string[], b: string[]) => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((v, i) => v === sortedB[i]); + }, +}); + export function useUrlChips({ paramName = "f" }: { paramName?: string } = {}) { const [filterStrings, setFilterStrings] = useQueryState( paramName, - parseAsArrayOf(parseAsString).withOptions({ + parseAsChipStrings.withOptions({ shallow: true, history: "push", clearOnDefault: true, diff --git a/src/ui/src/components/filter-bar/lib/preset-pill.ts b/src/ui/src/components/filter-bar/lib/preset-pill.ts new file mode 100644 index 000000000..9a812a743 --- /dev/null +++ b/src/ui/src/components/filter-bar/lib/preset-pill.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/lib/utils"; + +export function presetPillClasses( + bgClass: string, + active: boolean, + activeRingClass = "ring-black/15 ring-inset dark:ring-white/20", +): string { + return cn( + "inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all", + bgClass, + active && `ring-2 ${activeRingClass}`, + "group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg", + !active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100", + ); +} diff --git a/src/ui/src/features/datasets/list/components/toolbar/datasets-toolbar.tsx b/src/ui/src/features/datasets/list/components/toolbar/datasets-toolbar.tsx index 2fcab687f..b5983b41d 100644 --- a/src/ui/src/features/datasets/list/components/toolbar/datasets-toolbar.tsx +++ b/src/ui/src/features/datasets/list/components/toolbar/datasets-toolbar.tsx @@ -18,51 +18,25 @@ import { memo, useMemo } from "react"; import { User } from "lucide-react"; -import { cn } from "@/lib/utils"; import type { SearchChip } from "@/stores/types"; import type { ResultsCount, SearchField, SearchPreset } from "@/components/filter-bar/lib/types"; +import { presetPillClasses } from "@/components/filter-bar/lib/preset-pill"; import { TableToolbar } from "@/components/data-table/table-toolbar"; import { useDatasetsTableStore } from "@/features/datasets/list/stores/datasets-table-store"; import { OPTIONAL_COLUMNS } from "@/features/datasets/list/lib/dataset-columns"; import { DATASET_STATIC_FIELDS, type Dataset } from "@/features/datasets/list/lib/dataset-search-fields"; import { useDatasetsAsyncFields } from "@/features/datasets/list/hooks/use-datasets-async-fields"; -// ============================================================================= -// Helpers -// ============================================================================= - -function presetPillClasses(bgClass: string, active: boolean): string { - return cn( - "inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all", - bgClass, - active && "ring-2 ring-black/15 ring-inset dark:ring-white/20", - "group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg", - !active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100", - ); -} - -// ============================================================================= -// Props -// ============================================================================= - export interface DatasetsToolbarProps { datasets: Dataset[]; searchChips: SearchChip[]; onSearchChipsChange: (chips: SearchChip[]) => void; - /** Results count for displaying "N results" or "M of N results" */ resultsCount?: ResultsCount; - /** Current username for "My Datasets" preset */ currentUsername?: string | null; - /** Manual refresh callback */ onRefresh: () => void; - /** Loading state for refresh button */ isRefreshing: boolean; } -// ============================================================================= -// Component -// ============================================================================= - export const DatasetsToolbar = memo(function DatasetsToolbar({ datasets, searchChips, @@ -75,18 +49,16 @@ export const DatasetsToolbar = memo(function DatasetsToolbar({ const visibleColumnIds = useDatasetsTableStore((s) => s.visibleColumnIds); const toggleColumn = useDatasetsTableStore((s) => s.toggleColumn); - // Async fields: user list from /api/users with lazy loading const { userField } = useDatasetsAsyncFields(); - // Compose static + async fields const searchFields = useMemo( (): readonly SearchField[] => [ - DATASET_STATIC_FIELDS[0], // type - DATASET_STATIC_FIELDS[1], // name - DATASET_STATIC_FIELDS[2], // bucket - userField, // async - complete user list - DATASET_STATIC_FIELDS[3], // created_at - DATASET_STATIC_FIELDS[4], // updated_at + DATASET_STATIC_FIELDS[0], + DATASET_STATIC_FIELDS[1], + DATASET_STATIC_FIELDS[2], + userField, + DATASET_STATIC_FIELDS[3], + DATASET_STATIC_FIELDS[4], ], [userField], ); @@ -123,7 +95,6 @@ export const DatasetsToolbar = memo(function DatasetsToolbar({ return [{ label: "User:", items: [myDatasetsPreset] }]; }, [myDatasetsPreset]); - // Memoize autoRefreshProps to prevent unnecessary TableToolbar re-renders const autoRefreshProps = useMemo( () => ({ onRefresh, diff --git a/src/ui/src/features/occupancy/components/occupancy-column-defs.tsx b/src/ui/src/features/occupancy/components/occupancy-column-defs.tsx index a3efed331..7631ac5ff 100644 --- a/src/ui/src/features/occupancy/components/occupancy-column-defs.tsx +++ b/src/ui/src/features/occupancy/components/occupancy-column-defs.tsx @@ -17,13 +17,22 @@ "use client"; import type { ColumnDef } from "@tanstack/react-table"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, Layers, MoreHorizontal, Workflow } from "lucide-react"; import { cn, formatCompact, formatBytes } from "@/lib/utils"; +import { Link } from "@/components/link"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/shadcn/dropdown-menu"; +import { useMounted } from "@/hooks/use-mounted"; +import type { SearchChip } from "@/stores/types"; import type { OccupancyFlatRow, OccupancyGroupBy } from "@/lib/api/adapter/occupancy"; -// ============================================================================= -// Constants -// ============================================================================= +/** Occupancy chip fields that map directly to workflow filters. + * "status" is excluded — TaskGroupStatus is per-task-group, not per-workflow. */ +const CROSS_LINKABLE_FIELDS: ReadonlySet = new Set(["pool", "user", "priority"]); const PRIORITY_COLOR: Record<"high" | "normal" | "low", string> = { high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", @@ -31,10 +40,6 @@ const PRIORITY_COLOR: Record<"high" | "normal" | "low", string> = { low: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", }; -// ============================================================================= -// Helpers -// ============================================================================= - function ResourceCell({ value }: { value: number }) { return {formatCompact(value)}; } @@ -64,16 +69,100 @@ function PriorityBadge({ value, colorClass }: { value: number; colorClass: strin ); } -// ============================================================================= -// Column Definitions -// ============================================================================= +function buildWorkflowsUrl(row: OccupancyFlatRow, groupBy: OccupancyGroupBy, searchChips: SearchChip[]): string { + const params: string[] = []; + if (row._type === "parent") { + params.push(`f=${groupBy}:${encodeURIComponent(row.key)}`); + } else { + params.push(`f=${groupBy}:${encodeURIComponent(row.parentKey)}`); + const childDim = groupBy === "pool" ? "user" : "pool"; + params.push(`f=${childDim}:${encodeURIComponent(row.key)}`); + } + // Child rows provide both pool+user; parent rows only provide their own dimension. + // Only exclude what the row already supplies so searchChip context isn't lost. + const rowFields = row._type === "child" ? new Set(["pool", "user"]) : new Set([groupBy]); + for (const chip of searchChips) { + if (CROSS_LINKABLE_FIELDS.has(chip.field) && !rowFields.has(chip.field)) + params.push(`f=${chip.field}:${encodeURIComponent(chip.value)}`); + } + return `/workflows?${params.join("&")}&all=true`; +} + +function ParentRowActions({ + original, + href, + groupBy, +}: { + original: OccupancyFlatRow & { _type: "parent" }; + href: string; + groupBy: OccupancyGroupBy; +}) { + const mounted = useMounted(); + + if (!mounted) { + return ( + + ); + } + + return ( + + e.stopPropagation()} + > + + + + + + + View Workflows + + + {groupBy === "pool" && ( + + + + View Pool + + + )} + + + ); +} -export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef[] { +export function createOccupancyColumns( + groupBy: OccupancyGroupBy, + searchChips: SearchChip[], +): ColumnDef[] { const keyLabel = groupBy === "user" ? "User" : "Pool"; const countLabel = groupBy === "user" ? "Pools" : "Users"; return [ - // Expand/collapse chevron { id: "expand", enableSorting: false, @@ -99,7 +188,6 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef row.key, @@ -107,21 +195,31 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef { const original = row.original; - const isChild = original._type === "child"; + const href = buildWorkflowsUrl(original, groupBy, searchChips); + if (original._type === "child") { + return ( + e.stopPropagation()} + > + {original.key} + + ); + } return ( - - {original.key} - +
+ {original.key} + +
); }, }, - // Child count (parent rows only) { id: "count", enableSorting: false, @@ -135,7 +233,6 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef row.gpu, @@ -143,8 +240,6 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef , }, - - // CPU { id: "cpu", accessorFn: (row) => row.cpu, @@ -152,8 +247,6 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef , }, - - // Memory (GiB) { id: "memory", accessorFn: (row) => row.memory, @@ -161,8 +254,6 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef , }, - - // Storage (GiB) { id: "storage", accessorFn: (row) => row.storage, @@ -171,43 +262,16 @@ export function createOccupancyColumns(groupBy: OccupancyGroupBy): ColumnDef , }, - // High priority count - { - id: "high", - enableSorting: false, - header: "High", - cell: ({ row }) => ( - - ), - }, - - // Normal priority count - { - id: "normal", + ...(["high", "normal", "low"] as const).map((p) => ({ + id: p, enableSorting: false, - header: "Normal", - cell: ({ row }) => ( + header: `${p[0].toUpperCase()}${p.slice(1)}`, + cell: ({ row }: { row: { original: OccupancyFlatRow } }) => ( ), - }, - - // Low priority count - { - id: "low", - enableSorting: false, - header: "Low", - cell: ({ row }) => ( - - ), - }, + })), ]; } diff --git a/src/ui/src/features/occupancy/components/occupancy-data-table.tsx b/src/ui/src/features/occupancy/components/occupancy-data-table.tsx index 56d63cc59..f496eb07a 100644 --- a/src/ui/src/features/occupancy/components/occupancy-data-table.tsx +++ b/src/ui/src/features/occupancy/components/occupancy-data-table.tsx @@ -24,6 +24,7 @@ import { useColumnVisibility } from "@/components/data-table/hooks/use-column-vi import type { SortState } from "@/components/data-table/types"; import { useCompactMode } from "@/hooks/shared-preferences-hooks"; import { TABLE_ROW_HEIGHTS } from "@/lib/config"; +import type { SearchChip } from "@/stores/types"; import type { OccupancyGroup, OccupancyFlatRow, OccupancyGroupBy } from "@/lib/api/adapter/occupancy"; import { MANDATORY_COLUMN_IDS, @@ -34,17 +35,9 @@ import { createOccupancyColumns } from "@/features/occupancy/components/occupanc import { useOccupancyTableStore } from "@/features/occupancy/stores/occupancy-table-store"; import "@/features/occupancy/styles/occupancy.css"; -// Module-level constant — stable reference, no useMemo needed const FIXED_COLUMNS = Array.from(MANDATORY_COLUMN_IDS); - -// Parent rows are interactive (toggle expand); child rows are not const isOccupancyRowInteractive = (row: OccupancyFlatRow) => row._type === "parent"; -// ============================================================================= -// Helpers -// ============================================================================= - -/** Flatten groups + expand state into a flat row array for DataTable */ function flattenForTable(groups: OccupancyGroup[], expandedKeys: Set): OccupancyFlatRow[] { return groups.flatMap((group, groupIndex) => { const parent: OccupancyFlatRow = { @@ -69,19 +62,15 @@ function flattenForTable(groups: OccupancyGroup[], expandedKeys: Set): O }); } -/** Stable row ID: parent = key, child = parentKey::key */ function getRowId(row: OccupancyFlatRow): string { if (row._type === "parent") return row.key; return `${row.parentKey}::${row.key}`; } -// ============================================================================= -// Types -// ============================================================================= - export interface OccupancyDataTableProps { groups: OccupancyGroup[]; groupBy: OccupancyGroupBy; + searchChips: SearchChip[]; expandedKeys: Set; onToggleExpand: (key: string) => void; isLoading?: boolean; @@ -89,13 +78,10 @@ export interface OccupancyDataTableProps { onRetry?: () => void; } -// ============================================================================= -// Component -// ============================================================================= - export const OccupancyDataTable = memo(function OccupancyDataTable({ groups, groupBy, + searchChips, expandedKeys, onToggleExpand, isLoading = false, @@ -105,7 +91,6 @@ export const OccupancyDataTable = memo(function OccupancyDataTable({ const compactMode = useCompactMode(); const rowHeight = compactMode ? TABLE_ROW_HEIGHTS.COMPACT : TABLE_ROW_HEIGHTS.NORMAL; - // Table store state const storeVisibleColumnIds = asOccupancyColumnIds(useOccupancyTableStore((s) => s.visibleColumnIds)); const columnOrder = asOccupancyColumnIds(useOccupancyTableStore((s) => s.columnOrder)); const setColumnOrder = useOccupancyTableStore((s) => s.setColumnOrder); @@ -116,10 +101,9 @@ export const OccupancyDataTable = memo(function OccupancyDataTable({ const columnVisibility = useColumnVisibility(columnOrder, storeVisibleColumnIds); - // Flatten groups into flat rows for DataTable (includes expanded children) const flatRows = useMemo(() => flattenForTable(groups, expandedKeys), [groups, expandedKeys]); - const columns = useMemo(() => createOccupancyColumns(groupBy), [groupBy]); + const columns = useMemo(() => createOccupancyColumns(groupBy, searchChips), [groupBy, searchChips]); const handleSortChange = useCallback( (newSort: SortState) => { @@ -135,12 +119,12 @@ export const OccupancyDataTable = memo(function OccupancyDataTable({ [onToggleExpand], ); - // Row styling: zebra striping keyed on group index so parent + all children share the same stripe + // Zebra striping keyed on group index so parent + children share the same stripe const rowClassName = useCallback((row: OccupancyFlatRow) => { const zebraClass = row._visualGroupIndex % 2 === 0 ? "bg-white dark:bg-zinc-950" : "bg-gray-100/60 dark:bg-zinc-900/50"; if (row._type === "child") return `occupancy-row occupancy-row--child ${zebraClass}`; - return `occupancy-row ${zebraClass}${row.isExpanded ? " font-medium" : ""}`; + return `occupancy-row group/occ-row ${zebraClass}${row.isExpanded ? " font-medium" : ""}`; }, []); const emptyContent = useMemo(() => , []); @@ -171,27 +155,21 @@ export const OccupancyDataTable = memo(function OccupancyDataTable({ data={flatRows} columns={columns} getRowId={getRowId} - // Column management columnOrder={columnOrder} onColumnOrderChange={setColumnOrder} columnVisibility={columnVisibility} fixedColumns={FIXED_COLUMNS} - // Column sizing columnSizeConfigs={OCCUPANCY_COLUMN_SIZE_CONFIG} columnSizingPreferences={columnSizingPreferences} onColumnSizingPreferenceChange={setColumnSizingPreference} - // Sorting sorting={sortState ?? undefined} onSortingChange={handleSortChange} - // Layout rowHeight={rowHeight} compact={compactMode} className="text-sm" scrollClassName="scrollbar-styled flex-1" - // State isLoading={isLoading} emptyContent={emptyContent} - // Interaction onRowClick={handleRowClick} rowClassName={rowClassName} isRowInteractive={isOccupancyRowInteractive} diff --git a/src/ui/src/features/occupancy/components/occupancy-toolbar.tsx b/src/ui/src/features/occupancy/components/occupancy-toolbar.tsx index 889c56711..2311bc45b 100644 --- a/src/ui/src/features/occupancy/components/occupancy-toolbar.tsx +++ b/src/ui/src/features/occupancy/components/occupancy-toolbar.tsx @@ -25,12 +25,9 @@ import type { ResultsCount } from "@/components/filter-bar/lib/types"; import type { SearchChip } from "@/stores/types"; import type { OccupancyGroup, OccupancyGroupBy } from "@/lib/api/adapter/occupancy"; import { OPTIONAL_COLUMNS } from "@/features/occupancy/lib/occupancy-columns"; -import { OCCUPANCY_SEARCH_FIELDS } from "@/features/occupancy/lib/occupancy-search-fields"; +import { getOccupancySearchFields } from "@/features/occupancy/lib/occupancy-search-fields"; import { useOccupancyTableStore } from "@/features/occupancy/stores/occupancy-table-store"; - -// ============================================================================= -// Types -// ============================================================================= +import { TASK_GROUP_STATUS_PRESETS } from "@/lib/task-group-status-presets"; export interface OccupancyToolbarProps { groups: OccupancyGroup[]; @@ -46,10 +43,6 @@ export interface OccupancyToolbarProps { isRefreshing: boolean; } -// ============================================================================= -// GroupBy Toggle -// ============================================================================= - const GROUP_BY_OPTIONS: { value: OccupancyGroupBy; label: string }[] = [ { value: "pool", label: "By Pool" }, { value: "user", label: "By User" }, @@ -99,10 +92,6 @@ function GroupByToggle({ value, onChange }: { value: OccupancyGroupBy; onChange: ); } -// ============================================================================= -// Component -// ============================================================================= - export const OccupancyToolbar = memo(function OccupancyToolbar({ groups, groupBy, @@ -119,6 +108,7 @@ export const OccupancyToolbar = memo(function OccupancyToolbar({ const visibleColumnIds = useOccupancyTableStore((s) => s.visibleColumnIds); const toggleColumn = useOccupancyTableStore((s) => s.toggleColumn); + const searchFields = useMemo(() => getOccupancySearchFields(groupBy), [groupBy]); const refreshProps: RefreshControlProps = useMemo(() => ({ onRefresh, isRefreshing }), [onRefresh, isRefreshing]); return ( @@ -130,13 +120,14 @@ export const OccupancyToolbar = memo(function OccupancyToolbar({
diff --git a/src/ui/src/features/occupancy/lib/occupancy-search-fields.ts b/src/ui/src/features/occupancy/lib/occupancy-search-fields.ts index 419d00552..697a9b0a3 100644 --- a/src/ui/src/features/occupancy/lib/occupancy-search-fields.ts +++ b/src/ui/src/features/occupancy/lib/occupancy-search-fields.ts @@ -16,72 +16,59 @@ import type { SearchField } from "@/components/filter-bar/lib/types"; import { WorkflowPriority, TaskGroupStatus } from "@/lib/api/generated"; -import type { OccupancyGroup } from "@/lib/api/adapter/occupancy"; +import type { OccupancyGroup, OccupancyGroupBy } from "@/lib/api/adapter/occupancy"; -/** - * FilterBar search field definitions for the occupancy page. - * - * Filtering is applied as API params (users/pools/priorities) so results - * reflect server-side filtering. The FilterBar chips map to API query params - * in use-occupancy-data.ts. - * - * Note: These search fields are used for autocomplete suggestions only. - * The actual filtering happens via the API query params in the data hook. - */ -export const OCCUPANCY_SEARCH_FIELDS: SearchField[] = [ - { - id: "user", - label: "User", - hint: "user name", - prefix: "user:", - freeFormHint: "Type any user, press Enter", - getValues: (groups) => groups.map((g) => g.key).slice(0, 20), - match: (group, value) => group.key.toLowerCase().includes(value.toLowerCase()), - }, - { - id: "pool", - label: "Pool", - hint: "pool name", - prefix: "pool:", - freeFormHint: "Type any pool, press Enter", - getValues: (groups) => { - const pools = new Set(); - for (const group of groups) { - for (const child of group.children) pools.add(child.key); - } - return [...pools].sort().slice(0, 20); +function getGroupKeys(groups: OccupancyGroup[]): string[] { + return groups.map((g) => g.key).slice(0, 20); +} + +function getChildKeys(groups: OccupancyGroup[]): string[] { + const keys = new Set(); + for (const group of groups) { + for (const child of group.children) keys.add(child.key); + } + return [...keys].sort().slice(0, 20); +} + +export function getOccupancySearchFields(groupBy: OccupancyGroupBy): SearchField[] { + const groupByPool = groupBy === "pool"; + + return [ + { + id: "pool", + label: "Pool", + hint: "pool name", + prefix: "pool:", + freeFormHint: "Type any pool, press Enter", + getValues: (groups) => (groupByPool ? getGroupKeys(groups) : getChildKeys(groups)), + }, + { + id: "user", + label: "User", + hint: "user name", + prefix: "user:", + freeFormHint: "Type any user, press Enter", + getValues: (groups) => (groupByPool ? getChildKeys(groups) : getGroupKeys(groups)), + }, + { + id: "priority", + label: "Priority", + hint: "HIGH, NORMAL, or LOW", + prefix: "priority:", + freeFormHint: "Type a priority, press Enter", + getValues: () => [WorkflowPriority.HIGH, WorkflowPriority.NORMAL, WorkflowPriority.LOW], + exhaustive: true, + requiresValidValue: true, + }, + { + id: "status", + label: "Status", + hint: "RUNNING, WAITING, ...", + prefix: "status:", + freeFormHint: "Type a status, press Enter", + getValues: () => Object.values(TaskGroupStatus), + exhaustive: true, + requiresValidValue: true, }, - match: () => true, // Filtering handled server-side - }, - { - id: "priority", - label: "Priority", - hint: "HIGH, NORMAL, or LOW", - prefix: "priority:", - freeFormHint: "Type a priority, press Enter", - getValues: () => [WorkflowPriority.HIGH, WorkflowPriority.NORMAL, WorkflowPriority.LOW], - exhaustive: true, - requiresValidValue: true, - match: () => true, // Filtering handled server-side - }, - { - id: "status", - label: "Status", - hint: "RUNNING, WAITING, ...", - prefix: "status:", - freeFormHint: "Type a status, press Enter", - getValues: () => [ - TaskGroupStatus.RUNNING, - TaskGroupStatus.WAITING, - TaskGroupStatus.SCHEDULING, - TaskGroupStatus.INITIALIZING, - TaskGroupStatus.SUBMITTING, - TaskGroupStatus.PROCESSING, - TaskGroupStatus.COMPLETED, - TaskGroupStatus.FAILED, - ], - exhaustive: true, - requiresValidValue: true, - match: () => true, // Filtering handled server-side - }, -]; + ]; +} diff --git a/src/ui/src/features/pools/components/panel/panel-content.tsx b/src/ui/src/features/pools/components/panel/panel-content.tsx index ad2475a6e..f4c0dfb09 100644 --- a/src/ui/src/features/pools/components/panel/panel-content.tsx +++ b/src/ui/src/features/pools/components/panel/panel-content.tsx @@ -17,7 +17,7 @@ "use client"; import React, { memo, useMemo, useCallback } from "react"; -import { CirclePile, Clock, AlertCircle, Server, Workflow, ArrowRight } from "lucide-react"; +import { CirclePile, Clock, AlertCircle, Server, Workflow, ChartColumn, ArrowRight } from "lucide-react"; import { Badge } from "@/components/shadcn/badge"; import { Card, CardContent } from "@/components/shadcn/card"; import { Link } from "@/components/link"; @@ -187,6 +187,19 @@ export const PanelContent = memo(function PanelContent({
+ + {/* Occupancy Link */} + + +
+
Occupancy
+
View GPU usage by user in this pool
+
+ + diff --git a/src/ui/src/features/pools/components/pools-toolbar.tsx b/src/ui/src/features/pools/components/pools-toolbar.tsx index 33f69009c..a83d6341a 100644 --- a/src/ui/src/features/pools/components/pools-toolbar.tsx +++ b/src/ui/src/features/pools/components/pools-toolbar.tsx @@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"; import type { Pool } from "@/lib/api/adapter/types"; import type { SearchChip } from "@/stores/types"; import type { SearchPreset, PresetRenderProps, ResultsCount } from "@/components/filter-bar/lib/types"; +import { presetPillClasses } from "@/components/filter-bar/lib/preset-pill"; import { TableToolbar } from "@/components/data-table/table-toolbar"; import type { RefreshControlProps } from "@/components/refresh/refresh-control"; import { usePoolsTableStore } from "@/features/pools/stores/pools-table-store"; @@ -29,7 +30,6 @@ import { OPTIONAL_COLUMNS } from "@/features/pools/lib/pool-columns"; import { createPoolSearchFields } from "@/features/pools/lib/pool-search-fields"; import { STATUS_STYLES, type StatusCategory } from "@/lib/pool-status"; -/** Status icons matching the table column badges */ const STATUS_ICONS = { online: CheckCircle2, maintenance: Wrench, @@ -41,29 +41,16 @@ export interface PoolsToolbarProps { sharingGroups?: string[][]; searchChips: SearchChip[]; onSearchChipsChange: (chips: SearchChip[]) => void; - /** Results count for displaying "N results" or "M of N results" */ resultsCount?: ResultsCount; - /** Optional auto-refresh controls (if not provided, no refresh button shown) */ autoRefreshProps?: RefreshControlProps; } -/** Status preset configurations */ const STATUS_PRESET_CONFIG: { id: StatusCategory; label: string }[] = [ { id: "online", label: "Online" }, { id: "maintenance", label: "Maintenance" }, { id: "offline", label: "Offline" }, ]; -function presetPillClasses(colorClasses: string, active: boolean): string { - return cn( - "inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all", - colorClasses, - active && "ring-2 ring-white/40 ring-inset dark:ring-white/20", - "group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg", - !active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100", - ); -} - export const PoolsToolbar = memo(function PoolsToolbar({ pools, sharingGroups = [], @@ -75,10 +62,8 @@ export const PoolsToolbar = memo(function PoolsToolbar({ const visibleColumnIds = usePoolsTableStore((s) => s.visibleColumnIds); const toggleColumn = usePoolsTableStore((s) => s.toggleColumn); - // Create search fields with sharing context const searchFields = useMemo(() => createPoolSearchFields(sharingGroups), [sharingGroups]); - // Create status presets for quick filtering with custom badge rendering const statusPresets = useMemo( (): SearchPreset[] => STATUS_PRESET_CONFIG.map(({ id, label }) => { @@ -88,9 +73,8 @@ export const PoolsToolbar = memo(function PoolsToolbar({ return { id, chips: [{ field: "status", value: id, label: `status: ${label}` }], - // Custom render matching the table's status badge exactly render: ({ active }: PresetRenderProps) => ( - + {label} @@ -114,7 +98,13 @@ export const PoolsToolbar = memo(function PoolsToolbar({ return [...nonScopeChips, { field: "scope", value: "user", label: "My Pools" }]; }, render: ({ active }: PresetRenderProps) => ( - + My Pools diff --git a/src/ui/src/features/workflows/detail/components/panel/core/lib/task-search-fields.tsx b/src/ui/src/features/workflows/detail/components/panel/core/lib/task-search-fields.tsx index 392e8eaef..e066f555b 100644 --- a/src/ui/src/features/workflows/detail/components/panel/core/lib/task-search-fields.tsx +++ b/src/ui/src/features/workflows/detail/components/panel/core/lib/task-search-fields.tsx @@ -14,41 +14,13 @@ // // SPDX-License-Identifier: Apache-2.0 -/** - * Task Search Fields Configuration - * - * Defines search fields for task filtering using the canonical FilterBar component. - * Includes: - * - Field definitions with match functions - * - Presets for quick status filtering - * - Duration and time parsing utilities - */ - -import { cn } from "@/lib/utils"; -import type { SearchField, SearchPreset, SearchChip } from "@/components/filter-bar/lib/types"; -import { - STATE_CATEGORIES, - STATE_CATEGORY_NAMES, - STATUS_LABELS, - type StateCategory, -} from "@/features/workflows/detail/lib/status-utils"; -import { TaskGroupStatus } from "@/lib/api/generated"; +import type { SearchField } from "@/components/filter-bar/lib/types"; import type { TaskWithDuration } from "@/features/workflows/detail/lib/workflow-types"; -// ============================================================================ -// Lazy-loaded chrono-node with idle prefetch -// ============================================================================ - -/** - * chrono-node is lazy-loaded to reduce initial bundle size (~40KB). - * It's prefetched during browser idle time, so it's ready when needed. - */ +// chrono-node is lazy-loaded (~40KB) and prefetched during browser idle time. let chronoModule: typeof import("chrono-node") | null = null; let chronoLoadPromise: Promise | null = null; -// Prefetch during browser idle time (non-blocking) -// The dynamic import itself is async and won't block, but we want to -// schedule it at an optimal time to avoid competing with initial render if (typeof window !== "undefined" && "requestIdleCallback" in window) { requestIdleCallback( () => { @@ -57,12 +29,10 @@ if (typeof window !== "undefined" && "requestIdleCallback" in window) { return m; }); }, - { timeout: 5000 }, // Load within 5 seconds even if not idle + { timeout: 5000 }, ); } else if (typeof window !== "undefined") { - // Safari fallback: Use RAF to wait for initial render to complete, - // then use another RAF to ensure we're past the paint cycle - // This avoids setTimeout long task violations while still deferring the load + // Safari fallback: double-RAF defers past the initial paint cycle requestAnimationFrame(() => { requestAnimationFrame(() => { chronoLoadPromise = import("chrono-node").then((m) => { @@ -73,17 +43,6 @@ if (typeof window !== "undefined" && "requestIdleCallback" in window) { }); } -/** - * Get chrono module, loading it if not already loaded. - * Returns null if not yet loaded (sync access). - */ -function getChronoSync(): typeof import("chrono-node") | null { - return chronoModule; -} - -/** - * Ensure chrono is loaded (for prefetch on focus). - */ export function ensureChronoLoaded(): void { if (!chronoModule && !chronoLoadPromise) { chronoLoadPromise = import("chrono-node").then((m) => { @@ -93,13 +52,6 @@ export function ensureChronoLoaded(): void { } } -// ============================================================================ -// Duration Parsing Utilities -// ============================================================================ - -/** - * Parse duration string like "1m", "30s", "2h", "1h30m" into milliseconds. - */ function parseDurationString(str: string): number | null { const normalized = str.toLowerCase().trim(); if (!normalized) return null; @@ -143,9 +95,6 @@ function parseDurationString(str: string): number | null { return null; } -/** - * Compare a value using operator prefix (>, >=, <, <=, =). - */ function compareWithOperator(taskValue: number, filterValue: string, parser: (s: string) => number | null): boolean { const trimmed = filterValue.trim(); let operator = ">="; @@ -187,29 +136,18 @@ function compareWithOperator(taskValue: number, filterValue: string, parser: (s: } } -// ============================================================================ -// Time Parsing Utilities -// ============================================================================ - // LRU cache for chrono parsing const chronoCache = new Map(); const CHRONO_CACHE_MAX = 100; -/** - * Parse natural language date string using chrono-node. - * Uses LRU cache for performance. - * Returns null if chrono isn't loaded yet (shouldn't happen with prefetch). - */ function parseDateTime(input: string): Date | null { if (!input?.trim()) return null; const key = input.trim().toLowerCase(); if (chronoCache.has(key)) return chronoCache.get(key)!; - // Get chrono module (may be null if not yet loaded) - const chrono = getChronoSync(); - if (!chrono) return null; // Chrono not loaded yet - graceful degradation + if (!chronoModule) return null; - const result = chrono.parseDate(input); + const result = chronoModule.parseDate(input); if (chronoCache.size >= CHRONO_CACHE_MAX) { const firstKey = chronoCache.keys().next().value; if (firstKey) chronoCache.delete(firstKey); @@ -280,14 +218,6 @@ function matchTimeFilter(taskTime: number, filterValue: string): boolean { return false; } -// ============================================================================ -// Field Definitions -// ============================================================================ - -/** - * Search field definitions for task filtering. - * Compatible with the canonical FilterBar component. - */ export const TASK_SEARCH_FIELDS: readonly SearchField[] = [ { id: "name", @@ -378,151 +308,3 @@ export const TASK_SEARCH_FIELDS: readonly SearchField[] = [ hint: "last 2h, >yesterday, = { - completed: { - dot: "bg-emerald-500", - bg: "bg-emerald-100 dark:bg-emerald-900/50", - text: "text-emerald-700 dark:text-emerald-300", - }, - running: { - dot: "bg-blue-500", - bg: "bg-blue-100 dark:bg-blue-900/50", - text: "text-blue-700 dark:text-blue-300", - }, - failed: { - dot: "bg-red-500", - bg: "bg-red-100 dark:bg-red-900/50", - text: "text-red-700 dark:text-red-300", - }, - pending: { - dot: "bg-gray-400 dark:bg-zinc-400", - bg: "bg-gray-100 dark:bg-zinc-700", - text: "text-gray-700 dark:text-zinc-300", - }, -}; - -// ============================================================================= -// Status Presets - DERIVED FROM STATE_CATEGORIES -// ============================================================================= - -/** - * Status presets derived from STATE_CATEGORIES. - * Each preset maps a state category to its corresponding TaskGroupStatus values. - * This enables presets to expand to individual status chips for server-side filtering. - */ -export const STATUS_PRESETS: Record = { - completed: [...STATE_CATEGORIES.completed] as TaskGroupStatus[], - running: [...STATE_CATEGORIES.running] as TaskGroupStatus[], - failed: [...STATE_CATEGORIES.failed] as TaskGroupStatus[], - pending: [...STATE_CATEGORIES.pending] as TaskGroupStatus[], -}; - -/** - * Create chips for a status preset. - * Expands a state category to individual status chips. - * Uses exact enum value as label for consistency with workflow chips. - */ -export function createPresetChips(stateCategory: StateCategory): SearchChip[] { - const statuses = STATUS_PRESETS[stateCategory]; - return statuses.map((status) => ({ - field: "status", - value: status, - label: `status: ${status}`, - })); -} - -/** - * Check if a preset is fully satisfied by the current chips. - * A preset is active only if ALL its statuses are present. - */ -export function isPresetActive(stateCategory: StateCategory, chips: SearchChip[]): boolean { - const presetStatuses = STATUS_PRESETS[stateCategory]; - const statusChips = chips.filter((c) => c.field === "status"); - const statusValues = new Set(statusChips.map((c) => c.value)); - - return presetStatuses.every((status) => statusValues.has(status)); -} - -/** - * Toggle a preset on/off. - * - If active (all statuses present): remove all preset statuses - * - If inactive: add all preset statuses - */ -export function togglePreset(stateCategory: StateCategory, chips: SearchChip[]): SearchChip[] { - const isActive = isPresetActive(stateCategory, chips); - const presetStatusArray = STATUS_PRESETS[stateCategory]; - const presetStatusSet = new Set(presetStatusArray); - - if (isActive) { - // Remove all preset statuses - return chips.filter((c) => !(c.field === "status" && presetStatusSet.has(c.value))); - } else { - // Add missing preset statuses - const existingStatuses = new Set(chips.filter((c) => c.field === "status").map((c) => c.value)); - const newChips = [...chips]; - - for (const status of presetStatusArray) { - if (!existingStatuses.has(status)) { - newChips.push({ - field: "status", - value: status, - label: `status: ${STATUS_LABELS[status] ?? status}`, - }); - } - } - - return newChips; - } -} - -/** - * Task state presets for quick filtering. - * Each preset expands to individual status chips (e.g., "Failed" → FAILED, FAILED_CANCELED, etc.) - * - * Uses the `chips` property (not deprecated `chip`) to specify multiple status chips. - * FilterBar will add/remove all chips together, and the preset is active only when all are present. - */ -export const TASK_PRESETS: { label: string; items: SearchPreset[] }[] = [ - { - label: "State", - items: STATE_CATEGORY_NAMES.map((state) => { - const colors = STATE_PRESET_COLORS[state]; - const label = state.charAt(0).toUpperCase() + state.slice(1); - - return { - id: `state-${state}`, - chips: createPresetChips(state), - render: ({ active, focused }) => ( - - - {label} - {active && } - - ), - }; - }), - }, -]; - -// ============================================================================ -// Re-export types for convenience -// ============================================================================ - -export type { SearchChip }; -export type { StateCategory }; diff --git a/src/ui/src/features/workflows/detail/components/panel/ui/group/group-tasks-tab.tsx b/src/ui/src/features/workflows/detail/components/panel/ui/group/group-tasks-tab.tsx index 36067f35f..0504b159e 100644 --- a/src/ui/src/features/workflows/detail/components/panel/ui/group/group-tasks-tab.tsx +++ b/src/ui/src/features/workflows/detail/components/panel/ui/group/group-tasks-tab.tsx @@ -41,10 +41,8 @@ import { import { createTaskColumns } from "@/features/workflows/detail/components/panel/core/lib/task-column-defs"; import { filterByChips } from "@/components/filter-bar/lib/filter"; import type { SearchChip } from "@/components/filter-bar/lib/types"; -import { - TASK_SEARCH_FIELDS, - TASK_PRESETS, -} from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_SEARCH_FIELDS } from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_GROUP_STATUS_PRESETS } from "@/lib/task-group-status-presets"; import { useTaskTableStore } from "@/features/workflows/detail/components/panel/core/stores/task-table-store"; import { TABLE_ROW_HEIGHTS } from "@/lib/config"; import { useResultsCount } from "@/components/filter-bar/hooks/use-results-count"; @@ -195,7 +193,7 @@ export const GroupTasksTab = memo(function GroupTasksTab({ onSearchChipsChange={setSearchChips} defaultField="name" placeholder="Filter by name, status:, ip:, duration:..." - searchPresets={TASK_PRESETS} + searchPresets={TASK_GROUP_STATUS_PRESETS} resultsCount={resultsCount} />
diff --git a/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-table.tsx b/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-table.tsx index a14d8ebc7..df9cec6a1 100644 --- a/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-table.tsx +++ b/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-table.tsx @@ -61,10 +61,8 @@ import { SplitGroupHeader } from "@/features/workflows/detail/components/panel/u import { TaskNameCell } from "@/features/workflows/detail/components/panel/ui/table/tree/task-name-cell"; import { filterByChips } from "@/components/filter-bar/lib/filter"; import type { SearchChip } from "@/components/filter-bar/lib/types"; -import { - TASK_SEARCH_FIELDS, - TASK_PRESETS, -} from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_SEARCH_FIELDS } from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_GROUP_STATUS_PRESETS } from "@/lib/task-group-status-presets"; import type { GroupWithLayout, @@ -627,7 +625,7 @@ export const WorkflowTasksTable = memo(function WorkflowTasksTable({ onSearchChipsChange={setSearchChips} defaultField="name" placeholder="Filter by name, status:, ip:, duration:..." - searchPresets={TASK_PRESETS} + searchPresets={TASK_GROUP_STATUS_PRESETS} resultsCount={resultsCount} />
diff --git a/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-toolbar.tsx b/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-toolbar.tsx index 7a2bceddb..bccef73db 100644 --- a/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-toolbar.tsx +++ b/src/ui/src/features/workflows/detail/components/panel/ui/table/workflow-tasks-toolbar.tsx @@ -14,14 +14,6 @@ // // SPDX-License-Identifier: Apache-2.0 -/** - * WorkflowTasksToolbar Component - * - * Toolbar for the workflow tasks table with search/filter capabilities. - * Uses the canonical TableToolbar component with task-specific search fields - * and status presets. - */ - "use client"; import { memo } from "react"; @@ -29,31 +21,17 @@ import type { SearchChip, ResultsCount } from "@/components/filter-bar/lib/types import { TableToolbar } from "@/components/data-table/table-toolbar"; import { useTaskTableStore } from "@/features/workflows/detail/components/panel/core/stores/task-table-store"; import { OPTIONAL_COLUMNS } from "@/features/workflows/detail/components/panel/core/lib/task-columns"; -import { - TASK_SEARCH_FIELDS, - TASK_PRESETS, -} from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_SEARCH_FIELDS } from "@/features/workflows/detail/components/panel/core/lib/task-search-fields"; +import { TASK_GROUP_STATUS_PRESETS } from "@/lib/task-group-status-presets"; import type { TaskWithDuration } from "@/features/workflows/detail/lib/workflow-types"; -// ============================================================================= -// Types -// ============================================================================= - export interface WorkflowTasksToolbarProps { - /** All tasks for FilterBar autocomplete */ tasks: TaskWithDuration[]; - /** Current search chips */ searchChips: SearchChip[]; - /** Callback when chips change */ onSearchChipsChange: (chips: SearchChip[]) => void; - /** Results count for displaying "N results" or "M of N results" */ resultsCount?: ResultsCount; } -// ============================================================================= -// Component -// ============================================================================= - export const WorkflowTasksToolbar = memo(function WorkflowTasksToolbar({ tasks, searchChips, @@ -74,7 +52,7 @@ export const WorkflowTasksToolbar = memo(function WorkflowTasksToolbar({ onSearchChipsChange={onSearchChipsChange} defaultField="name" placeholder="Search tasks... (try 'name:', 'status:', 'node:', 'duration:')" - searchPresets={TASK_PRESETS} + searchPresets={TASK_GROUP_STATUS_PRESETS} resultsCount={resultsCount} /> ); diff --git a/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx b/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx index 9b26e7870..5366584ee 100644 --- a/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx +++ b/src/ui/src/features/workflows/list/components/workflows-toolbar.tsx @@ -21,16 +21,14 @@ import { User } from "lucide-react"; import { cn } from "@/lib/utils"; import type { SearchChip } from "@/stores/types"; import type { SearchPreset, PresetRenderProps, ResultsCount, SearchField } from "@/components/filter-bar/lib/types"; +import { presetPillClasses } from "@/components/filter-bar/lib/preset-pill"; import { TableToolbar } from "@/components/data-table/table-toolbar"; import type { RefreshControlProps } from "@/components/refresh/refresh-control"; import { useWorkflowsTableStore } from "@/features/workflows/list/stores/workflows-table-store"; import { OPTIONAL_COLUMNS } from "@/features/workflows/list/lib/workflow-columns"; import type { WorkflowListEntry } from "@/lib/api/adapter/types"; -import { - WORKFLOW_STATIC_FIELDS, - createPresetChips, - type StatusPresetId, -} from "@/features/workflows/list/lib/workflow-search-fields"; +import { WORKFLOW_FIELD } from "@/features/workflows/list/lib/workflow-search-fields"; +import { createPresetChips, type StatusPresetId } from "@/lib/workflows/workflow-status-presets"; import { STATUS_STYLES } from "@/lib/workflows/workflow-constants"; import { WORKFLOW_STATUS_ICONS } from "@/lib/workflows/workflow-status-icons"; import { useWorkflowAsyncFields } from "@/features/workflows/list/hooks/use-workflow-async-fields"; @@ -42,16 +40,6 @@ const STATUS_PRESET_CONFIG: { id: StatusPresetId; label: string }[] = [ { id: "failed", label: "Failed" }, ]; -function presetPillClasses(bgClass: string, active: boolean): string { - return cn( - "inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all", - bgClass, - active && "ring-2 ring-black/15 ring-inset dark:ring-white/20", - "group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg", - !active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100", - ); -} - export interface WorkflowsToolbarProps { workflows: WorkflowListEntry[]; searchChips: SearchChip[]; @@ -76,13 +64,13 @@ export const WorkflowsToolbar = memo(function WorkflowsToolbar({ const searchFields = useMemo( (): readonly SearchField[] => [ - WORKFLOW_STATIC_FIELDS[0], // name - WORKFLOW_STATIC_FIELDS[1], // status - userField, // async - complete user list - poolField, // async - complete pool list - WORKFLOW_STATIC_FIELDS[2], // priority - WORKFLOW_STATIC_FIELDS[3], // app - WORKFLOW_STATIC_FIELDS[4], // tag + WORKFLOW_FIELD.name, + WORKFLOW_FIELD.status, + userField, + poolField, + WORKFLOW_FIELD.priority, + WORKFLOW_FIELD.app, + WORKFLOW_FIELD.tag, ], [userField, poolField], ); diff --git a/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts b/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts index 00e9851e8..63d34449d 100644 --- a/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts +++ b/src/ui/src/features/workflows/list/lib/workflow-search-fields.test.ts @@ -15,11 +15,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { - WORKFLOW_STATIC_FIELDS, - STATUS_PRESETS, - createPresetChips, -} from "@/features/workflows/list/lib/workflow-search-fields"; +import { WORKFLOW_STATIC_FIELDS } from "@/features/workflows/list/lib/workflow-search-fields"; +import { STATUS_PRESETS, createPresetChips } from "@/lib/workflows/workflow-status-presets"; import type { WorkflowListEntry } from "@/lib/api/adapter/types"; function createWorkflow(overrides: Partial = {}): WorkflowListEntry { diff --git a/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts b/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts index d5a16095f..a6e40a57f 100644 --- a/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts +++ b/src/ui/src/features/workflows/list/lib/workflow-search-fields.ts @@ -18,18 +18,11 @@ import type { SearchField } from "@/components/filter-bar/lib/types"; import { WorkflowPriority } from "@/lib/api/generated"; import type { WorkflowListEntry } from "@/lib/api/adapter/types"; import { ALL_WORKFLOW_STATUSES } from "@/lib/workflows/workflow-constants"; -import { STATUS_PRESETS, createPresetChips, type StatusPresetId } from "@/lib/workflows/workflow-status-presets"; import { naturalCompare } from "@/lib/utils"; -export { STATUS_PRESETS, createPresetChips }; -export type { StatusPresetId }; -/** - * Static workflow search fields. Async fields (user, pool) are provided - * separately by useWorkflowAsyncFields(). Filtering is server-side; - * no `match` functions are needed. - */ -export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = Object.freeze([ - { +// Static fields keyed by id. Async fields (user, pool) provided by useWorkflowAsyncFields(). +export const WORKFLOW_FIELD: Readonly>> = Object.freeze({ + name: { id: "name", label: "Name", hint: "workflow name (substring match)", @@ -38,7 +31,7 @@ export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = singular: true, getValues: (workflows) => workflows.map((w) => w.name).slice(0, 20), }, - { + status: { id: "status", label: "Status", hint: "workflow status", @@ -47,7 +40,7 @@ export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = exhaustive: true, requiresValidValue: true, }, - { + priority: { id: "priority", label: "Priority", hint: "HIGH, NORMAL, LOW", @@ -56,7 +49,7 @@ export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = exhaustive: true, requiresValidValue: true, }, - { + app: { id: "app", label: "App", hint: "app name", @@ -66,7 +59,7 @@ export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = getValues: (workflows) => [...new Set(workflows.map((w) => w.app_name).filter((a): a is string => !!a))].sort(naturalCompare), }, - { + tag: { id: "tag", label: "Tag", hint: "workflow tag", @@ -74,4 +67,8 @@ export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = freeFormHint: "Type any tag, press Enter", getValues: () => [], }, -]); +}); + +export const WORKFLOW_STATIC_FIELDS: readonly SearchField[] = Object.freeze( + Object.values(WORKFLOW_FIELD), +); diff --git a/src/ui/src/lib/api/adapter/occupancy-shim.ts b/src/ui/src/lib/api/adapter/occupancy-shim.ts index 2cd8c3174..d9ea14515 100644 --- a/src/ui/src/lib/api/adapter/occupancy-shim.ts +++ b/src/ui/src/lib/api/adapter/occupancy-shim.ts @@ -146,13 +146,18 @@ export function sortGroupsLocal( // ============================================================================= export async function fetchOccupancySummary(params: OccupancyQueryParams): Promise { + const hasPoolFilter = params.pools != null && params.pools.length > 0; + const hasUserFilter = params.users != null && params.users.length > 0; + const response = await listTaskApiTaskGet({ summary: true, limit: MAX_SUMMARY_ROWS, - ...(params.users && params.users.length > 0 ? { users: params.users } : {}), - ...(params.pools && params.pools.length > 0 ? { pools: params.pools } : {}), + ...(hasUserFilter ? { users: params.users } : {}), + ...(hasPoolFilter ? { pools: params.pools } : {}), ...(params.priorities && params.priorities.length > 0 ? { priority: params.priorities } : {}), ...(params.statuses && params.statuses.length > 0 ? { statuses: params.statuses } : {}), + all_pools: !hasPoolFilter, + all_users: !hasUserFilter, }); // customFetch throws on 4xx/5xx — we only reach here on 200 diff --git a/src/ui/src/lib/task-group-status-presets.tsx b/src/ui/src/lib/task-group-status-presets.tsx new file mode 100644 index 000000000..abb580d06 --- /dev/null +++ b/src/ui/src/lib/task-group-status-presets.tsx @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/lib/utils"; +import type { SearchPreset, PresetRenderProps } from "@/components/filter-bar/lib/types"; +import { presetPillClasses } from "@/components/filter-bar/lib/preset-pill"; +import type { SearchChip } from "@/stores/types"; +import { TaskGroupStatus } from "@/lib/api/generated"; +import { TASK_STATUS_METADATA } from "@/lib/api/status-metadata.generated"; +import { WORKFLOW_STATUS_UI_STYLES } from "@/lib/workflows/workflow-status-primitives"; +import { WORKFLOW_STATUS_ICONS } from "@/lib/workflows/workflow-status-icons"; + +export type TaskStateCategory = "completed" | "running" | "failed" | "waiting"; + +export const TASK_STATE_CATEGORY_ORDER: readonly TaskStateCategory[] = ["running", "waiting", "completed", "failed"]; + +function buildTaskStateCategories(): Record { + const cats: Record = { + completed: [], + running: [], + failed: [], + waiting: [], + }; + for (const [status, meta] of Object.entries(TASK_STATUS_METADATA)) { + switch (meta.category) { + case "completed": + cats.completed.push(status as TaskGroupStatus); + break; + case "running": + cats.running.push(status as TaskGroupStatus); + break; + case "failed": + cats.failed.push(status as TaskGroupStatus); + break; + case "waiting": + case "pending": + cats.waiting.push(status as TaskGroupStatus); + break; + case "unknown": + cats.failed.push(status as TaskGroupStatus); + break; + } + } + return cats; +} + +export const TASK_STATE_CATEGORIES: Record = buildTaskStateCategories(); + +function buildTaskStateChips(category: TaskStateCategory): SearchChip[] { + return TASK_STATE_CATEGORIES[category].map((status) => ({ + field: "status", + value: status, + label: `status: ${status}`, + })); +} + +export const TASK_GROUP_STATUS_PRESETS: { label: string; items: SearchPreset[] }[] = [ + { + label: "Status:", + items: TASK_STATE_CATEGORY_ORDER.map((state) => { + const styles = WORKFLOW_STATUS_UI_STYLES[state]; + const Icon = WORKFLOW_STATUS_ICONS[state]; + const label = state.charAt(0).toUpperCase() + state.slice(1); + return { + id: `state-${state}`, + chips: buildTaskStateChips(state), + render: ({ active }: PresetRenderProps) => ( + + + {label} + + ), + }; + }), + }, +];