Skip to content
Merged
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
54 changes: 5 additions & 49 deletions src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,82 +14,49 @@

//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";
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 }),
);

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,
order,
searchChips,
});

// ==========================================================================
// Toolbar props
// ==========================================================================

const resultsCount = useResultsCount({
total: groups.length,
filteredTotal: groups.length,
Expand All @@ -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<string> }>({
groupBy,
keys: new Set(),
Expand Down Expand Up @@ -140,13 +103,8 @@ export function OccupancyPageContent() {

const allExpanded = groups.length > 0 && expandedKeys.size === groups.length;

// ==========================================================================
// Render
// ==========================================================================

return (
<div className="flex h-full flex-col gap-4 p-6">
{/* Toolbar */}
<div className="shrink-0">
<InlineErrorBoundary
title="Toolbar error"
Expand All @@ -168,7 +126,6 @@ export function OccupancyPageContent() {
</InlineErrorBoundary>
</div>

{/* KPI summary cards */}
<div className="shrink-0">
<InlineErrorBoundary
title="Summary cards error"
Expand All @@ -181,15 +138,13 @@ export function OccupancyPageContent() {
</InlineErrorBoundary>
</div>

{/* Scale limit warning */}
{truncated && (
<div className="shrink-0 rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
Results may be incomplete — reached the 10,000 row fetch limit. Backend group_by pagination (Issue #23) is
required for full data at this scale.
</div>
)}

{/* Main table */}
<div className="min-h-0 flex-1">
<InlineErrorBoundary
title="Unable to display occupancy table"
Expand All @@ -199,6 +154,7 @@ export function OccupancyPageContent() {
<OccupancyDataTable
groups={groups}
groupBy={groupBy}
searchChips={searchChips}
expandedKeys={expandedKeys}
onToggleExpand={handleToggleExpand}
isLoading={isLoading}
Expand Down
52 changes: 40 additions & 12 deletions src/ui/src/components/filter-bar/hooks/use-default-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ import type { SearchChip } from "@/stores/types";
import { useUrlChips } from "@/components/filter-bar/hooks/use-url-chips";

/**
* Adds a default filter chip when no chip for `field` is present in the URL,
* Adds default filter chip(s) when no chip for `field` is present in the URL,
* unless the user has explicitly opted out via `?{optOutParam}=true`.
*
* Accepts a single `defaultValue` string or an array of strings (for multi-chip defaults
* like defaulting to all running-category statuses).
*
* - effectiveChips is computed synchronously so the first render uses correct params (no double-fetch).
* - The chip is NOT written to the URL on mount — only when the user interacts with the filter bar.
* Writing it on mount caused a race with nuqs: the effect fired before nuqs had fully parsed all
* repeated `f=` URL params, so it overwrote the URL with a partial chip set (losing all but the first).
* - Removing all chips for `field` sets ?{optOutParam}=true; adding one clears it.
* - After nuqs has parsed the URL (post-paint), an effect writes the default chip to the URL
* - After nuqs has parsed the URL (post-paint), an effect writes the default chip(s) to the URL
* using history:"replace" so that refresh/share reflects the active filter.
*/
export function useDefaultFilter({
Expand All @@ -40,7 +43,7 @@ export function useDefaultFilter({
optOutParam = "all",
}: {
field: string;
defaultValue: string | null | undefined;
defaultValue: string | string[] | null | undefined;
label?: string;
optOutParam?: string;
}): {
Expand All @@ -50,6 +53,11 @@ export function useDefaultFilter({
} {
const { searchChips, setSearchChips } = useUrlChips();

const normalizedDefaults = useMemo(
() => (defaultValue == null ? [] : Array.isArray(defaultValue) ? defaultValue : [defaultValue]),
[defaultValue],
);

const [optOut, setOptOut] = useQueryState(
optOutParam,
parseAsBoolean.withDefault(false).withOptions({
Expand All @@ -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[]) => {
Expand All @@ -91,5 +119,5 @@ export function useDefaultFilter({
[effectiveChips, field, optOut, setOptOut, setSearchChips],
);

return { effectiveChips, handleChipsChange, optOut };
return { effectiveChips, handleChipsChange, optOut: effectiveOptOut };
}
23 changes: 17 additions & 6 deletions src/ui/src/components/filter-bar/hooks/use-url-chips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,33 @@
"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";

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,
Expand Down
31 changes: 31 additions & 0 deletions src/ui/src/components/filter-bar/lib/preset-pill.ts
Original file line number Diff line number Diff line change
@@ -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",
);
}
Loading
Loading