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
34 changes: 34 additions & 0 deletions cueweb/app/__tests__/hosts/host_format_utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { kbStringToHuman, kbStringToNumber, idleRatio } from "@/app/hosts/host_format_utils";

describe("host_format_utils", () => {
describe("kbStringToNumber", () => {
it("parses a numeric KB string", () => {
expect(kbStringToNumber("6815744")).toBe(6815744);
});
it("returns 0 for non-numeric or empty input", () => {
expect(kbStringToNumber("")).toBe(0);
expect(kbStringToNumber("abc")).toBe(0);
expect(kbStringToNumber(undefined as unknown as string)).toBe(0);
});
});

describe("kbStringToHuman", () => {
it("formats KB strings to a human unit", () => {
expect(kbStringToHuman("6815744")).toBe("6.5G");
expect(kbStringToHuman("512")).toBe("512K");
});
it("renders a dash for non-numeric input", () => {
expect(kbStringToHuman("abc")).toBe("-");
expect(kbStringToHuman("")).toBe("-");
});
});

describe("idleRatio", () => {
it("returns idle / total as a 0..1 ratio", () => {
expect(idleRatio(4, 8)).toBe(0.5);
});
it("returns 0 when total is 0 to avoid divide-by-zero", () => {
expect(idleRatio(0, 0)).toBe(0);
});
});
});
92 changes: 92 additions & 0 deletions cueweb/app/hosts/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

/*
* Copyright Contributors to the OpenCue Project
*
* 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.
*/

import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Status } from "@/components/ui/status";
import { Host } from "@/app/utils/get_utils";
import { idleRatio, kbStringToHuman, kbStringToNumber } from "@/app/hosts/host_format_utils";

function sortableHeader(label: string) {
// eslint-disable-next-line react/display-name
return ({ column }: { column: any }) => (
<Button
variant="ghost"
size="sm"
className="-mx-2 h-7 px-1.5 text-xs font-medium"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{label}
<ArrowUpDown className="ml-1 h-3 w-3 opacity-60" />
</Button>
);
}

export const hostColumns: ColumnDef<Host>[] = [
{
accessorKey: "name",
header: sortableHeader("Name"),
cell: ({ row }) => <span>{row.original.name}</span>,
},
{
accessorKey: "state",
header: sortableHeader("State"),
cell: ({ row }) => <Status status={row.original.state} />,
},
{
accessorKey: "lockState",
id: "locked",
header: sortableHeader("Locked"),
cell: ({ row }) => <Status status={row.original.lockState} />,
},
{
accessorKey: "nimbyEnabled",
id: "nimby",
header: sortableHeader("NIMBY"),
cell: ({ row }) => <span>{row.original.nimbyEnabled ? "Yes" : "No"}</span>,
},
{
id: "cores",
header: sortableHeader("Cores (Idle/Total)"),
// Sort by idle ratio so "most free" sorts together regardless of host size.
accessorFn: (h) => idleRatio(h.idleCores, h.cores),
cell: ({ row }) => (
<span>
{row.original.idleCores.toFixed(2)} / {row.original.cores.toFixed(2)}
</span>
),
},
{
id: "memory",
header: sortableHeader("Memory (Idle/Total)"),
// Sort by idle ratio (matching Cores), not the formatted string.
accessorFn: (h) => idleRatio(kbStringToNumber(h.idleMemory), kbStringToNumber(h.totalMemory)),
cell: ({ row }) => (
<span>
{kbStringToHuman(row.original.idleMemory)} / {kbStringToHuman(row.original.totalMemory)}
</span>
),
},
{
id: "freeMcp",
header: sortableHeader("Free /mcp"),
accessorFn: (h) => kbStringToNumber(h.freeMcp),
cell: ({ row }) => <span>{kbStringToHuman(row.original.freeMcp)}</span>,
},
];
39 changes: 39 additions & 0 deletions cueweb/app/hosts/host_format_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright Contributors to the OpenCue Project
*
* 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.
*/

import { convertMemoryToString } from "@/app/utils/layers_frames_utils";

// The gateway sends memory / mcp sizes as KB strings (e.g. "6815744").
// Non-numeric input becomes 0 so callers never get NaN.
export function kbStringToNumber(kb: string): number {
const n = Number(kb);
return Number.isFinite(n) ? n : 0;
}

export function kbStringToHuman(kb: string): string {
const n = Number(kb);
// Number("") === 0 (finite), so guard "" explicitly to show "-" not "0K".
if (kb === "" || !Number.isFinite(n)) {
return "-";
}
return convertMemoryToString(n, "host");
}

// Guards divide-by-zero; used as a numeric sort key.
export function idleRatio(idle: number, total: number): number {
if (!total) return 0;
return idle / total;
}
91 changes: 91 additions & 0 deletions cueweb/app/hosts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"use client";

/*
* Copyright Contributors to the OpenCue Project
*
* 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.
*/

import * as React from "react";
import { Host, getHosts } from "@/app/utils/get_utils";
import { hostColumns } from "@/app/hosts/columns";
import { SimpleDataTable } from "@/components/ui/simple-data-table";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";

const REFRESH_MS = 30000;

export default function HostsPage() {
const [hosts, setHosts] = React.useState<Host[] | null>(null);
const [error, setError] = React.useState<string | null>(null);

// isCancelled lets the polling effect drop a late response after unmount;
// the Retry button omits it.
const load = React.useCallback(async (isCancelled?: () => boolean) => {
try {
const data = await getHosts();
if (isCancelled?.()) return;
setHosts(data);
setError(null);
} catch (err) {
if (isCancelled?.()) return;
// Keep previously loaded rows on a failed poll; only blank to [] if we
// never loaded anything. getHosts already toasts via handleError.
setError(err instanceof Error ? err.message : String(err));
setHosts((prev) => prev ?? []);
}
}, []);

React.useEffect(() => {
let cancelled = false;
const isCancelled = () => cancelled;
load(isCancelled);
const interval = setInterval(() => load(isCancelled), REFRESH_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [load]);

return (
<div className="p-4">
<h1 className="mb-4 text-lg font-semibold">Monitor Hosts</h1>

{hosts === null ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : (
<>
{error && hosts.length === 0 ? (
<div className="mb-3 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm">
<span>Could not load hosts from Cuebot.</span>
<Button size="sm" variant="outline" onClick={() => load()}>
Retry
</Button>
</div>
) : null}
<SimpleDataTable
columns={hostColumns}
data={hosts}
username=""
isHostsTable
columnVisibilityStorageKey="cueweb.hosts.columnVisibility"
/>
</>
)}
</div>
);
}
18 changes: 15 additions & 3 deletions cueweb/app/utils/get_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ export type Depend = {
export type Host = {
id: string;
name: string;
state: string;
lockState: string;
state: string; // UP / DOWN / REPAIR ...
lockState: string; // OPEN / LOCKED / NIMBY_LOCKED
nimbyEnabled: boolean;
cores: number;
idleCores: number;
memory: string; // KB, as string from the gateway
idleMemory: string; // KB, as string
totalMemory: string; // KB, as string
freeMcp: string; // KB, as string
bootTime: number;
pingTime: number;
};
Expand Down Expand Up @@ -184,10 +191,15 @@ export const getFrameLogDir = (job: Job, frame: Frame): string => {
};

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

// Fetch every show known to Cuebot.
Expand Down
49 changes: 32 additions & 17 deletions cueweb/components/ui/simple-data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { ChevronDown, ChevronLeft, ChevronRight, Layers, Search, X } from "lucide-react";
import { ChevronDown, ChevronLeft, ChevronRight, Layers, Search, Server, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
import { Job } from "../../app/jobs/columns";
Expand All @@ -66,6 +66,9 @@ interface SimpleDataTableProps<TData, TValue> {
job?: Job;
isFramesTable?: boolean;
isFramesLogTable?: boolean;
// Hosts variant (read-only): host-specific filter/empty copy and no row
// context menu. Mirrors the isFramesTable / isFramesLogTable flags.
isHostsTable?: boolean;
username: string;
// When set, column visibility for this table persists to localStorage
// under the given key. Use stable keys like "cueweb.layers.columnVisibility"
Expand Down Expand Up @@ -97,6 +100,7 @@ export function SimpleDataTable<TData, TValue>({
job,
isFramesTable = false,
isFramesLogTable = false,
isHostsTable = false,
username,
columnVisibilityStorageKey,
defaultColumnVisibility,
Expand Down Expand Up @@ -498,8 +502,8 @@ export function SimpleDataTable<TData, TValue>({
type="search"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder={isFramesTable ? "Filter frames..." : "Filter layers..."}
aria-label={isFramesTable ? "Filter frames" : "Filter layers"}
placeholder={isHostsTable ? "Filter hosts..." : isFramesTable ? "Filter frames..." : "Filter layers..."}
aria-label={isHostsTable ? "Filter hosts" : isFramesTable ? "Filter frames" : "Filter layers"}
className="h-8 w-44 pl-7 pr-7 text-xs"
/>
{globalFilter ? (
Expand Down Expand Up @@ -566,7 +570,7 @@ export function SimpleDataTable<TData, TValue>({
data-state={
isSelectedById || row.getIsSelected() ? "selected" : undefined
}
onContextMenu={(e) => contextMenuHandleOpen(e, row)}
onContextMenu={isHostsTable ? undefined : (e) => contextMenuHandleOpen(e, row)}
onClick={
onRowClick
? () => onRowClick(row.original as TData)
Expand Down Expand Up @@ -603,20 +607,30 @@ export function SimpleDataTable<TData, TValue>({
<TableRow>
<TableCell colSpan={columns.length} className="h-32 p-0">
<EmptyState
icon={<Layers className="h-6 w-6" aria-hidden="true" />}
icon={
isHostsTable ? (
<Server className="h-6 w-6" aria-hidden="true" />
) : (
<Layers className="h-6 w-6" aria-hidden="true" />
)
}
title={
isFramesTable
? "Layer has no frames"
: isFramesLogTable
? "Frame not found"
: "Job has no layers"
isHostsTable
? "No hosts registered"
: isFramesTable
? "Layer has no frames"
: isFramesLogTable
? "Frame not found"
: "Job has no layers"
}
description={
isFramesTable
? "No frames matched the current filter. Clear the frame-state chips above to see every frame."
: isFramesLogTable
? "The frame referenced by this URL is no longer available in Cuebot."
: "This job does not contain any layers yet."
isHostsTable
? "No hosts have reported to Cuebot yet."
: isFramesTable
? "No frames matched the current filter. Clear the frame-state chips above to see every frame."
: isFramesLogTable
? "The frame referenced by this URL is no longer available in Cuebot."
: "This job does not contain any layers yet."
}
/>
</TableCell>
Expand All @@ -635,8 +649,9 @@ export function SimpleDataTable<TData, TValue>({
</div>
)}

{/* Context menus for frames and layers */}
{(isFramesTable || isFramesLogTable) ? (
{/* Context menus for frames and layers. The read-only hosts table
renders no menu. */}
{isHostsTable ? null : (isFramesTable || isFramesLogTable) ? (
<FrameContextMenu
username={username}
job={job}
Expand Down
Loading
Loading