feat: the System tab is one bounded table, and the dial is the switch (4295)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m10s
CI and images / smoke-web (push) Successful in 52s
CI and images / promote (push) Skipped
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m10s
CI and images / smoke-web (push) Successful in 52s
CI and images / promote (push) Skipped
Operator, 2026-09-23, on the screenshot: *"I feel that we can probably combine the two sections into a single table and to format it in such a way that it appears more bounded and less free-form or open. also there's nothing to describe what 'auto' means or why their needs to be or should be on/off toggles. almost all of it always needs to run there's only one optional piece and it is killed by moving the 'cap' to zero."* Three separate things, all correct. ## The four lanes were listed twice The roster (milestone 365) said "ML tagging is running", and four hundred pixels below it the lanes pane said "ML tagging · 1/1 busy". Two answers to one question from two endpoints, free to disagree on screen. I moved the second pane onto this tab yesterday and did not notice it duplicated the first. Now one row per part, with controls on the rows that have a lane and none on the rows that do not. The join is on the QUEUE SET, because that is what `service_roster` keys a celery part on — as a set, not as a string, so neither side has to agree about order. It lives in `utils/systemParts.js` rather than inline, and has a spec, because its failure is SILENT and is the exact thing it exists to prevent: a lane that stops matching its part does not throw, it grows a second row for the same worker. The duplication, returning through the code that removed it. ## Bounded, not free-form A real table — header, column rules, one bordered card — instead of dotted rows floating on the page background with nothing saying where the list began or what a column meant. ## The dial is the switch There was an `On` switch per lane beside the slots dial. Of four lanes, three must run for the application to work at all, so that switch offered a choice that was never real — and for the one lane that IS optional, "off" and "zero slots" were two ways of saying the same thing that could disagree with each other. So `enabled` is now DERIVED from the number: `set_lane` sets it from `slots > 0` when the caller did not say. It stays on the API and in the model — it is still the mechanism, and a drain-before-restart may still want a lane holding its process with consumers cancelled without destroying the operator's slot count to say so. Two things fell out that a test now pins: - The consumer command is sent on the CHANGE, not on the field being present. Otherwise every slots write re-sends a command that changes nothing — lesson #4183's churn, arriving through the new derivation. - The model fetch fires on the off→on TRANSITION. It used to test `enabled is True`, the field having been sent. The UI no longer sends it, so the download that makes the ML lane usable would simply never have fired and the lane would have come on to consume a queue it had no model for. ## And Auto now says what it is A legend under the table, in the operator's terms: what a slot is, that zero turns a lane off, that three of the four are not optional, what `of N` means, and that Auto lets a lane add slots by itself when its queue is backed up AND every slot is busy — with why it is off by default, since it is the only thing on the page that acts without being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Joining the roster to the worker lanes, for the System tab's one table.
|
||||
//
|
||||
// Extracted from the component rather than left inline because the failure
|
||||
// this can have is SILENT and is exactly the thing the merge exists to fix: if
|
||||
// a lane stops matching its roster part, nothing throws — the table simply
|
||||
// grows a second row for the same worker, one with controls and one without,
|
||||
// which is the duplication the operator asked to be rid of, returned by the
|
||||
// code that removed it.
|
||||
//
|
||||
// Operator, 2026-09-23: "I feel that we can probably combine the two
|
||||
// sections into a single table."
|
||||
|
||||
// A learned roster part and a lane are the same thing seen from two sides, and
|
||||
// the QUEUES are what identify it — `service_roster.refresh_celery_roster`
|
||||
// keys a celery part on exactly `"celery:" + ",".join(sorted(queues))`.
|
||||
//
|
||||
// Matched on the sorted set rather than on that string so the join survives a
|
||||
// change to how the key is spelled, and so neither side has to agree about
|
||||
// ORDER: the lane table lists a lane's queues in the order the role reads them
|
||||
// (`default, import, thumbnail, download`) while the roster sorts them
|
||||
// (`default, download, import, thumbnail`).
|
||||
export function queueKey(queues) {
|
||||
return [...(queues || [])].sort().join(',')
|
||||
}
|
||||
|
||||
// Worst first. A stopped datastore is why someone opened this tab.
|
||||
export const SEVERITY = { down: 3, stale: 2, unknown: 1, ok: 0 }
|
||||
|
||||
export function kindLabel(kind) {
|
||||
if (kind === 'celery') return 'worker lane'
|
||||
if (kind === 'agent') return 'GPU agent'
|
||||
if (kind === 'datastore') return 'datastore'
|
||||
return kind
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per moving part, with a lane attached where there is one.
|
||||
*
|
||||
* @param parts the roster's parts, as /api/system/health returns them
|
||||
* @param lanes the lane rows, as /api/system/workers returns them
|
||||
* @param stuckFor a lane -> "40 minutes" | null reporter (laneStuckFor)
|
||||
*/
|
||||
export function mergeParts(parts, lanes, stuckFor = () => null) {
|
||||
const unmatched = {}
|
||||
for (const lane of lanes || []) unmatched[queueKey(lane.queues)] = lane
|
||||
|
||||
const out = []
|
||||
for (const part of parts || []) {
|
||||
const key = queueKey(part.queues)
|
||||
const lane = part.kind === 'celery' ? unmatched[key] : undefined
|
||||
if (lane) delete unmatched[key]
|
||||
out.push({
|
||||
key: part.key,
|
||||
name: part.name,
|
||||
kindLabel: lane?.optional ? 'optional lane' : kindLabel(part.kind),
|
||||
state: part.state,
|
||||
// A lane dialled to zero is OFF, not broken. Say so, rather than let the
|
||||
// roster's heartbeat sentence report the operator's own choice as a
|
||||
// fault — the roster cannot know the difference, and the lane can.
|
||||
detail: lane && lane.slots === 0 ? 'off — no slots' : part.detail,
|
||||
queues: (part.queues || []).join(', '),
|
||||
lane,
|
||||
stuckFor: lane ? stuckFor(lane) : null,
|
||||
severity: SEVERITY[part.state] ?? SEVERITY.unknown,
|
||||
})
|
||||
}
|
||||
|
||||
// A lane the roster has not learned yet. Parts appear only once they have
|
||||
// checked in, while the lane table is known up front — so without this, the
|
||||
// lane an operator most needs to find (an optional one, never yet started)
|
||||
// would be the only one missing from the table.
|
||||
for (const lane of Object.values(unmatched)) out.push(laneRow(lane, stuckFor))
|
||||
|
||||
// Severity leads; then lanes ahead of everything else, because they are the
|
||||
// rows you can actually do something about; then by name.
|
||||
return out.sort((a, b) =>
|
||||
b.severity - a.severity
|
||||
|| Number(Boolean(b.lane)) - Number(Boolean(a.lane))
|
||||
|| a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function laneRow(lane, stuckFor) {
|
||||
const on = lane.slots > 0
|
||||
let state = 'unknown'
|
||||
if (lane.live?.present) state = on ? (stuckFor(lane) ? 'stale' : 'ok') : 'unknown'
|
||||
else if (on) state = 'down'
|
||||
return {
|
||||
key: `lane:${lane.name}`,
|
||||
name: lane.display_name,
|
||||
kindLabel: lane.optional ? 'optional lane' : 'worker lane',
|
||||
state,
|
||||
detail: lane.live?.present
|
||||
? (on ? 'running' : 'off — no slots')
|
||||
: 'has not checked in yet',
|
||||
queues: (lane.queues || []).join(', '),
|
||||
lane,
|
||||
stuckFor: stuckFor(lane),
|
||||
severity: SEVERITY[state],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user