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.
458 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|