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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user