Files
app/frontend/src/routes/_layout/workers.tsx
T
stroblme baa73bb3a3 no-em-dash pre-commit hook for user-facing strings, existing em-dashes rewritten
frontend/scripts/no-em-dash.ts walks src/ with the TypeScript parser and flags
an em-dash in JSX text, string literals and template text. A bare "—"
placeholder passes, separators go in no-em-dash.allow.json. The script is
identical to index's copy.
2026-10-15 22:03:36 +02:00

458 lines
14 KiB
TypeScript

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 { Badge } from "@/components/ui/badge"
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 { ago } from "@/lib/utils"
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
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>
{worker.behind ? (
<span className="pl-6 text-xs text-muted-foreground">
Refused for an older protocol. Upgrade it with{" "}
<code className="font-mono">pip install -U fluksio-worker</code>
</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="flex items-center gap-3 text-xs text-muted-foreground">
{worker.behind ? (
<Badge
variant="outline"
className="border-transparent bg-destructive/15 text-destructive"
>
Needs upgrade
</Badge>
) : null}
<span className="font-mono">
{worker.version ? `v${worker.version} · ` : ""}protocol{" "}
{worker.protocol}
</span>
<span>
{worker.behind ? "refused" : "seen"} {ago(worker.last_seen)}
</span>
</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>
)
}