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
+320
View File
@@ -722,6 +722,154 @@ export const EventRowSchema = {
title: 'EventRow'
} as const;
export const FlavorCreateSchema = {
properties: {
cpus: {
type: 'integer',
minimum: 1,
title: 'Cpus',
default: 1
},
gpus: {
type: 'integer',
minimum: 0,
title: 'Gpus',
default: 0
},
ram: {
type: 'integer',
minimum: 1,
title: 'Ram',
default: 2048
},
description: {
type: 'string',
maxLength: 255,
title: 'Description',
default: ''
},
name: {
type: 'string',
maxLength: 64,
title: 'Name'
}
},
type: 'object',
required: ['name'],
title: 'FlavorCreate'
} as const;
export const FlavorPublicSchema = {
properties: {
cpus: {
type: 'integer',
minimum: 1,
title: 'Cpus',
default: 1
},
gpus: {
type: 'integer',
minimum: 0,
title: 'Gpus',
default: 0
},
ram: {
type: 'integer',
minimum: 1,
title: 'Ram',
default: 2048
},
description: {
type: 'string',
maxLength: 255,
title: 'Description',
default: ''
},
name: {
type: 'string',
title: 'Name'
}
},
type: 'object',
required: ['name'],
title: 'FlavorPublic'
} as const;
export const FlavorUpdateSchema = {
properties: {
cpus: {
anyOf: [
{
type: 'integer',
minimum: 1
},
{
type: 'null'
}
],
title: 'Cpus'
},
gpus: {
anyOf: [
{
type: 'integer',
minimum: 0
},
{
type: 'null'
}
],
title: 'Gpus'
},
ram: {
anyOf: [
{
type: 'integer',
minimum: 1
},
{
type: 'null'
}
],
title: 'Ram'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
title: 'FlavorUpdate',
description: 'Everything but the name: renaming would orphan the nodes that ask.'
} as const;
export const FlavorsPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/FlavorPublic'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'FlavorsPublic'
} as const;
export const FlowDef_InputSchema = {
properties: {
name: {
@@ -2364,6 +2512,22 @@ export const RemoteUserBodySchema = {
title: 'RemoteUserBody'
} as const;
export const ResourceLevelSchema = {
properties: {
total: {
type: 'integer',
title: 'Total'
},
free: {
type: 'integer',
title: 'Free'
}
},
type: 'object',
required: ['total', 'free'],
title: 'ResourceLevel'
} as const;
export const ResourcesSchema = {
properties: {
cpus: {
@@ -2380,6 +2544,44 @@ export const ResourcesSchema = {
description: 'Whole devices held for the whole execution, named to the node through CUDA_VISIBLE_DEVICES. Nothing else is given them while it runs, which is what keeps two preallocating processes apart.',
default: 0
},
ram: {
anyOf: [
{
type: 'integer',
minimum: 1
},
{
type: 'null'
}
],
title: 'Ram',
description: "Megabytes held for the whole execution; accepts '512M' or '2G'. Counted against machines that said how much they have, and ignored by those that did not — which is a machine with nothing to say about memory, not one with none."
},
flavor: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Flavor',
description: 'A stored size by name, standing in for cpus, gpus and ram. Read again every time the node is built, so editing the flavor edits what the next run gets.'
},
duration_s: {
anyOf: [
{
type: 'integer',
minimum: 1
},
{
type: 'null'
}
],
title: 'Duration S',
description: "How long one execution is expected to take; accepts '30m' or '2h'. A statement about the node for whoever is planning around it, not a limit — the limit is `timeout`."
},
env: {
additionalProperties: {
type: 'string'
@@ -2407,6 +2609,42 @@ arrives. Both are a node saying how much of the machine it takes, which is
what this is.`
} as const;
export const ResourcesSnapshotSchema = {
properties: {
cpus: {
'$ref': '#/components/schemas/ResourceLevel'
},
gpus: {
'$ref': '#/components/schemas/ResourceLevel'
},
waiting: {
items: {
'$ref': '#/components/schemas/WaitingNode'
},
type: 'array',
title: 'Waiting'
},
targets: {
items: {
'$ref': '#/components/schemas/TargetResources'
},
type: 'array',
title: 'Targets'
},
provisioners: {
items: {
additionalProperties: true,
type: 'object'
},
type: 'array',
title: 'Provisioners'
}
},
type: 'object',
required: ['cpus', 'gpus'],
title: 'ResourcesSnapshot'
} as const;
export const RuleSchema = {
properties: {
events: {
@@ -2877,6 +3115,47 @@ export const SweepEntrySchema = {
title: 'SweepEntry'
} as const;
export const TargetResourcesSchema = {
properties: {
target: {
type: 'string',
title: 'Target'
},
cpus: {
'$ref': '#/components/schemas/ResourceLevel'
},
gpus: {
'$ref': '#/components/schemas/ResourceLevel'
},
ram_mb: {
anyOf: [
{
'$ref': '#/components/schemas/ResourceLevel'
},
{
type: 'null'
}
]
},
labels: {
items: {
type: 'string'
},
type: 'array',
title: 'Labels'
},
in_flight: {
type: 'integer',
title: 'In Flight',
default: 0
}
},
type: 'object',
required: ['target', 'cpus', 'gpus'],
title: 'TargetResources',
description: 'One machine: this engine, or a worker attached to it.'
} as const;
export const TokenSchema = {
properties: {
access_token: {
@@ -3326,6 +3605,26 @@ export const ValidationResultSchema = {
title: 'ValidationResult'
} as const;
export const WaitingNodeSchema = {
properties: {
node: {
type: 'string',
title: 'Node'
},
reason: {
type: 'string',
title: 'Reason'
},
seconds: {
type: 'number',
title: 'Seconds'
}
},
type: 'object',
required: ['node', 'reason', 'seconds'],
title: 'WaitingNode'
} as const;
export const WidgetDefSchema = {
properties: {
id: {
@@ -3427,6 +3726,27 @@ export const WorkerInfoSchema = {
type: 'string',
title: 'Venv Digest',
default: ''
},
cpus: {
type: 'integer',
title: 'Cpus',
default: 1
},
gpus: {
type: 'integer',
title: 'Gpus',
default: 0
},
ram_mb: {
anyOf: [
{
type: 'integer'
},
{
type: 'null'
}
],
title: 'Ram Mb'
}
},
type: 'object',
File diff suppressed because one or more lines are too long
+101 -1
View File
@@ -224,6 +224,37 @@ export type EventRow = {
actor: string;
};
export type FlavorCreate = {
cpus?: number;
gpus?: number;
ram?: number;
description?: string;
name: string;
};
export type FlavorPublic = {
cpus?: number;
gpus?: number;
ram?: number;
description?: string;
name: string;
};
export type FlavorsPublic = {
data: Array<FlavorPublic>;
count: number;
};
/**
* Everything but the name: renaming would orphan the nodes that ask.
*/
export type FlavorUpdate = {
cpus?: (number | null);
gpus?: (number | null);
ram?: (number | null);
description?: (string | null);
};
/**
* One atomic flow.
*/
@@ -866,6 +897,11 @@ export type RemoteUserBody = {
code: string;
};
export type ResourceLevel = {
total: number;
free: number;
};
/**
* What one execution of a node needs to have to itself.
*
@@ -890,6 +926,18 @@ export type Resources = {
* Whole devices held for the whole execution, named to the node through CUDA_VISIBLE_DEVICES. Nothing else is given them while it runs, which is what keeps two preallocating processes apart.
*/
gpus?: number;
/**
* Megabytes held for the whole execution; accepts '512M' or '2G'. Counted against machines that said how much they have, and ignored by those that did not — which is a machine with nothing to say about memory, not one with none.
*/
ram?: (number | null);
/**
* A stored size by name, standing in for cpus, gpus and ram. Read again every time the node is built, so editing the flavor edits what the next run gets.
*/
flavor?: (string | null);
/**
* How long one execution is expected to take; accepts '30m' or '2h'. A statement about the node for whoever is planning around it, not a limit — the limit is `timeout`.
*/
duration_s?: (number | null);
/**
* Extra environment for the worker this node runs in, applied over what the allocation derives. Where a library's own tuning goes — XLA_FLAGS, XLA_PYTHON_CLIENT_MEM_FRACTION — since those are composed strings the engine must not invent.
*/
@@ -898,6 +946,16 @@ export type Resources = {
};
};
export type ResourcesSnapshot = {
cpus: ResourceLevel;
gpus: ResourceLevel;
waiting?: Array<WaitingNode>;
targets?: Array<TargetResources>;
provisioners?: Array<{
[key: string]: unknown;
}>;
};
/**
* Which events go to which channels.
*/
@@ -1033,6 +1091,18 @@ export type SweepEntry = {
idempotency_key?: (string | null);
};
/**
* One machine: this engine, or a worker attached to it.
*/
export type TargetResources = {
target: string;
cpus: ResourceLevel;
gpus: ResourceLevel;
ram_mb?: (ResourceLevel | null);
labels?: Array<(string)>;
in_flight?: number;
};
export type Token = {
access_token: string;
token_type?: string;
@@ -1134,6 +1204,12 @@ export type ValidationResult = {
issues?: Array<ValidationIssue>;
};
export type WaitingNode = {
node: string;
reason: string;
seconds: number;
};
/**
* One tile: what it shows or does, and where it sits.
*
@@ -1184,6 +1260,9 @@ export type WorkerInfo = {
last_seen?: number;
python?: string;
venv_digest?: string;
cpus?: number;
gpus?: number;
ram_mb?: (number | null);
};
export type AlertsReadAlertsConfigResponse = (AlertsConfig);
@@ -1286,6 +1365,27 @@ export type DashboardsRenameDashboardData = {
export type DashboardsRenameDashboardResponse = (DashboardDef_Output);
export type FlavorsReadFlavorsResponse = (FlavorsPublic);
export type FlavorsCreateFlavorData = {
requestBody: FlavorCreate;
};
export type FlavorsCreateFlavorResponse = (FlavorPublic);
export type FlavorsUpdateFlavorData = {
name: string;
requestBody: FlavorUpdate;
};
export type FlavorsUpdateFlavorResponse = (FlavorPublic);
export type FlavorsDeleteFlavorData = {
name: string;
};
export type FlavorsDeleteFlavorResponse = (Message);
export type FlowsReadFlowsResponse = (FlowsPublic);
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
@@ -1760,7 +1860,7 @@ export type UtilsHealthResponse = ({
export type WorkersReadWorkersResponse = (Array<WorkerInfo>);
export type WorkersReadResourcesResponse = (unknown);
export type WorkersReadResourcesResponse = (ResourcesSnapshot);
export type WorkersIssueTokenData = {
requestBody: TokenRequest;