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
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:
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user