Skip to content

Commit 94bc190

Browse files
[cueweb/docs] Host and Allocation Management: Hosts monitor page - Initial version (#2391)
## Related Issues - #2292 This is the foundation for the other Host & Allocation Management issues that need a host listing surface to build on: #2293 (lock/unlock), #2294 (reboot), #2295 (host detail page), #2296 (host tag editor). ## Summarize your change. Adds a `/hosts` Monitor Hosts page to CueWeb: the web equivalent of CueGUI's CueCommander Monitor Hosts plugin (`cuegui/cuegui/plugins/MonitorHostsPlugin.py`, `cuegui/cuegui/HostMonitorTree.py`). CueWeb previously had no hosts page; the sidebar `CueCommander > Monitor Hosts` link 404'd. This provides the basic sortable, auto-refreshing table the issue asks for. - New page at `app/hosts/page.tsx`: loads hosts via the existing `getHosts()` proxy (`/api/host/gethosts`) and auto-refreshes every 30s, with loading (skeletons), empty, and error (inline message + Retry) states. Read-only. - Sortable, filterable table (`app/hosts/columns.tsx`) with columns: Name, State, Locked, NIMBY, Cores (Idle/Total), Memory (Idle/Total), Free /mcp. State/Locked reuse the existing `Status` badge. Numeric columns sort by their underlying value, not the formatted string. - `Host` type widened (`app/utils/get_utils.ts`) to include the fields the table needs. Memory/`mcp` values arrive from the gateway as KB-in-string, so there are small parse/format helpers (`app/hosts/host_format_utils.ts`) with Jest unit tests. - `getHosts()` (`app/utils/get_utils.ts`) now throws on a failed request instead of collapsing failures into `[]`, so the page can tell a real Cuebot/gateway outage from an empty registry and actually render the inline error + **Retry** (the dashboard host widgets get the same fix for free). - `SimpleDataTable` (shared by jobs/layers/frames) gains an `isHostsTable` flag, mirroring the existing `isFramesTable` / `isFramesLogTable` flags: host-specific filter placeholder and empty-state copy, and no row context menu. It defaults to `false`, so existing callers are unchanged, verified by the existing test suite still passing. - Docs: Monitor Hosts section in the CueWeb user guide (with CueCommander-menu and light/dark page screenshots), a feature entry in the CueWeb overview (`other-guides/cueweb.md`), and a reference subsection plus the `GetHosts` endpoint / `/api/host/gethosts` proxy-route entries (`reference/cueweb.md`). **Idle/Total vs used/total:** the issue text says "used/total", but the backend and CueGUI's Monitor Hosts both expose idle vs total, so the columns show and are labeled "(Idle/Total)" to stay faithful to the data source. **Scope:** read-only by design. Host actions (lock/unlock, tag edit, reboot, NIMBY toggle) and server-side filtering belong to the sibling issues (#2293#2296) and are intentionally out of scope here. **Testing:** added Jest unit tests for the formatting/sort helpers; full CueWeb suite passes (48/48). Manually verified against the local sandbox, the page lists the rqd hosts, sorting and the substring filter work, the table refreshes every 30s, and the existing jobs/layers/frames tables are unaffected by the `SimpleDataTable` change. ## LLM usage disclosure Hai Shun: @ttpss930141011 - Claude (Opus) was used to explore the existing CueGUI patterns, draft unit tests and the PR request, and write the documentation. Co-authored-by: Hai Shun <o927416847@gmail.com> Co-authored-by: Ramon Figueiredo <rfigueiredo@imageworks.com>
1 parent c8618f6 commit 94bc190

13 files changed

Lines changed: 372 additions & 23 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { kbStringToHuman, kbStringToNumber, idleRatio } from "@/app/hosts/host_format_utils";
2+
3+
describe("host_format_utils", () => {
4+
describe("kbStringToNumber", () => {
5+
it("parses a numeric KB string", () => {
6+
expect(kbStringToNumber("6815744")).toBe(6815744);
7+
});
8+
it("returns 0 for non-numeric or empty input", () => {
9+
expect(kbStringToNumber("")).toBe(0);
10+
expect(kbStringToNumber("abc")).toBe(0);
11+
expect(kbStringToNumber(undefined as unknown as string)).toBe(0);
12+
});
13+
});
14+
15+
describe("kbStringToHuman", () => {
16+
it("formats KB strings to a human unit", () => {
17+
expect(kbStringToHuman("6815744")).toBe("6.5G");
18+
expect(kbStringToHuman("512")).toBe("512K");
19+
});
20+
it("renders a dash for non-numeric input", () => {
21+
expect(kbStringToHuman("abc")).toBe("-");
22+
expect(kbStringToHuman("")).toBe("-");
23+
});
24+
});
25+
26+
describe("idleRatio", () => {
27+
it("returns idle / total as a 0..1 ratio", () => {
28+
expect(idleRatio(4, 8)).toBe(0.5);
29+
});
30+
it("returns 0 when total is 0 to avoid divide-by-zero", () => {
31+
expect(idleRatio(0, 0)).toBe(0);
32+
});
33+
});
34+
});

cueweb/app/hosts/columns.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"use client";
2+
3+
/*
4+
* Copyright Contributors to the OpenCue Project
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import { ColumnDef } from "@tanstack/react-table";
20+
import { ArrowUpDown } from "lucide-react";
21+
import { Button } from "@/components/ui/button";
22+
import { Status } from "@/components/ui/status";
23+
import { Host } from "@/app/utils/get_utils";
24+
import { idleRatio, kbStringToHuman, kbStringToNumber } from "@/app/hosts/host_format_utils";
25+
26+
function sortableHeader(label: string) {
27+
// eslint-disable-next-line react/display-name
28+
return ({ column }: { column: any }) => (
29+
<Button
30+
variant="ghost"
31+
size="sm"
32+
className="-mx-2 h-7 px-1.5 text-xs font-medium"
33+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
34+
>
35+
{label}
36+
<ArrowUpDown className="ml-1 h-3 w-3 opacity-60" />
37+
</Button>
38+
);
39+
}
40+
41+
export const hostColumns: ColumnDef<Host>[] = [
42+
{
43+
accessorKey: "name",
44+
header: sortableHeader("Name"),
45+
cell: ({ row }) => <span>{row.original.name}</span>,
46+
},
47+
{
48+
accessorKey: "state",
49+
header: sortableHeader("State"),
50+
cell: ({ row }) => <Status status={row.original.state} />,
51+
},
52+
{
53+
accessorKey: "lockState",
54+
id: "locked",
55+
header: sortableHeader("Locked"),
56+
cell: ({ row }) => <Status status={row.original.lockState} />,
57+
},
58+
{
59+
accessorKey: "nimbyEnabled",
60+
id: "nimby",
61+
header: sortableHeader("NIMBY"),
62+
cell: ({ row }) => <span>{row.original.nimbyEnabled ? "Yes" : "No"}</span>,
63+
},
64+
{
65+
id: "cores",
66+
header: sortableHeader("Cores (Idle/Total)"),
67+
// Sort by idle ratio so "most free" sorts together regardless of host size.
68+
accessorFn: (h) => idleRatio(h.idleCores, h.cores),
69+
cell: ({ row }) => (
70+
<span>
71+
{row.original.idleCores.toFixed(2)} / {row.original.cores.toFixed(2)}
72+
</span>
73+
),
74+
},
75+
{
76+
id: "memory",
77+
header: sortableHeader("Memory (Idle/Total)"),
78+
// Sort by idle ratio (matching Cores), not the formatted string.
79+
accessorFn: (h) => idleRatio(kbStringToNumber(h.idleMemory), kbStringToNumber(h.totalMemory)),
80+
cell: ({ row }) => (
81+
<span>
82+
{kbStringToHuman(row.original.idleMemory)} / {kbStringToHuman(row.original.totalMemory)}
83+
</span>
84+
),
85+
},
86+
{
87+
id: "freeMcp",
88+
header: sortableHeader("Free /mcp"),
89+
accessorFn: (h) => kbStringToNumber(h.freeMcp),
90+
cell: ({ row }) => <span>{kbStringToHuman(row.original.freeMcp)}</span>,
91+
},
92+
];
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { convertMemoryToString } from "@/app/utils/layers_frames_utils";
18+
19+
// The gateway sends memory / mcp sizes as KB strings (e.g. "6815744").
20+
// Non-numeric input becomes 0 so callers never get NaN.
21+
export function kbStringToNumber(kb: string): number {
22+
const n = Number(kb);
23+
return Number.isFinite(n) ? n : 0;
24+
}
25+
26+
export function kbStringToHuman(kb: string): string {
27+
const n = Number(kb);
28+
// Number("") === 0 (finite), so guard "" explicitly to show "-" not "0K".
29+
if (kb === "" || !Number.isFinite(n)) {
30+
return "-";
31+
}
32+
return convertMemoryToString(n, "host");
33+
}
34+
35+
// Guards divide-by-zero; used as a numeric sort key.
36+
export function idleRatio(idle: number, total: number): number {
37+
if (!total) return 0;
38+
return idle / total;
39+
}

cueweb/app/hosts/page.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"use client";
2+
3+
/*
4+
* Copyright Contributors to the OpenCue Project
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import * as React from "react";
20+
import { Host, getHosts } from "@/app/utils/get_utils";
21+
import { hostColumns } from "@/app/hosts/columns";
22+
import { SimpleDataTable } from "@/components/ui/simple-data-table";
23+
import { Button } from "@/components/ui/button";
24+
import { Skeleton } from "@/components/ui/skeleton";
25+
26+
const REFRESH_MS = 30000;
27+
28+
export default function HostsPage() {
29+
const [hosts, setHosts] = React.useState<Host[] | null>(null);
30+
const [error, setError] = React.useState<string | null>(null);
31+
32+
// isCancelled lets the polling effect drop a late response after unmount;
33+
// the Retry button omits it.
34+
const load = React.useCallback(async (isCancelled?: () => boolean) => {
35+
try {
36+
const data = await getHosts();
37+
if (isCancelled?.()) return;
38+
setHosts(data);
39+
setError(null);
40+
} catch (err) {
41+
if (isCancelled?.()) return;
42+
// Keep previously loaded rows on a failed poll; only blank to [] if we
43+
// never loaded anything. getHosts already toasts via handleError.
44+
setError(err instanceof Error ? err.message : String(err));
45+
setHosts((prev) => prev ?? []);
46+
}
47+
}, []);
48+
49+
React.useEffect(() => {
50+
let cancelled = false;
51+
const isCancelled = () => cancelled;
52+
load(isCancelled);
53+
const interval = setInterval(() => load(isCancelled), REFRESH_MS);
54+
return () => {
55+
cancelled = true;
56+
clearInterval(interval);
57+
};
58+
}, [load]);
59+
60+
return (
61+
<div className="p-4">
62+
<h1 className="mb-4 text-lg font-semibold">Monitor Hosts</h1>
63+
64+
{hosts === null ? (
65+
<div className="space-y-2">
66+
<Skeleton className="h-8 w-full" />
67+
<Skeleton className="h-8 w-full" />
68+
<Skeleton className="h-8 w-full" />
69+
</div>
70+
) : (
71+
<>
72+
{error && hosts.length === 0 ? (
73+
<div className="mb-3 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm">
74+
<span>Could not load hosts from Cuebot.</span>
75+
<Button size="sm" variant="outline" onClick={() => load()}>
76+
Retry
77+
</Button>
78+
</div>
79+
) : null}
80+
<SimpleDataTable
81+
columns={hostColumns}
82+
data={hosts}
83+
username=""
84+
isHostsTable
85+
columnVisibilityStorageKey="cueweb.hosts.columnVisibility"
86+
/>
87+
</>
88+
)}
89+
</div>
90+
);
91+
}

cueweb/app/utils/get_utils.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,15 @@ export type Depend = {
5151
export type Host = {
5252
id: string;
5353
name: string;
54-
state: string;
55-
lockState: string;
54+
state: string; // UP / DOWN / REPAIR ...
55+
lockState: string; // OPEN / LOCKED / NIMBY_LOCKED
56+
nimbyEnabled: boolean;
57+
cores: number;
58+
idleCores: number;
59+
memory: string; // KB, as string from the gateway
60+
idleMemory: string; // KB, as string
61+
totalMemory: string; // KB, as string
62+
freeMcp: string; // KB, as string
5663
bootTime: number;
5764
pingTime: number;
5865
};
@@ -184,10 +191,15 @@ export const getFrameLogDir = (job: Job, frame: Frame): string => {
184191
};
185192

186193
// Fetch every host known to Cuebot. Optionally accepts a host-search filter (HostSearchCriteria).
194+
// Returns an array (possibly empty) on success; throws on a failed request so
195+
// callers can tell a real failure apart from an empty registry.
187196
export async function getHosts(body: string = JSON.stringify({ r: {} })): Promise<Host[]> {
188197
const ENDPOINT = "/api/host/gethosts";
189198
const response = await accessGetApi(ENDPOINT, body);
190-
return Array.isArray(response) ? response : [];
199+
if (!Array.isArray(response)) {
200+
throw new Error("Failed to load hosts from Cuebot.");
201+
}
202+
return response;
191203
}
192204

193205
// Fetch every show known to Cuebot.

cueweb/components/ui/simple-data-table.tsx

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
useReactTable,
4747
VisibilityState,
4848
} from "@tanstack/react-table";
49-
import { ChevronDown, ChevronLeft, ChevronRight, Layers, Search, X } from "lucide-react";
49+
import { ChevronDown, ChevronLeft, ChevronRight, Layers, Search, Server, X } from "lucide-react";
5050
import { usePathname, useRouter, useSearchParams } from "next/navigation";
5151
import * as React from "react";
5252
import { Job } from "../../app/jobs/columns";
@@ -66,6 +66,9 @@ interface SimpleDataTableProps<TData, TValue> {
6666
job?: Job;
6767
isFramesTable?: boolean;
6868
isFramesLogTable?: boolean;
69+
// Hosts variant (read-only): host-specific filter/empty copy and no row
70+
// context menu. Mirrors the isFramesTable / isFramesLogTable flags.
71+
isHostsTable?: boolean;
6972
username: string;
7073
// When set, column visibility for this table persists to localStorage
7174
// under the given key. Use stable keys like "cueweb.layers.columnVisibility"
@@ -97,6 +100,7 @@ export function SimpleDataTable<TData, TValue>({
97100
job,
98101
isFramesTable = false,
99102
isFramesLogTable = false,
103+
isHostsTable = false,
100104
username,
101105
columnVisibilityStorageKey,
102106
defaultColumnVisibility,
@@ -498,8 +502,8 @@ export function SimpleDataTable<TData, TValue>({
498502
type="search"
499503
value={globalFilter}
500504
onChange={(e) => setGlobalFilter(e.target.value)}
501-
placeholder={isFramesTable ? "Filter frames..." : "Filter layers..."}
502-
aria-label={isFramesTable ? "Filter frames" : "Filter layers"}
505+
placeholder={isHostsTable ? "Filter hosts..." : isFramesTable ? "Filter frames..." : "Filter layers..."}
506+
aria-label={isHostsTable ? "Filter hosts" : isFramesTable ? "Filter frames" : "Filter layers"}
503507
className="h-8 w-44 pl-7 pr-7 text-xs"
504508
/>
505509
{globalFilter ? (
@@ -566,7 +570,7 @@ export function SimpleDataTable<TData, TValue>({
566570
data-state={
567571
isSelectedById || row.getIsSelected() ? "selected" : undefined
568572
}
569-
onContextMenu={(e) => contextMenuHandleOpen(e, row)}
573+
onContextMenu={isHostsTable ? undefined : (e) => contextMenuHandleOpen(e, row)}
570574
onClick={
571575
onRowClick
572576
? () => onRowClick(row.original as TData)
@@ -603,20 +607,30 @@ export function SimpleDataTable<TData, TValue>({
603607
<TableRow>
604608
<TableCell colSpan={columns.length} className="h-32 p-0">
605609
<EmptyState
606-
icon={<Layers className="h-6 w-6" aria-hidden="true" />}
610+
icon={
611+
isHostsTable ? (
612+
<Server className="h-6 w-6" aria-hidden="true" />
613+
) : (
614+
<Layers className="h-6 w-6" aria-hidden="true" />
615+
)
616+
}
607617
title={
608-
isFramesTable
609-
? "Layer has no frames"
610-
: isFramesLogTable
611-
? "Frame not found"
612-
: "Job has no layers"
618+
isHostsTable
619+
? "No hosts registered"
620+
: isFramesTable
621+
? "Layer has no frames"
622+
: isFramesLogTable
623+
? "Frame not found"
624+
: "Job has no layers"
613625
}
614626
description={
615-
isFramesTable
616-
? "No frames matched the current filter. Clear the frame-state chips above to see every frame."
617-
: isFramesLogTable
618-
? "The frame referenced by this URL is no longer available in Cuebot."
619-
: "This job does not contain any layers yet."
627+
isHostsTable
628+
? "No hosts have reported to Cuebot yet."
629+
: isFramesTable
630+
? "No frames matched the current filter. Clear the frame-state chips above to see every frame."
631+
: isFramesLogTable
632+
? "The frame referenced by this URL is no longer available in Cuebot."
633+
: "This job does not contain any layers yet."
620634
}
621635
/>
622636
</TableCell>
@@ -635,8 +649,9 @@ export function SimpleDataTable<TData, TValue>({
635649
</div>
636650
)}
637651

638-
{/* Context menus for frames and layers */}
639-
{(isFramesTable || isFramesLogTable) ? (
652+
{/* Context menus for frames and layers. The read-only hosts table
653+
renders no menu. */}
654+
{isHostsTable ? null : (isFramesTable || isFramesLogTable) ? (
640655
<FrameContextMenu
641656
username={username}
642657
job={job}

0 commit comments

Comments
 (0)