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,134 +1,120 @@
<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>
<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> </div>
<!-- Not present is NOT zero slots it is "nothing answered".
Saying "stopped" here would be a verdict drawn from an <div v-for="lane in lanesList" :key="lane.name" class="fc-sys__row">
unswept read, and the operator would go looking for a crash <span class="fc-sys__dot" :class="`fc-sys__dot--${laneState(lane)}`" />
that has not happened. -->
<div v-if="!lane.live.present" class="text-caption fc-muted"> <div class="fc-sys__body">
not answering <div class="fc-sys__name">
{{ lane.display_name }}
<!-- Said in the row rather than only in the advisory below.
Someone scanning to work out why a lane is off should not have
to turn it on to find out it was never required. -->
<span class="fc-sys__kind">{{ lane.optional ? 'optional' : 'lane' }}</span>
</div> </div>
<div v-else-if="lane.live.replicas > 1" class="text-caption fc-muted"> <!-- "Not answering" is NOT zero slots — it is "nothing replied".
{{ lane.live.replicas }} replicas Calling it stopped would be a verdict drawn from an unswept
</div> read, and would send the operator looking for a crash that has
<!-- The operator asked for a lane held at full for a long time not happened. -->
to TRIGGER growth. It reports instead: extra slots do not <div class="fc-sys__detail">{{ laneDetail(lane) }}</div>
make a long task finish sooner, so scaling on this would <!-- The operator asked for a lane held at full for a long time to
spend memory to change nothing. Seeing the lane is wedged TRIGGER growth. It reports instead: extra slots do not make a
on one slow job is the useful half, and it cannot mislead long task finish sooner, so scaling on this would spend memory
a lane into growing for the wrong reason. --> to change nothing. Seeing the lane wedged on one slow job is the
<div v-if="laneStuckFor(lane)" class="text-caption text-warning"> useful half, and it cannot mislead a lane into growing for the
wrong reason. -->
<div v-if="laneStuckFor(lane)" class="fc-sys__detail text-warning">
all slots busy for {{ laneStuckFor(lane) }} all slots busy for {{ laneStuckFor(lane) }}
</div> </div>
</td> </div>
<td class="text-caption fc-muted">{{ lane.queues.join(', ') }}</td>
<div class="fc-sys__meta">
<!-- depth + reserved. LLEN alone reads 0 while a worker holds <!-- depth + reserved. LLEN alone reads 0 while a worker holds
prefetched tasks in memory, which is the number that would prefetched tasks in memory, which is the number that would make
make someone think a lane was idle while it was buried. --> someone think a buried lane was idle. -->
<td class="text-right"> <div>{{ lane.pending === null ? '' : lane.pending }} pending</div>
<span v-if="lane.pending === null" class="fc-muted"></span> <div v-if="lane.live.present">
<span v-else>{{ lane.pending }}</span> {{ lane.live.active }} / {{ lane.live.pool ?? '?' }} busy
</td> </div>
<div class="fc-sys__queues">{{ lane.queues.join(', ') }}</div>
</div>
<td class="text-right"> <div class="fc-lanes__controls">
<span v-if="!lane.live.present" class="fc-muted"></span> <div class="fc-lanes__control">
<span v-else>{{ lane.live.active }} / {{ lane.live.pool ?? '?' }}</span> <div class="d-flex align-center">
</td>
<td>
<div class="d-flex align-center" style="gap: 4px;">
<v-btn <v-btn
icon="mdi-minus" size="x-small" variant="text" icon="mdi-minus" size="x-small" variant="text"
:disabled="busy === lane.name || lane.slots <= 0" :disabled="busy === lane.name || lane.slots <= 0"
:aria-label="`Fewer slots for ${lane.display_name}`" :aria-label="`Fewer slots for ${lane.display_name}`"
@click="step(lane, -1)" @click="step(lane, -1)"
/> />
<span class="fc-slots">{{ lane.slots }}</span> <span class="fc-lanes__slots">{{ lane.slots }}</span>
<v-btn <v-btn
icon="mdi-plus" size="x-small" variant="text" icon="mdi-plus" size="x-small" variant="text"
:disabled="busy === lane.name || lane.slots >= lane.slots_cap" :disabled="busy === lane.name || lane.slots >= lane.slots_cap"
:aria-label="`More slots for ${lane.display_name}`" :aria-label="`More slots for ${lane.display_name}`"
@click="step(lane, 1)" @click="step(lane, 1)"
/> />
<!-- The REASON a higher value is unavailable, always on screen. </div>
A greyed control with no explanation reads as a bug, and <!-- The REASON a higher value is unavailable, always on screen. A
the ceiling is the one number here the operator cannot greyed control with no explanation reads as a bug, and the
change so it has to justify itself. --> ceiling is the one number here the operator cannot change — so
<span class="text-caption fc-muted ml-1"> it has to justify itself. -->
cap {{ lane.slots_cap }}<template v-if="lane.slots_cap >= lane.ceiling"> <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)' : '' }} · max {{ lane.ceiling }}{{ lane.memory_bound ? ' (memory)' : '' }}
</template> </template>
</span>
</div> </div>
</td> </div>
<!-- The autoscaler is the one control here that acts without <!-- The autoscaler is the one control here that acts without being
being asked, so it is a separate switch from `On` rather than asked, so it is a separate switch from `On` rather than a mode
a mode folded into it, and it is off until someone opts this folded into it, and it is off until someone opts this particular
particular lane in. lane in.
Turning it ON needs room to grow into; turning it OFF never Turning it ON needs room to grow into; turning it OFF never
does. Disabling the whole switch at cap == slots would strand does. Disabling the whole switch at cap == slots would strand an
an already-autoscaling lane as soon as someone raised its already-autoscaling lane the moment someone raised its floor to
floor to the cap, with the control that would undo it greyed the cap, with the control that would undo it greyed out. -->
out. And the reason is on screen, for the same reason the <div class="fc-lanes__control">
ceiling justifies itself below: a greyed control with no
explanation reads as a bug. -->
<td class="text-right">
<v-switch <v-switch
:model-value="lane.autoscale" :model-value="lane.autoscale"
density="compact" hide-details color="accent" density="compact" hide-details color="accent"
@@ -136,12 +122,12 @@
:aria-label="`Let ${lane.display_name} grow itself`" :aria-label="`Let ${lane.display_name} grow itself`"
@update:model-value="setAutoscale(lane, $event)" @update:model-value="setAutoscale(lane, $event)"
/> />
<div v-if="!canGrow(lane)" class="text-caption fc-muted"> <div class="fc-lanes__label">
raise the cap {{ canGrow(lane) ? 'auto' : 'raise the cap' }}
</div>
</div> </div>
</td>
<td class="text-right"> <div class="fc-lanes__control">
<v-switch <v-switch
:model-value="lane.enabled" :model-value="lane.enabled"
density="compact" hide-details color="accent" density="compact" hide-details color="accent"
@@ -149,20 +135,20 @@
:aria-label="`Enable ${lane.display_name}`" :aria-label="`Enable ${lane.display_name}`"
@update:model-value="toggle(lane, $event)" @update:model-value="toggle(lane, $event)"
/> />
</td> <div class="fc-lanes__label">on</div>
</tr> </div>
</tbody> </div>
</v-table> </div>
</v-card>
<!-- What an optional, off lane will cost BEFORE it is switched on. <!-- What an optional, off lane will cost BEFORE it is switched on. Every
Every number here comes from the lane payload, and `measured` number comes from the lane payload, and `measured` travels with them —
travels with them an estimate is labelled rather than rounded an estimate is labelled rather than rounded into something that reads
into something that reads like a fact. The previous version of this like a fact. An earlier version said "a few GB", which told the
said "a few GB", which told the operator nothing they could plan operator nothing they could plan with and was sourced from nothing. -->
with and was not sourced from anything. -->
<v-alert <v-alert
v-for="lane in offOptionalLanes" :key="`advisory-${lane.name}`" v-for="lane in offOptionalLanes" :key="`advisory-${lane.name}`"
type="info" variant="tonal" density="compact" class="mt-3" type="info" variant="tonal" density="compact" class="mt-4"
> >
<div class="font-weight-medium mb-1"> <div class="font-weight-medium mb-1">
{{ lane.display_name }} is optional and currently off {{ lane.display_name }} is optional and currently off
@@ -170,41 +156,39 @@
<p class="mb-2"> <p class="mb-2">
It computes image embeddings on the CPU — what similarity search, It computes image embeddings on the CPU — what similarity search,
duplicate grouping and tag suggestions are built on. You only need it 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 if you are <em>not</em> running the GPU agent, which does the same work
work faster. faster.
</p> </p>
<p class="mb-1">Turning it on downloads, once:</p> <p class="mb-1">Turning it on downloads, once:</p>
<ul class="mb-2"> <ul class="mb-2">
<li v-for="m in lane.models" :key="m.repo"> <li v-for="m in lane.models" :key="m.repo">
<code>{{ m.repo }}</code> — <code>{{ m.repo }}</code> —
{{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download, {{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download, and
and about {{ gb(m.resident_bytes) }} of RAM for about {{ gb(m.resident_bytes) }} of RAM for
<strong>each</strong> slot while it runs. <strong>each</strong> slot while it runs.
</li> </li>
</ul> </ul>
<p class="mb-0 text-caption"> <p class="mb-0 text-caption">
Each slot loads its own copy, which is why this container caps the Each slot loads its own copy, which is why this container caps the lane
lane at {{ lane.ceiling }}. at {{ lane.ceiling }}.
<template v-if="lane.ceiling === 0"> <template v-if="lane.ceiling === 0">
It has too little memory to run this at all. It has too little memory to run this at all.
</template> </template>
</p> </p>
</v-alert> </v-alert>
</v-card-text> </div>
</v-card>
</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; }