From 2f8f0bc9ebe787ee20efd1d1b900d6fa9d3f753a Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 23 Sep 2026 11:01:54 -0400
Subject: [PATCH] feat: worker lanes move to the System tab, in the roster's
own idiom (4295)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
---
.../components/settings/SystemActivityTab.vue | 7 -
.../components/settings/SystemHealthTab.vue | 47 +-
.../components/settings/WorkerLanesCard.vue | 430 ++++++++++--------
frontend/src/styles/app.css | 47 ++
4 files changed, 296 insertions(+), 235 deletions(-)
diff --git a/frontend/src/components/settings/SystemActivityTab.vue b/frontend/src/components/settings/SystemActivityTab.vue
index 7c8d8a0..5e7f091 100644
--- a/frontend/src/components/settings/SystemActivityTab.vue
+++ b/frontend/src/components/settings/SystemActivityTab.vue
@@ -17,11 +17,6 @@
-
-
-
@@ -177,7 +172,6 @@ import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue'
-import WorkerLanesCard from './WorkerLanesCard.vue'
import CardHeading from '../common/CardHeading.vue'
import GpuActivityPanel from './GpuActivityPanel.vue'
import DownloadsActivityPanel from './DownloadsActivityPanel.vue'
@@ -242,7 +236,6 @@ function pollQueues() {
store.loadQueues()
store.loadWorkers()
store.loadRecentRuns()
- store.loadLanes()
}
function pollFailures() {
if (document.hidden) return
diff --git a/frontend/src/components/settings/SystemHealthTab.vue b/frontend/src/components/settings/SystemHealthTab.vue
index c1853aa..753d2d2 100644
--- a/frontend/src/components/settings/SystemHealthTab.vue
+++ b/frontend/src/components/settings/SystemHealthTab.vue
@@ -65,6 +65,17 @@
then neither, and an indicator that reddened on every update would stop
being read.
+
+
+
@@ -73,6 +84,7 @@ import { onMounted } from 'vue'
import { useSystemHealthStore } from '../../stores/systemHealth.js'
import { formatRelative } from '../../utils/date.js'
+import WorkerLanesCard from './WorkerLanesCard.vue'
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.
onMounted(() => { store.refresh() })
-
-
diff --git a/frontend/src/components/settings/WorkerLanesCard.vue b/frontend/src/components/settings/WorkerLanesCard.vue
index dce4fd8..247e9db 100644
--- a/frontend/src/components/settings/WorkerLanesCard.vue
+++ b/frontend/src/components/settings/WorkerLanesCard.vue
@@ -1,210 +1,194 @@
-
-
-
+ It is the roster's second half. The pane above answers "what is
+ running"; this answers "how hard" — so it is built out of the SAME row
+ idiom rather than a bordered card wrapping a v-table, which is what it
+ was and what made it read as bolted on. Dot, name, detail, numbers on
+ the right; the controls take the place the roster gives to latency. -->
+
+
+ Worker lanes
-
- updated {{ formatRelative(store.lanes?.fetched_at) }}
+
+ {{ store.lanes ? `updated ${formatRelative(store.lanes.fetched_at)}` : 'reading…' }}
-
+
-
-
- Slots are how many tasks a lane runs at once. Changes apply to the
- running worker immediately and survive a restart.
-
+
+ Slots are how many tasks a lane runs at once. Changes reach the running
+ worker immediately and survive a restart, so this is the dial to reach
+ for when a queue above is growing faster than it drains.
+
-
- {{ notice.text }}
-
+
+ {{ notice.text }}
+
-
-
-
- | Lane |
- Queues |
- Pending |
- Busy |
- Slots |
- Auto |
- On |
-
-
-
-
- |
-
- {{ lane.display_name }}
-
-
- optional
-
-
-
-
- not answering
-
-
- {{ lane.live.replicas }} replicas
-
-
-
- all slots busy for {{ laneStuckFor(lane) }}
-
- |
+
+
+ Still reading the lanes — this fills in on the first check.
+
- {{ lane.queues.join(', ') }} |
+
+
-
-
- —
- {{ lane.pending }}
- |
-
-
- —
- {{ lane.live.active }} / {{ lane.live.pool ?? '?' }}
- |
-
-
-
-
- {{ lane.slots }}
-
-
-
- cap {{ lane.slots_cap }}
- · max {{ lane.ceiling }}{{ lane.memory_bound ? ' (memory)' : '' }}
-
-
-
- |
-
-
-
-
-
- raise the cap
-
- |
-
-
-
- |
-
-
-
-
-
-
-
- {{ lane.display_name }} is optional and currently off
+
+
+ {{ lane.display_name }}
+
+ {{ lane.optional ? 'optional' : 'lane' }}
+
+
+
{{ laneDetail(lane) }}
+
+
+ all slots busy for {{ laneStuckFor(lane) }}
+
-
- 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 not running the GPU agent, which does the same
- work faster.
-
-
Turning it on downloads, once:
-
- -
-
{{ m.repo }} —
- {{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download,
- and about {{ gb(m.resident_bytes) }} of RAM for
- each slot while it runs.
-
-
-
- Each slot loads its own copy, which is why this container caps the
- lane at {{ lane.ceiling }}.
-
- It has too little memory to run this at all.
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ lane.slots }}
+
+
+
+
+ slots · cap {{ lane.slots_cap }}
+ · max {{ lane.ceiling }}{{ lane.memory_bound ? ' (memory)' : '' }}
+
+
+
+
+
+
+
+
+ {{ canGrow(lane) ? 'auto' : 'raise the cap' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ lane.display_name }} is optional and currently off
+
+
+ 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 not running the GPU agent, which does the same work
+ faster.
+
+ Turning it on downloads, once:
+
+ -
+
{{ m.repo }} —
+ {{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download, and
+ about {{ gb(m.resident_bytes) }} of RAM for
+ each slot while it runs.
+
+
+
+ Each slot loads its own copy, which is why this container caps the lane
+ at {{ lane.ceiling }}.
+
+ It has too little memory to run this at all.
+
+
+
+