Put resources where somebody would look for them

`resources` was reachable from the SDK and the API and nowhere else, and
`node_queued` was published to nothing at all — so a node waiting for a machine
looked exactly like a node that had hung, which is the failure the event was
added for.

The node panel gets a Resources section: a size by name, the numbers for a node
that wants its own, and how long it is expected to take. Picking a flavor drops
the numbers, because saying both is two answers and the engine refuses it.

A queued node draws a neutral dot rather than one of the three status colours —
it is not running, it did not go well, and it did not go wrong; it is idle with
a reason, which the tooltip gives.

And a Workers screen, which is the first UI for any of this: every machine the
engine can reach with what is free of each, what is attached, what a cluster
has been asked for, and the sizes, editable. `fluksio status` grew a line of
the same.

The demo's training node already preferred a GPU worker, which was exactly the
declaration that used to be dropped, so it now says how much of that machine it
takes as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
This commit is contained in:
2026-08-27 09:32:08 +02:00
co-authored by Claude Opus 5
parent 40f8ad378d
commit 7d87dfffdd
12 changed files with 1156 additions and 7 deletions
+439
View File
@@ -0,0 +1,439 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Cpu, Pencil, Plus, Server, Trash2 } from "lucide-react"
import { useState } from "react"
import {
type ApiError,
type FlavorPublic,
FlavorsService,
WorkersService,
} from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/workers")({
component: Workers,
head: () => ({
meta: [{ title: "Workers - Fluksio" }],
}),
})
/** The node panel's dropdown reads this too, so an edit refreshes it. */
const flavorsKey = ["flavors"]
const emptyFlavor = { name: "", cpus: 1, gpus: 0, ram: 2048, description: "" }
/** Fresh enough to watch a node take a machine, slow enough to be free. */
const REFETCH_MS = 5000
function ago(seconds: number): string {
const since = Date.now() / 1000 - seconds
if (since < 60) return `${Math.max(0, Math.round(since))}s ago`
if (since < 3600) return `${Math.round(since / 60)}m ago`
return `${Math.round(since / 3600)}h ago`
}
type Level = { total: number; free: number }
/** "cpu 2/18" — what is in use, out of what there is. */
function Used({ label, level }: { label: string; level?: Level | null }) {
if (!level?.total) return null
return (
<span className="font-mono text-xs text-muted-foreground">
{label} {level.total - level.free}/{level.total}
</span>
)
}
function Machines() {
const { data, isError } = useQuery({
queryKey: ["workers", "resources"],
queryFn: () => WorkersService.readResources(),
refetchInterval: REFETCH_MS,
})
if (isError || !data) {
return (
<p className="text-sm text-muted-foreground">
This engine does not account for resources.
</p>
)
}
return (
<div className="grid gap-2">
{(data.targets ?? []).map((target) => (
<div
key={target.target}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
>
<span className="flex items-center gap-2 text-sm">
<Cpu className="size-4 text-muted-foreground" />
{target.target}
{(target.labels ?? []).length > 0 ? (
<span className="font-mono text-xs text-muted-foreground">
{(target.labels ?? []).join(", ")}
</span>
) : null}
</span>
<span className="flex items-center gap-3">
<Used label="cpu" level={target.cpus} />
<Used label="gpu" level={target.gpus} />
<Used label="MB" level={target.ram_mb} />
</span>
</div>
))}
{(data.waiting ?? []).length > 0 ? (
<div className="grid gap-1 pt-1">
{(data.waiting ?? []).map((node) => (
<p key={node.node} className="text-xs text-muted-foreground">
<span className="font-mono">{node.node}</span> {node.reason} ·{" "}
{node.seconds.toFixed(0)}s
</p>
))}
</div>
) : null}
{(data.provisioners ?? []).map((provisioner) => {
const outstanding = (provisioner.outstanding ?? []) as {
profile: string
job: string
seconds: number
}[]
return (
<p
key={String(provisioner.name)}
className="text-xs text-muted-foreground"
>
{String(provisioner.name)} can start{" "}
{(provisioner.profiles as string[]).join(", ")}
{outstanding.length > 0
? ` — asked for ${outstanding
.map((job) => `${job.profile} (job ${job.job})`)
.join(", ")}`
: ""}
{provisioner.last_error
? ` — last failed: ${String(provisioner.last_error)}`
: ""}
</p>
)
})}
</div>
)
}
function Attached() {
const { data } = useQuery({
queryKey: ["workers"],
queryFn: () => WorkersService.readWorkers(),
refetchInterval: REFETCH_MS,
})
const workers = data ?? []
if (workers.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Nothing attached. A machine runs{" "}
<code className="font-mono">pip install fluksio-worker</code> and dials
in; nodes go to it by label, or because it has room and this engine does
not.
</p>
)
}
return (
<div className="grid gap-2">
{workers.map((worker) => (
<div
key={worker.name}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
>
<span className="grid gap-0.5">
<span className="flex items-center gap-2 text-sm">
<Server className="size-4 text-muted-foreground" />
{worker.name}
{(worker.labels ?? []).length > 0 ? (
<span className="font-mono text-xs text-muted-foreground">
{(worker.labels ?? []).join(", ")}
</span>
) : null}
</span>
<span className="pl-6 font-mono text-xs text-muted-foreground">
{worker.cpus} cpu
{worker.gpus ? ` · ${worker.gpus} gpu` : ""}
{worker.ram_mb ? ` · ${Math.round(worker.ram_mb / 1024)} GB` : ""}{" "}
· {worker.in_flight}/{worker.max_parallel} in flight
</span>
</span>
<span className="text-xs text-muted-foreground">
seen {ago(worker.last_seen ?? 0)}
</span>
</div>
))}
</div>
)
}
function Flavors() {
const { user } = useAuth()
const { data } = useQuery({
queryKey: flavorsKey,
queryFn: () => FlavorsService.readFlavors(),
})
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [editing, setEditing] = useState<typeof emptyFlavor | null>(null)
const [isNew, setIsNew] = useState(false)
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const refresh = () => queryClient.invalidateQueries({ queryKey: flavorsKey })
const save = useMutation({
mutationFn: (flavor: typeof emptyFlavor) =>
isNew
? FlavorsService.createFlavor({ requestBody: flavor })
: FlavorsService.updateFlavor({
name: flavor.name,
requestBody: {
cpus: flavor.cpus,
gpus: flavor.gpus,
ram: flavor.ram,
description: flavor.description,
},
}),
onSuccess: (_result, flavor) => {
showSuccessToast(`Saved '${flavor.name}'`)
setEditing(null)
},
onError: handleError.bind(showErrorToast),
onSettled: refresh,
})
const remove = useMutation({
mutationFn: (name: string) => FlavorsService.deleteFlavor({ name }),
onSuccess: (_result, name) => {
showSuccessToast(`Deleted '${name}'`)
setPendingDelete(null)
},
onError: (error: ApiError) => {
handleError.call(showErrorToast, error)
setPendingDelete(null)
},
onSettled: refresh,
})
const flavors = data?.data ?? []
const mayEdit = Boolean(user?.is_superuser)
return (
<div className="grid gap-2">
{flavors.map((flavor: FlavorPublic) => (
<div
key={flavor.name}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
>
<span className="grid gap-0.5">
<span className="text-sm">{flavor.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{flavor.cpus} cpu · {Math.round((flavor.ram ?? 0) / 1024)} GB
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
{flavor.description ? `${flavor.description}` : ""}
</span>
</span>
{mayEdit ? (
<span className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${flavor.name}`}
onClick={() => {
setIsNew(false)
setEditing({ ...emptyFlavor, ...flavor })
}}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${flavor.name}`}
className="text-destructive hover:text-destructive"
onClick={() => setPendingDelete(flavor.name)}
>
<Trash2 />
</Button>
</span>
) : null}
</div>
))}
{mayEdit ? (
<Button
variant="secondary"
className="justify-self-start"
onClick={() => {
setIsNew(true)
setEditing({ ...emptyFlavor })
}}
>
<Plus />
Add a size
</Button>
) : null}
<Dialog
open={editing !== null}
onOpenChange={(open) => !open && setEditing(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isNew ? "New size" : `Edit '${editing?.name}'`}
</DialogTitle>
<DialogDescription>
Every node that names this gets what it says here, from its next
run.
</DialogDescription>
</DialogHeader>
{editing ? (
<div className="grid gap-3">
{isNew ? (
<div className="grid gap-1.5">
<Label htmlFor="flavor-name">Name</Label>
<Input
id="flavor-name"
value={editing.name}
placeholder="gpu-large"
onChange={(event) =>
setEditing({ ...editing, name: event.target.value })
}
/>
</div>
) : null}
<div className="grid grid-cols-3 gap-2">
{(
[
["cpus", "Cores", 1],
["gpus", "GPUs", 0],
["ram", "MB", 1],
] as const
).map(([field, label, floor]) => (
<div key={field} className="grid gap-1.5">
<Label htmlFor={`flavor-${field}`}>{label}</Label>
<Input
id={`flavor-${field}`}
type="number"
min={floor}
value={String(editing[field])}
onChange={(event) =>
setEditing({
...editing,
[field]: Math.max(
floor,
Number(event.target.value) || 0,
),
})
}
/>
</div>
))}
</div>
<div className="grid gap-1.5">
<Label htmlFor="flavor-description">Description</Label>
<Input
id="flavor-description"
value={editing.description}
placeholder="What this size is for"
onChange={(event) =>
setEditing({ ...editing, description: event.target.value })
}
/>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
disabled={!editing?.name.trim() || save.isPending}
onClick={() => editing && save.mutate(editing)}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete '{pendingDelete}'?</DialogTitle>
<DialogDescription>
A node still asking for it keeps this from being deleted, and it
will say which. Nothing is seeded back afterwards.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
variant="destructive"
disabled={remove.isPending}
onClick={() => pendingDelete && remove.mutate(pendingDelete)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function Workers() {
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Workers</h1>
<p className="text-sm text-muted-foreground">
Every machine this engine can run a node on, what is free of each, and
the sizes a node can ask for by name.
</p>
</div>
<section className="grid gap-2">
<h2 className="text-sm text-muted-foreground">Machines</h2>
<Machines />
</section>
<section className="grid gap-2">
<h2 className="text-sm text-muted-foreground">Attached workers</h2>
<Attached />
</section>
<section className="grid gap-2">
<h2 className="text-sm text-muted-foreground">Sizes</h2>
<Flavors />
</section>
</div>
)
}