feat: worker lanes move to the System tab, in the roster's own idiom (4295)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 23s
Build images / sign-extension (push) Successful in 8s
CI / backend-lint-and-test (push) Failing after 32s
CI / integration (push) Successful in 2m16s
Build images / build-web (push) Successful in 1m55s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped

Operator, 2026-09-23: *"where is the setting surface to set the number of
each kind of worker? we have a system view under settings but no options to
be set here"* — then *"move it to system tab and improve the view to be more
inline with other UI elements."*

They went looking under System and found a read-only roster. The card's own
first cut argued that a control had no place on a tab about things being
broken; that was wrong twice over. Half these lanes are optional and off by
DESIGN, so "not running" is the normal state someone comes here to change —
and a tab that only reports is a tab you leave in order to act somewhere else.

So it is the roster's second half now, and built out of the same parts: the
pane above says what is running, this one says how hard. Dot, name, detail,
numbers on the right; the controls take the place the roster gives to latency.
It was a bordered card wrapping a v-table, which is what made it read as
bolted on.

- The row styles move from SystemHealthTab's scoped block into app.css, so
  the second pane IS the first one's idiom rather than imitating it. A copy
  would have drifted the first time either was touched.
- Lane state uses the roster's four dots. A lane that is off reads grey, never
  red: colouring an intended state as a fault is how an indicator stops being
  read.
- The card owns its own polling. On Activity it rode that tab's 3s queue
  timer; System has none, so moving without this would have shown an empty
  pane to anyone who opened System first. 15s and `document.hidden`-guarded —
  it is a dial, not a live meter, and v-window keeps a hidden tab mounted.
- The table header carried the column names, so each control now labels
  itself: `slots · cap N`, `auto`, `on`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 11:01:54 -04:00
co-authored by Claude Opus 5
parent 86d6509936
commit 2f8f0bc9eb
4 changed files with 296 additions and 235 deletions
@@ -17,11 +17,6 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- The dial for the pane above (milestone 422). Directly beneath it
because the two are read together: you see a queue growing and give
that lane more slots without leaving the pane. -->
<WorkerLanesCard />
<!-- The non-Celery halves of the app (2026-07-02): the GPU agent does the <!-- The non-Celery halves of the app (2026-07-02): the GPU agent does the
majority of processing and downloads feed the library Activity is majority of processing and downloads feed the library Activity is
the whole-app pulse, not just the worker queues. --> the whole-app pulse, not just the worker queues. -->
@@ -177,7 +172,6 @@ import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js' import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue' import QueuesTable from './QueuesTable.vue'
import WorkerLanesCard from './WorkerLanesCard.vue'
import CardHeading from '../common/CardHeading.vue' import CardHeading from '../common/CardHeading.vue'
import GpuActivityPanel from './GpuActivityPanel.vue' import GpuActivityPanel from './GpuActivityPanel.vue'
import DownloadsActivityPanel from './DownloadsActivityPanel.vue' import DownloadsActivityPanel from './DownloadsActivityPanel.vue'
@@ -242,7 +236,6 @@ function pollQueues() {
store.loadQueues() store.loadQueues()
store.loadWorkers() store.loadWorkers()
store.loadRecentRuns() store.loadRecentRuns()
store.loadLanes()
} }
function pollFailures() { function pollFailures() {
if (document.hidden) return if (document.hidden) return
@@ -65,6 +65,17 @@
then neither, and an indicator that reddened on every update would stop then neither, and an indicator that reddened on every update would stop
being read. being read.
</p> </p>
<!-- The dial for the roster above (milestone 422). Moved here from
Activity on 2026-09-23 at the operator's ask: they went looking for
"how many workers of each kind" under System, which is where the
question belongs the roster says what is running, and this says how
hard. Its own first cut argued the opposite, that a control has no
place on a tab about things being broken. That was wrong twice over:
half these lanes are optional and off by DESIGN, so "not running" is
the normal state the operator comes here to change, and a tab that
only reports is a tab you leave to go act somewhere else. -->
<WorkerLanesCard />
</div> </div>
</template> </template>
@@ -73,6 +84,7 @@ import { onMounted } from 'vue'
import { useSystemHealthStore } from '../../stores/systemHealth.js' import { useSystemHealthStore } from '../../stores/systemHealth.js'
import { formatRelative } from '../../utils/date.js' import { formatRelative } from '../../utils/date.js'
import WorkerLanesCard from './WorkerLanesCard.vue'
const store = useSystemHealthStore() const store = useSystemHealthStore()
@@ -91,38 +103,3 @@ function kindLabel(kind) {
// One refresh on open, so arriving at the tab doesn't wait out the nav's tick. // One refresh on open, so arriving at the tab doesn't wait out the nav's tick.
onMounted(() => { store.refresh() }) onMounted(() => { store.refresh() })
</script> </script>
<style scoped>
.fc-sys__lede, .fc-sys__muted, .fc-sys__checked, .fc-sys__foot {
color: rgb(var(--v-theme-on-surface) / 0.66);
}
.fc-sys__checked { font-size: 0.78rem; }
.fc-sys__card { background: rgb(var(--v-theme-on-surface) / 0.04); }
.fc-sys__row {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface) / 0.08);
}
.fc-sys__row:last-child { border-bottom: 0; }
.fc-sys__dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
.fc-sys__dot--ok { background: rgb(var(--v-theme-success)); }
.fc-sys__dot--stale { background: rgb(var(--v-theme-warning)); }
.fc-sys__dot--down { background: rgb(var(--v-theme-error)); }
.fc-sys__dot--unknown { background: rgb(var(--v-theme-on-surface) / 0.35); }
.fc-sys__body { min-width: 0; flex: 1 1 auto; }
.fc-sys__name { font-weight: 600; }
.fc-sys__kind {
margin-left: 8px; font-weight: 400; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: rgb(var(--v-theme-on-surface) / 0.5);
}
.fc-sys__detail { font-size: 0.82rem; color: rgb(var(--v-theme-on-surface) / 0.72); }
.fc-sys__meta {
text-align: right; font-size: 0.75rem; flex: 0 0 auto;
font-variant-numeric: tabular-nums; color: rgb(var(--v-theme-on-surface) / 0.6);
}
.fc-sys__queues { opacity: 0.75; }
</style>
@@ -1,210 +1,194 @@
<template> <template>
<!-- Milestone 422 step 4. The dial for how much work each lane does, beside <!-- Milestone 422 step 4, restyled and moved to the System tab on
the "Queues + workers" pane that shows the backlog it is reacting to 2026-09-23 (operator: "move it to system tab and improve the view to be
you see the queue growing and adjust the lane without leaving the pane. more inline with other UI elements").
Deliberately NOT in the System tab. That one (milestone 365) answers "is It is the roster's second half. The pane above answers "what is
everything running", and every control on it would be about a thing that running"; this answers "how hard" — so it is built out of the SAME row
is broken. This is about a thing that is working and should work harder. --> idiom rather than a bordered card wrapping a v-table, which is what it
<v-card class="mb-4"> was and what made it read as bolted on. Dot, name, detail, numbers on
<CardHeading icon="mdi-tune-variant" title="Worker lanes"> the right; the controls take the place the roster gives to latency. -->
<div class="fc-lanes">
<div class="d-flex align-center mb-1 mt-8">
<span class="fc-section-h">Worker lanes</span>
<v-spacer /> <v-spacer />
<span class="text-caption fc-muted"> <span class="fc-sys__checked">
updated {{ formatRelative(store.lanes?.fetched_at) }} {{ store.lanes ? `updated ${formatRelative(store.lanes.fetched_at)}` : 'reading' }}
</span> </span>
</CardHeading> </div>
<v-card-text> <p class="fc-sys__lede text-body-2 mb-5">
<p class="fc-section__hint mb-3"> Slots are how many tasks a lane runs at once. Changes reach the running
Slots are how many tasks a lane runs at once. Changes apply to the worker immediately and survive a restart, so this is the dial to reach
running worker immediately and survive a restart. for when a queue above is growing faster than it drains.
</p> </p>
<v-alert <v-alert
v-if="notice" v-if="notice"
:type="notice.type" variant="tonal" density="compact" :type="notice.type" variant="tonal" density="compact"
class="mb-3" closable class="mb-4" closable
@click:close="notice = null" @click:close="notice = null"
> >
{{ notice.text }} {{ notice.text }}
</v-alert> </v-alert>
<v-table density="compact"> <v-card variant="flat" class="fc-sys__card">
<thead> <div v-if="!lanesList.length" class="pa-6 text-center fc-sys__muted">
<tr> Still reading the lanes — this fills in on the first check.
<th>Lane</th> </div>
<th>Queues</th>
<th class="text-right">Pending</th>
<th class="text-right">Busy</th>
<th style="width: 200px;">Slots</th>
<th class="text-right">Auto</th>
<th class="text-right">On</th>
</tr>
</thead>
<tbody>
<tr v-for="lane in lanesList" :key="lane.name">
<td>
<div class="d-flex align-center" style="gap: 6px;">
<span>{{ lane.display_name }}</span>
<!-- Said in the row, not only in the warning below. Someone
scanning the table to work out why a lane is off should
not have to enable it to find out it was never required. -->
<v-chip v-if="lane.optional" size="x-small" variant="tonal">
optional
</v-chip>
</div>
<!-- Not present is NOT zero slots it is "nothing answered".
Saying "stopped" here would be a verdict drawn from an
unswept read, and the operator would go looking for a crash
that has not happened. -->
<div v-if="!lane.live.present" class="text-caption fc-muted">
not answering
</div>
<div v-else-if="lane.live.replicas > 1" class="text-caption fc-muted">
{{ lane.live.replicas }} replicas
</div>
<!-- The operator asked for a lane held at full for a long time
to TRIGGER growth. It reports instead: extra slots do not
make a long task finish sooner, so scaling on this would
spend memory to change nothing. Seeing the lane is wedged
on one slow job is the useful half, and it cannot mislead
a lane into growing for the wrong reason. -->
<div v-if="laneStuckFor(lane)" class="text-caption text-warning">
all slots busy for {{ laneStuckFor(lane) }}
</div>
</td>
<td class="text-caption fc-muted">{{ lane.queues.join(', ') }}</td> <div v-for="lane in lanesList" :key="lane.name" class="fc-sys__row">
<span class="fc-sys__dot" :class="`fc-sys__dot--${laneState(lane)}`" />
<!-- depth + reserved. LLEN alone reads 0 while a worker holds <div class="fc-sys__body">
prefetched tasks in memory, which is the number that would <div class="fc-sys__name">
make someone think a lane was idle while it was buried. --> {{ lane.display_name }}
<td class="text-right"> <!-- Said in the row rather than only in the advisory below.
<span v-if="lane.pending === null" class="fc-muted"></span> Someone scanning to work out why a lane is off should not have
<span v-else>{{ lane.pending }}</span> to turn it on to find out it was never required. -->
</td> <span class="fc-sys__kind">{{ lane.optional ? 'optional' : 'lane' }}</span>
</div>
<td class="text-right"> <!-- "Not answering" is NOT zero slots — it is "nothing replied".
<span v-if="!lane.live.present" class="fc-muted"></span> Calling it stopped would be a verdict drawn from an unswept
<span v-else>{{ lane.live.active }} / {{ lane.live.pool ?? '?' }}</span> read, and would send the operator looking for a crash that has
</td> not happened. -->
<div class="fc-sys__detail">{{ laneDetail(lane) }}</div>
<td> <!-- The operator asked for a lane held at full for a long time to
<div class="d-flex align-center" style="gap: 4px;"> TRIGGER growth. It reports instead: extra slots do not make a
<v-btn long task finish sooner, so scaling on this would spend memory
icon="mdi-minus" size="x-small" variant="text" to change nothing. Seeing the lane wedged on one slow job is the
:disabled="busy === lane.name || lane.slots <= 0" useful half, and it cannot mislead a lane into growing for the
:aria-label="`Fewer slots for ${lane.display_name}`" wrong reason. -->
@click="step(lane, -1)" <div v-if="laneStuckFor(lane)" class="fc-sys__detail text-warning">
/> all slots busy for {{ laneStuckFor(lane) }}
<span class="fc-slots">{{ lane.slots }}</span> </div>
<v-btn
icon="mdi-plus" size="x-small" variant="text"
:disabled="busy === lane.name || lane.slots >= lane.slots_cap"
:aria-label="`More slots for ${lane.display_name}`"
@click="step(lane, 1)"
/>
<!-- The REASON a higher value is unavailable, always on screen.
A greyed control with no explanation reads as a bug, and
the ceiling is the one number here the operator cannot
change so it has to justify itself. -->
<span class="text-caption fc-muted ml-1">
cap {{ lane.slots_cap }}<template v-if="lane.slots_cap >= lane.ceiling">
· max {{ lane.ceiling }}{{ lane.memory_bound ? ' (memory)' : '' }}
</template>
</span>
</div>
</td>
<!-- The autoscaler is the one control here that acts without
being asked, so it is a separate switch from `On` rather than
a mode folded into it, and it is off until someone opts this
particular lane in.
Turning it ON needs room to grow into; turning it OFF never
does. Disabling the whole switch at cap == slots would strand
an already-autoscaling lane as soon as someone raised its
floor to the cap, with the control that would undo it greyed
out. And the reason is on screen, for the same reason the
ceiling justifies itself below: a greyed control with no
explanation reads as a bug. -->
<td class="text-right">
<v-switch
:model-value="lane.autoscale"
density="compact" hide-details color="accent"
:disabled="busy === lane.name || (!lane.autoscale && !canGrow(lane))"
:aria-label="`Let ${lane.display_name} grow itself`"
@update:model-value="setAutoscale(lane, $event)"
/>
<div v-if="!canGrow(lane)" class="text-caption fc-muted">
raise the cap
</div>
</td>
<td class="text-right">
<v-switch
:model-value="lane.enabled"
density="compact" hide-details color="accent"
:disabled="busy === lane.name"
:aria-label="`Enable ${lane.display_name}`"
@update:model-value="toggle(lane, $event)"
/>
</td>
</tr>
</tbody>
</v-table>
<!-- What an optional, off lane will cost BEFORE it is switched on.
Every number here comes from the lane payload, and `measured`
travels with them an estimate is labelled rather than rounded
into something that reads like a fact. The previous version of this
said "a few GB", which told the operator nothing they could plan
with and was not sourced from anything. -->
<v-alert
v-for="lane in offOptionalLanes" :key="`advisory-${lane.name}`"
type="info" variant="tonal" density="compact" class="mt-3"
>
<div class="font-weight-medium mb-1">
{{ lane.display_name }} is optional and currently off
</div> </div>
<p class="mb-2">
It computes image embeddings on the CPU what similarity search, <div class="fc-sys__meta">
duplicate grouping and tag suggestions are built on. You only need it <!-- depth + reserved. LLEN alone reads 0 while a worker holds
if you are <em>not</em> running the GPU agent, which does the same prefetched tasks in memory, which is the number that would make
work faster. someone think a buried lane was idle. -->
</p> <div>{{ lane.pending === null ? '' : lane.pending }} pending</div>
<p class="mb-1">Turning it on downloads, once:</p> <div v-if="lane.live.present">
<ul class="mb-2"> {{ lane.live.active }} / {{ lane.live.pool ?? '?' }} busy
<li v-for="m in lane.models" :key="m.repo"> </div>
<code>{{ m.repo }}</code> <div class="fc-sys__queues">{{ lane.queues.join(', ') }}</div>
{{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download, </div>
and about {{ gb(m.resident_bytes) }} of RAM for
<strong>each</strong> slot while it runs. <div class="fc-lanes__controls">
</li> <div class="fc-lanes__control">
</ul> <div class="d-flex align-center">
<p class="mb-0 text-caption"> <v-btn
Each slot loads its own copy, which is why this container caps the icon="mdi-minus" size="x-small" variant="text"
lane at {{ lane.ceiling }}. :disabled="busy === lane.name || lane.slots <= 0"
<template v-if="lane.ceiling === 0"> :aria-label="`Fewer slots for ${lane.display_name}`"
It has too little memory to run this at all. @click="step(lane, -1)"
</template> />
</p> <span class="fc-lanes__slots">{{ lane.slots }}</span>
</v-alert> <v-btn
</v-card-text> icon="mdi-plus" size="x-small" variant="text"
</v-card> :disabled="busy === lane.name || lane.slots >= lane.slots_cap"
:aria-label="`More slots for ${lane.display_name}`"
@click="step(lane, 1)"
/>
</div>
<!-- The REASON a higher value is unavailable, always on screen. A
greyed control with no explanation reads as a bug, and the
ceiling is the one number here the operator cannot change — so
it has to justify itself. -->
<div class="fc-lanes__label">
slots · cap {{ lane.slots_cap }}<template
v-if="lane.slots_cap >= lane.ceiling"
>
· max {{ lane.ceiling }}{{ lane.memory_bound ? ' (memory)' : '' }}
</template>
</div>
</div>
<!-- The autoscaler is the one control here that acts without being
asked, so it is a separate switch from `On` rather than a mode
folded into it, and it is off until someone opts this particular
lane in.
Turning it ON needs room to grow into; turning it OFF never
does. Disabling the whole switch at cap == slots would strand an
already-autoscaling lane the moment someone raised its floor to
the cap, with the control that would undo it greyed out. -->
<div class="fc-lanes__control">
<v-switch
:model-value="lane.autoscale"
density="compact" hide-details color="accent"
:disabled="busy === lane.name || (!lane.autoscale && !canGrow(lane))"
:aria-label="`Let ${lane.display_name} grow itself`"
@update:model-value="setAutoscale(lane, $event)"
/>
<div class="fc-lanes__label">
{{ canGrow(lane) ? 'auto' : 'raise the cap' }}
</div>
</div>
<div class="fc-lanes__control">
<v-switch
:model-value="lane.enabled"
density="compact" hide-details color="accent"
:disabled="busy === lane.name"
:aria-label="`Enable ${lane.display_name}`"
@update:model-value="toggle(lane, $event)"
/>
<div class="fc-lanes__label">on</div>
</div>
</div>
</div>
</v-card>
<!-- What an optional, off lane will cost BEFORE it is switched on. Every
number comes from the lane payload, and `measured` travels with them —
an estimate is labelled rather than rounded into something that reads
like a fact. An earlier version said "a few GB", which told the
operator nothing they could plan with and was sourced from nothing. -->
<v-alert
v-for="lane in offOptionalLanes" :key="`advisory-${lane.name}`"
type="info" variant="tonal" density="compact" class="mt-4"
>
<div class="font-weight-medium mb-1">
{{ lane.display_name }} is optional and currently off
</div>
<p class="mb-2">
It computes image embeddings on the CPU — what similarity search,
duplicate grouping and tag suggestions are built on. You only need it
if you are <em>not</em> running the GPU agent, which does the same work
faster.
</p>
<p class="mb-1">Turning it on downloads, once:</p>
<ul class="mb-2">
<li v-for="m in lane.models" :key="m.repo">
<code>{{ m.repo }}</code> —
{{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download, and
about {{ gb(m.resident_bytes) }} of RAM for
<strong>each</strong> slot while it runs.
</li>
</ul>
<p class="mb-0 text-caption">
Each slot loads its own copy, which is why this container caps the lane
at {{ lane.ceiling }}.
<template v-if="lane.ceiling === 0">
It has too little memory to run this at all.
</template>
</p>
</v-alert>
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js' import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative } from '../../utils/date.js' import { formatRelative } from '../../utils/date.js'
import CardHeading from '../common/CardHeading.vue'
const store = useSystemActivityStore() const store = useSystemActivityStore()
// Which lane is mid-write. Per-lane rather than a global flag so adjusting // Which lane is mid-write. Per-lane rather than a global flag so adjusting one
// one lane does not freeze the others. // lane does not freeze the others.
const busy = ref(null) const busy = ref(null)
const notice = ref(null) const notice = ref(null)
@@ -218,6 +202,51 @@ const offOptionalLanes = computed(() =>
lanesList.value.filter((l) => l.optional && !l.enabled && l.models?.length), lanesList.value.filter((l) => l.optional && !l.enabled && l.models?.length),
) )
// This card owns its own reads now. On Activity it rode that tab's 3s queue
// poll; the System tab has no timer at all — the roster is fed by TopNav's
// store, which knows nothing about lanes — so moving without this would have
// shown an empty pane to anyone who opened System first.
//
// 15s, not 3s: `/api/system/workers` costs a broker round trip, and this pane
// is a dial rather than a live meter. `document.hidden` because v-window keeps
// a visited tab MOUNTED, so the interval keeps firing behind Maintenance and
// behind a backgrounded browser tab.
const POLL_MS = 15000
let pollId = null
function poll() {
if (document.hidden) return
store.loadLanes()
}
onMounted(() => {
store.loadLanes()
pollId = setInterval(poll, POLL_MS)
})
onUnmounted(() => { clearInterval(pollId) })
// Same four states the roster's dots mean, so one legend covers both panes.
// A lane that is off reads `unknown` (grey), never `down` (red): half of these
// are optional and off by design, and colouring the intended state as a fault
// is how an indicator stops being read.
function laneState(lane) {
if (!lane.live.present) return lane.enabled ? 'down' : 'unknown'
if (!lane.enabled) return 'unknown'
if (laneStuckFor(lane)) return 'stale'
return 'ok'
}
function laneDetail(lane) {
if (!lane.live.present) {
return lane.enabled
? 'not answering — it is switched on, so it should be'
: 'not answering, and switched off'
}
if (!lane.enabled) return 'running but not consuming its queues'
if (lane.live.replicas > 1) return `running on ${lane.live.replicas} replicas`
return 'running'
}
function gb(bytes) { function gb(bytes) {
return `${(bytes / 1024 ** 3).toFixed(1)} GB` return `${(bytes / 1024 ** 3).toFixed(1)} GB`
} }
@@ -240,7 +269,7 @@ async function apply(lane, fields) {
notice.value = { notice.value = {
type: 'info', type: 'info',
text: `${lane.display_name} is on. Downloading its model now — ` text: `${lane.display_name} is on. Downloading its model now — `
+ 'watch progress in Queues + workers above. It only happens once.', + 'watch progress under Activity. It only happens once.',
} }
} else if (reply && reply.applied === false) { } else if (reply && reply.applied === false) {
notice.value = { notice.value = {
@@ -257,9 +286,9 @@ async function apply(lane, fields) {
// //
// `e.body`, not `e.detail`: ApiError puts the parsed response on `.body` // `e.body`, not `e.detail`: ApiError puts the parsed response on `.body`
// and sets `.message` to the short `error` key. Reading the wrong one // and sets `.message` to the short `error` key. Reading the wrong one
// falls back to that key and shows the operator the word "refused" with // falls back to that key and shows the operator the word "refused" with no
// no reason — which is exactly the greyed-control-with-no-explanation // reason — which is exactly the greyed-control-with-no-explanation failure
// failure this line exists to prevent. // this line exists to prevent.
notice.value = { type: 'error', text: e.body?.detail || e.message } notice.value = { type: 'error', text: e.body?.detail || e.message }
} finally { } finally {
busy.value = null busy.value = null
@@ -287,9 +316,24 @@ function setAutoscale(lane, value) {
</script> </script>
<style scoped> <style scoped>
/* The controls occupy the slot the roster gives to latency and last-seen, so
they are right-aligned and fixed-width — a row whose switches slide left as
a lane's numbers change is the thing that made the old table feel unrelated
to the pane above it. */
.fc-lanes__controls {
display: flex; align-items: flex-start; gap: 4px; flex: 0 0 auto;
}
.fc-lanes__control { text-align: center; min-width: 56px; }
.fc-lanes__control:first-child { min-width: 132px; }
.fc-lanes__label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface) / 0.5); white-space: nowrap;
}
/* Fixed width so the number does not shift the +/- buttons as it changes /* Fixed width so the number does not shift the +/- buttons as it changes
between one and two digits. */ between one and two digits. */
.fc-slots { .fc-lanes__slots {
display: inline-block; display: inline-block;
min-width: 1.75em; min-width: 1.75em;
text-align: center; text-align: center;
+47
View File
@@ -132,3 +132,50 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
} }
/* The System tab's row idiom (operator 2026-09-23: "improve the view to be
more inline with other UI elements").
The roster (milestone 365) and the worker lanes (milestone 422) sit on the
same tab and answer the two halves of one question — what is running, and
how hard. They were built months apart and looked it: one a flat list of
dotted rows, the other a bordered card wrapping a v-table. Lifting these out
of SystemHealthTab's scoped block is what lets the second pane BE the first
one's idiom rather than imitate it — a copy would drift the first time
either was touched.
`fc-sys__` rather than a new prefix because the roster's markup already uses
these names; renaming would have been churn in the file that is not
changing. */
.fc-sys__lede, .fc-sys__muted, .fc-sys__checked, .fc-sys__foot {
color: rgb(var(--v-theme-on-surface) / 0.66);
}
.fc-sys__checked { font-size: 0.78rem; }
.fc-sys__card { background: rgb(var(--v-theme-on-surface) / 0.04); }
.fc-sys__row {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface) / 0.08);
}
.fc-sys__row:last-child { border-bottom: 0; }
.fc-sys__dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
.fc-sys__dot--ok { background: rgb(var(--v-theme-success)); }
.fc-sys__dot--stale { background: rgb(var(--v-theme-warning)); }
.fc-sys__dot--down { background: rgb(var(--v-theme-error)); }
.fc-sys__dot--unknown { background: rgb(var(--v-theme-on-surface) / 0.35); }
.fc-sys__body { min-width: 0; flex: 1 1 auto; }
.fc-sys__name { font-weight: 600; }
.fc-sys__kind {
margin-left: 8px; font-weight: 400; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: rgb(var(--v-theme-on-surface) / 0.5);
}
.fc-sys__detail { font-size: 0.82rem; color: rgb(var(--v-theme-on-surface) / 0.72); }
.fc-sys__meta {
text-align: right; font-size: 0.75rem; flex: 0 0 auto;
font-variant-numeric: tabular-nums; color: rgb(var(--v-theme-on-surface) / 0.6);
}
.fc-sys__queues { opacity: 0.75; }