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 @@ + + + + + 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') + }) +})