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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user