diff --git a/frontend/src/components/settings/SystemActivityTab.vue b/frontend/src/components/settings/SystemActivityTab.vue
index 8470352..7c8d8a0 100644
--- a/frontend/src/components/settings/SystemActivityTab.vue
+++ b/frontend/src/components/settings/SystemActivityTab.vue
@@ -17,6 +17,11 @@
+
+
+
@@ -172,6 +177,7 @@ 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'
@@ -198,6 +204,16 @@ const filterErrorType = ref(null)
const filterTask = ref(null) // server-side task-name search (All activity)
const failureSearch = ref('') // client-side search over loaded failures
+// This filters HISTORY — task_run.queue — which is why it stays a written-out
+// list rather than being derived from the lanes endpoint like the other queue
+// lists were in milestone 422. Two reasons, and the second is the real one:
+// a derived list is empty whenever that endpoint is down, and more importantly
+// it would HIDE the filter for any queue that has rows but no longer has a
+// lane serving it, which is exactly when someone is looking.
+//
+// It had drifted regardless — `maintenance_long` was missing, so activity on
+// the long-maintenance lane could not be filtered for at all despite four task
+// routes pointing there. Added.
const queueOptions = [
{ title: 'All queues', value: null },
{ title: 'import', value: 'import' },
@@ -206,6 +222,7 @@ const queueOptions = [
{ title: 'download', value: 'download' },
{ title: 'scan', value: 'scan' },
{ title: 'maintenance', value: 'maintenance' },
+ { title: 'maintenance_long', value: 'maintenance_long' },
{ title: 'default', value: 'default' },
]
const statusOptions = [
@@ -225,6 +242,7 @@ function pollQueues() {
store.loadQueues()
store.loadWorkers()
store.loadRecentRuns()
+ store.loadLanes()
}
function pollFailures() {
if (document.hidden) return
diff --git a/frontend/src/components/settings/WorkerLanesCard.vue b/frontend/src/components/settings/WorkerLanesCard.vue
new file mode 100644
index 0000000..ca0d09e
--- /dev/null
+++ b/frontend/src/components/settings/WorkerLanesCard.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+ updated {{ formatRelative(store.lanes?.fetched_at) }}
+
+
+
+
+
+ Slots are how many tasks a lane runs at once. Changes apply to the
+ running worker immediately and survive a restart.
+
+ ML tagging is off. Turning it on downloads the tagging model the first
+ time it runs — a few GB, once.
+
+
+
+
+
+
+
+
diff --git a/frontend/src/stores/systemActivity.js b/frontend/src/stores/systemActivity.js
index 89ebbab..2d72fb6 100644
--- a/frontend/src/stores/systemActivity.js
+++ b/frontend/src/stores/systemActivity.js
@@ -12,13 +12,21 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
const recentRuns = ref([]) // last-60s rows (for Overview summary)
const failures = ref(null) // { recent, count_by_type, since }
+ // Worker lanes (milestone 422): the configured slots joined to the live
+ // pool. Lives here rather than in its own store because it is the same
+ // domain the queues and workers above describe — a second store polling
+ // /api/system/* would be two things to keep in step.
+ const lanes = ref(null) // { lanes: [...], fetched_at }
+
// Paginated runs (Activity tab "All recent activity" pane).
const runs = ref([])
const runsCursor = ref(null)
const runsHasMore = ref(false)
const runsFilter = ref({ queue: null, status: null, task: null, limit: 50 })
- const loading = ref({ queues: false, workers: false, runs: false, failures: false })
+ const loading = ref({
+ queues: false, workers: false, runs: false, failures: false, lanes: false,
+ })
const lastError = ref(null)
async function loadQueues() {
@@ -45,6 +53,32 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
}
}
+ async function loadLanes() {
+ loading.value.lanes = true
+ lastError.value = null
+ try {
+ lanes.value = await api.get('/api/system/workers')
+ } catch (e) {
+ lastError.value = e.message
+ } finally {
+ loading.value.lanes = false
+ }
+ }
+
+ // Change one lane. Returns the endpoint's reply so the caller can tell a
+ // stored-but-not-yet-live change (`applied: false`) from a live one — the
+ // difference between "saved, the lane is restarting" and "that failed",
+ // which the UI must not collapse into one message.
+ //
+ // Deliberately NOT swallowing the error: a refused value (400) carries the
+ // sentence explaining why, and the card shows it. Returning null on failure
+ // would leave the operator with a control that silently did nothing.
+ async function setLane(name, fields) {
+ const reply = await api.post(`/api/system/workers/${name}`, { body: fields })
+ await loadLanes()
+ return reply
+ }
+
async function loadRecentRuns() {
// Used by the Overview summary card: pull last 60s of runs to compute
// per-queue ok/err counts. One call covers all queues; UI groups.
@@ -107,10 +141,10 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
}
return {
- queues, workers, recentRuns, failures, summary,
+ queues, workers, recentRuns, failures, summary, lanes,
runs, runsCursor, runsHasMore, runsFilter,
loading, lastError,
- loadQueues, loadWorkers, loadRecentRuns,
+ loadQueues, loadWorkers, loadRecentRuns, loadLanes, setLane,
loadRuns, loadFailures, loadSummary, setFilter,
}
})
diff --git a/frontend/test/workerLanes.spec.js b/frontend/test/workerLanes.spec.js
new file mode 100644
index 0000000..a0e75ae
--- /dev/null
+++ b/frontend/test/workerLanes.spec.js
@@ -0,0 +1,141 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useSystemActivityStore } from '../src/stores/systemActivity.js'
+
+// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
+// part that decides what the card can tell the operator.
+//
+// The distinction being protected: a change that was STORED but not pushed
+// (`applied: false`, because the lane is restarting) is not a failure, and a
+// REFUSED value (400) is. Collapsing those two into one message is how a
+// control stops being trustworthy — one invites waiting, the other invites
+// changing what you asked for.
+
+function stubFetch(handler) {
+ globalThis.fetch = vi.fn(async (url, init) => {
+ const { status, body } = handler(url, init)
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: String(status),
+ text: async () => (body == null ? '' : JSON.stringify(body)),
+ }
+ })
+}
+
+const LANES_BODY = {
+ lanes: [
+ {
+ name: 'worker', display_name: 'Worker',
+ queues: ['default', 'import', 'thumbnail', 'download'],
+ slots: 1, slots_cap: 4, ceiling: 8, enabled: true, memory_bound: false,
+ live: { present: true, replicas: 1, pool: 1, active: 0, reserved: 3 },
+ queue_depth: 5, pending: 8,
+ },
+ {
+ name: 'ml', display_name: 'ML tagging', queues: ['ml'],
+ slots: 0, slots_cap: 1, ceiling: 2, enabled: false, memory_bound: true,
+ live: { present: false, replicas: 0, pool: null, active: 0, reserved: 0 },
+ queue_depth: null, pending: null,
+ },
+ ],
+ fetched_at: '2026-09-22T12:00:00Z',
+}
+
+describe('worker lanes store', () => {
+ beforeEach(() => setActivePinia(createPinia()))
+ afterEach(() => vi.restoreAllMocks())
+
+ it('loads the lanes', async () => {
+ stubFetch(() => ({ status: 200, body: LANES_BODY }))
+ const s = useSystemActivityStore()
+ await s.loadLanes()
+ expect(s.lanes.lanes.map((l) => l.name)).toEqual(['worker', 'ml'])
+ })
+
+ it('a load failure records the error rather than throwing at the caller', async () => {
+ // The card polls this every 3s. An unhandled rejection per tick would
+ // drown the console and stop the other pollers in the same function.
+ stubFetch(() => ({ status: 500, body: { error: 'boom' } }))
+ const s = useSystemActivityStore()
+ await expect(s.loadLanes()).resolves.toBeUndefined()
+ expect(s.lastError).toBeTruthy()
+ })
+
+ it('setLane posts only the fields it was given', async () => {
+ // Partial update: the stepper sends slots without restating a cap it did
+ // not touch. Sending the whole row back would make two operators editing
+ // different fields clobber each other.
+ const calls = []
+ stubFetch((url, init) => {
+ calls.push({ url, init })
+ if (init?.method === 'POST') {
+ return { status: 200, body: { name: 'worker', slots: 2, applied: true } }
+ }
+ return { status: 200, body: LANES_BODY }
+ })
+ const s = useSystemActivityStore()
+ await s.setLane('worker', { slots: 2 })
+
+ const post = calls.find((c) => c.init?.method === 'POST')
+ expect(post.url).toContain('/api/system/workers/worker')
+ expect(JSON.parse(post.init.body)).toEqual({ slots: 2 })
+ })
+
+ it('setLane refetches so the card shows the server truth, not the guess', async () => {
+ // The reply is one lane; the card renders all of them plus live pool and
+ // pending. Patching the local row from the reply would leave every other
+ // column stale and eventually wrong.
+ let gets = 0
+ stubFetch((url, init) => {
+ if (init?.method === 'POST') return { status: 200, body: { applied: true } }
+ gets += 1
+ return { status: 200, body: LANES_BODY }
+ })
+ const s = useSystemActivityStore()
+ await s.setLane('worker', { slots: 2 })
+ expect(gets).toBe(1)
+ })
+
+ it('a stored-but-unapplied change comes back as applied:false, not an error', async () => {
+ // The lane is restarting. The value IS saved and the reconcile will carry
+ // it — so this must reach the card as information, not as a failure that
+ // invites the operator to set it again.
+ stubFetch((url, init) => {
+ if (init?.method === 'POST') {
+ return {
+ status: 200,
+ body: { applied: false, apply_error: 'lane is not running', slots: 2 },
+ }
+ }
+ return { status: 200, body: LANES_BODY }
+ })
+ const s = useSystemActivityStore()
+ const reply = await s.setLane('worker', { slots: 2 })
+ expect(reply.applied).toBe(false)
+ expect(reply.apply_error).toContain('not running')
+ })
+
+ it('a refused value throws so the card can show the reason', async () => {
+ // Deliberately NOT swallowed. The detail is written to be read by a person
+ // ("above what this container can hold"), and a control that silently does
+ // nothing is worse than one that refuses out loud.
+ stubFetch((url, init) => {
+ if (init?.method === 'POST') {
+ return {
+ status: 400,
+ body: { error: 'refused', detail: 'cap 10000 is above what this container can hold (2 for ML tagging)' },
+ }
+ }
+ return { status: 200, body: LANES_BODY }
+ })
+ const s = useSystemActivityStore()
+ // Assert the REASON is reachable, not merely that it threw. `toThrow()`
+ // alone passes whether the card can read the sentence or not — which is
+ // how the first version of the card shipped reading `e.detail` (always
+ // undefined) and would have shown the operator the bare word "refused".
+ const err = await s.setLane('ml', { slots_cap: 10000 }).catch((e) => e)
+ expect(err.status).toBe(400)
+ expect(err.body.detail).toContain('container can hold')
+ })
+})