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
+8 -1
View File
@@ -60,7 +60,14 @@ const NODE_ICONS = {
// One dot says everything about a node's state. Idle nodes carry no dot at all,
// so the canvas stays quiet until something happens.
// Queued is neutral on purpose: the three status colours mean "running",
// "went well" and "went wrong", and a node waiting for a machine is none of
// those — it is idle with a reason, which the tooltip gives.
const STATUS_STYLES = {
queued: {
dot: "bg-muted-foreground",
label: "Queued — waiting for a machine",
},
running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" },
error: { dot: "bg-destructive", label: "Something went wrong" },
@@ -305,7 +312,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
/>
</TooltipTrigger>
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
{problem || style.label}
{problem || live?.detail || style.label}
</TooltipContent>
</Tooltip>
) : null}
+147
View File
@@ -42,6 +42,7 @@ import { cn } from "@/lib/utils"
import { DtypeValue } from "./FlowBoundary"
import { MessageSparkline } from "./MessageSparkline"
import {
flavorsQueryOptions,
flowKeys,
libraryQueryOptions,
nodeSourceQueryOptions,
@@ -81,6 +82,27 @@ const RESERVED_PARAMS = new Set(["synchronous"])
/** A setting reaches the function by name, so the name has to be one. */
const IDENTIFIER = /^[A-Za-z_]\w*$/
/** Sentinels for the resources dropdown, which has two options that are not sizes. */
const NO_RESOURCES = "__shared__"
const CUSTOM_RESOURCES = "__custom__"
/** `2h` and `30m` and `90` — the grammar the engine parses, written back. */
function parseDuration(text: string): number | null {
const match = /^\s*(\d+)\s*([smhd])?\s*$/i.exec(text)
if (!match) return null
const unit = (match[2] ?? "s").toLowerCase()
const scale = { s: 1, m: 60, h: 3600, d: 86400 }[unit] ?? 1
return Number(match[1]) * scale
}
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return ""
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
/**
* A text field that offers what is already in use elsewhere.
*
@@ -828,6 +850,128 @@ function ParamsForm({
)
}
/**
* How much of a machine this node takes, and how long it is expected to take.
*
* A named size is the usual answer: what "gpu-small" means is a property of the
* machines this installation has, and those change. The numbers are still there
* for a node that genuinely wants its own.
*/
function ResourcesSection({
node,
onChange,
}: {
node: NodeDef_Input
onChange: (node: NodeDef_Input) => void
}) {
const { data: flavors } = useQuery(flavorsQueryOptions())
const resources = node.resources ?? null
const choice = !resources
? NO_RESOURCES
: (resources.flavor ?? CUSTOM_RESOURCES)
const edit = (next: NonNullable<NodeDef_Input["resources"]> | null) =>
onChange({ ...node, resources: next })
const pick = (value: string) => {
if (value === NO_RESOURCES) return edit(null)
// A flavor already says how much, so the numbers go with it — sending both
// is two answers, and the engine refuses it.
const kept = resources?.duration_s
? { duration_s: resources.duration_s }
: {}
if (value === CUSTOM_RESOURCES) return edit({ cpus: 1, gpus: 0, ...kept })
return edit({ flavor: value, ...kept })
}
return (
<div className="grid gap-1.5">
<Label htmlFor="node-resources" className={SECTION}>
Resources
</Label>
<Select value={choice} onValueChange={pick}>
<SelectTrigger id="node-resources" className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_RESOURCES}>Shared pool</SelectItem>
{(flavors?.data ?? []).map((flavor) => (
<SelectItem key={flavor.name} value={flavor.name}>
{flavor.name} {flavor.cpus} cpu ·{" "}
{Math.round((flavor.ram ?? 0) / 1024)} GB
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
</SelectItem>
))}
<SelectItem value={CUSTOM_RESOURCES}>Custom</SelectItem>
</SelectContent>
</Select>
{choice === CUSTOM_RESOURCES ? (
<div className="grid grid-cols-3 gap-1.5">
{(
[
["cpus", "cores", 1],
["gpus", "gpus", 0],
["ram", "MB", 0],
] as const
).map(([field, label, floor]) => (
<div key={field} className="grid gap-1">
<Input
type="number"
min={floor}
className="h-8 text-sm"
aria-label={label}
placeholder={field === "ram" ? "unstated" : String(floor)}
value={
resources?.[field] != null ? String(resources[field]) : ""
}
onChange={(event) => {
const raw = event.target.value
const value =
raw === "" ? null : Math.max(floor, Number(raw) || 0)
edit({
...resources,
cpus: resources?.cpus ?? 1,
// `ram` may be left unstated; the other two always have one.
[field]: field === "ram" ? value : (value ?? floor),
})
}}
/>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
) : null}
{resources ? (
<Input
className="h-8 text-sm"
placeholder="expected duration — 30m, 2h"
defaultValue={formatDuration(resources.duration_s)}
key={`${choice}-${resources.duration_s ?? ""}`}
onBlur={(event) => {
const text = event.target.value.trim()
const seconds = text === "" ? null : parseDuration(text)
if (text !== "" && seconds === null) {
event.target.value = formatDuration(resources.duration_s)
return
}
if (seconds !== resources.duration_s) {
edit({ ...resources, duration_s: seconds })
}
}}
/>
) : null}
<p className="text-xs text-muted-foreground">
{resources
? "Held for the whole execution, and what the node's own libraries are told they may use. The duration is a planning fact, not a limit."
: "Nothing held: the node shares the pool with every other node that says nothing."}
</p>
</div>
)
}
/**
* Sharing a node moves its code to the library, where other flows can point at
* it. Each flow keeps its own ports and settings; only the code is common, so
@@ -1209,6 +1353,9 @@ function PanelBody({
</p>
</div>
) : null}
{hasSource ? (
<ResourcesSection node={node} onChange={editNode} />
) : null}
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}
+3 -1
View File
@@ -24,8 +24,10 @@ export type LiveValue = {
source?: ValueSource
}
export type LiveStatus = {
status: "active" | "error" | "running" | "success"
status: "active" | "error" | "queued" | "running" | "success"
error?: string | null
/** Why it is queued — what it is waiting for, and on which machine. */
detail?: string | null
}
/** How a node's connection is doing, which is not how its last run went. */
export type NodeHealth = {
+7
View File
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
import {
ApiError,
FlavorsService,
type FlowDef_Input,
FlowsService,
SecretsService,
@@ -44,6 +45,12 @@ export const secretsQueryOptions = () => ({
queryFn: () => SecretsService.readSecrets(),
})
/** The named sizes a node can ask for, offered in the node panel. */
export const flavorsQueryOptions = () => ({
queryKey: ["flavors"] as const,
queryFn: () => FlavorsService.readFlavors(),
})
/** Every flow at once, merged on what its nodes talk to. */
export const graphQueryOptions = () => ({
queryKey: flowKeys.graph,
@@ -42,6 +42,14 @@ type FlowEvent =
source?: ValueSource
}
| { type: "node_started"; node: string }
| {
type: "node_queued"
flow?: string
node: string
run?: string
detail?: string
ts?: number
}
| {
type: "node_executed"
flow?: string
@@ -179,6 +187,14 @@ function connect() {
source: message.source,
})
break
case "node_queued":
// Waiting for a machine, which looks exactly like hung from outside.
// `node_started` overwrites this, so nothing has to clear it.
liveStore.setStatus(message.node, {
status: "queued",
detail: message.detail,
})
break
case "node_started":
liveStore.setStatus(message.node, { status: "running" })
break
@@ -7,6 +7,7 @@ import {
LayoutDashboard,
LogOut,
Package,
Server,
Settings,
Users,
Workflow,
@@ -37,6 +38,7 @@ const baseItems: Item[] = [
// sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
{ icon: Package, title: "Modules", path: "/modules" },
{ icon: Server, title: "Workers", path: "/workers" },
{ icon: Bell, title: "Alerts", path: "/alerts" },
]