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>
|
||||
|
||||
<!-- 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
|
||||
majority of processing and downloads feed the library — Activity is
|
||||
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 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
|
||||
|
||||
@@ -65,6 +65,17 @@
|
||||
then neither, and an indicator that reddened on every update would stop
|
||||
being read.
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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() })
|
||||
</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>
|
||||
<!-- Milestone 422 step 4. The dial for how much work each lane does, beside
|
||||
the "Queues + workers" pane that shows the backlog it is reacting to —
|
||||
you see the queue growing and adjust the lane without leaving the pane.
|
||||
<!-- Milestone 422 step 4, restyled and moved to the System tab on
|
||||
2026-09-23 (operator: "move it to system tab and improve the view to be
|
||||
more inline with other UI elements").
|
||||
|
||||
Deliberately NOT in the System tab. That one (milestone 365) answers "is
|
||||
everything running", and every control on it would be about a thing that
|
||||
is broken. This is about a thing that is working and should work harder. -->
|
||||
<v-card class="mb-4">
|
||||
<CardHeading icon="mdi-tune-variant" title="Worker lanes">
|
||||
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. -->
|
||||
<div class="fc-lanes">
|
||||
<div class="d-flex align-center mb-1 mt-8">
|
||||
<span class="fc-section-h">Worker lanes</span>
|
||||
<v-spacer />
|
||||
<span class="text-caption fc-muted">
|
||||
updated {{ formatRelative(store.lanes?.fetched_at) }}
|
||||
<span class="fc-sys__checked">
|
||||
{{ store.lanes ? `updated ${formatRelative(store.lanes.fetched_at)}` : 'reading…' }}
|
||||
</span>
|
||||
</CardHeading>
|
||||
</div>
|
||||
|
||||
<v-card-text>
|
||||
<p class="fc-section__hint mb-3">
|
||||
Slots are how many tasks a lane runs at once. Changes apply to the
|
||||
running worker immediately and survive a restart.
|
||||
</p>
|
||||
<p class="fc-sys__lede text-body-2 mb-5">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="notice"
|
||||
:type="notice.type" variant="tonal" density="compact"
|
||||
class="mb-3" closable
|
||||
@click:close="notice = null"
|
||||
>
|
||||
{{ notice.text }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="notice"
|
||||
:type="notice.type" variant="tonal" density="compact"
|
||||
class="mb-4" closable
|
||||
@click:close="notice = null"
|
||||
>
|
||||
{{ notice.text }}
|
||||
</v-alert>
|
||||
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<v-card variant="flat" class="fc-sys__card">
|
||||
<div v-if="!lanesList.length" class="pa-6 text-center fc-sys__muted">
|
||||
Still reading the lanes — this fills in on the first check.
|
||||
</div>
|
||||
|
||||
<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
|
||||
prefetched tasks in memory, which is the number that would
|
||||
make someone think a lane was idle while it was buried. -->
|
||||
<td class="text-right">
|
||||
<span v-if="lane.pending === null" class="fc-muted">—</span>
|
||||
<span v-else>{{ lane.pending }}</span>
|
||||
</td>
|
||||
|
||||
<td class="text-right">
|
||||
<span v-if="!lane.live.present" class="fc-muted">—</span>
|
||||
<span v-else>{{ lane.live.active }} / {{ lane.live.pool ?? '?' }}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="d-flex align-center" style="gap: 4px;">
|
||||
<v-btn
|
||||
icon="mdi-minus" size="x-small" variant="text"
|
||||
:disabled="busy === lane.name || lane.slots <= 0"
|
||||
:aria-label="`Fewer slots for ${lane.display_name}`"
|
||||
@click="step(lane, -1)"
|
||||
/>
|
||||
<span class="fc-slots">{{ lane.slots }}</span>
|
||||
<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 class="fc-sys__body">
|
||||
<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>
|
||||
<!-- "Not answering" is NOT zero slots — it is "nothing replied".
|
||||
Calling it stopped would be a verdict drawn from an unswept
|
||||
read, and would send the operator looking for a crash that has
|
||||
not happened. -->
|
||||
<div class="fc-sys__detail">{{ laneDetail(lane) }}</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 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="fc-sys__detail text-warning">
|
||||
all slots busy for {{ laneStuckFor(lane) }}
|
||||
</div>
|
||||
</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>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="fc-sys__meta">
|
||||
<!-- depth + reserved. LLEN alone reads 0 while a worker holds
|
||||
prefetched tasks in memory, which is the number that would make
|
||||
someone think a buried lane was idle. -->
|
||||
<div>{{ lane.pending === null ? '—' : lane.pending }} pending</div>
|
||||
<div v-if="lane.live.present">
|
||||
{{ lane.live.active }} / {{ lane.live.pool ?? '?' }} busy
|
||||
</div>
|
||||
<div class="fc-sys__queues">{{ lane.queues.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-lanes__controls">
|
||||
<div class="fc-lanes__control">
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
icon="mdi-minus" size="x-small" variant="text"
|
||||
:disabled="busy === lane.name || lane.slots <= 0"
|
||||
:aria-label="`Fewer slots for ${lane.display_name}`"
|
||||
@click="step(lane, -1)"
|
||||
/>
|
||||
<span class="fc-lanes__slots">{{ lane.slots }}</span>
|
||||
<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)"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
|
||||
const store = useSystemActivityStore()
|
||||
|
||||
// Which lane is mid-write. Per-lane rather than a global flag so adjusting
|
||||
// one lane does not freeze the others.
|
||||
// Which lane is mid-write. Per-lane rather than a global flag so adjusting one
|
||||
// lane does not freeze the others.
|
||||
const busy = ref(null)
|
||||
const notice = ref(null)
|
||||
|
||||
@@ -218,6 +202,51 @@ const offOptionalLanes = computed(() =>
|
||||
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) {
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
@@ -240,7 +269,7 @@ async function apply(lane, fields) {
|
||||
notice.value = {
|
||||
type: 'info',
|
||||
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) {
|
||||
notice.value = {
|
||||
@@ -257,9 +286,9 @@ async function apply(lane, fields) {
|
||||
//
|
||||
// `e.body`, not `e.detail`: ApiError puts the parsed response on `.body`
|
||||
// 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
|
||||
// no reason — which is exactly the greyed-control-with-no-explanation
|
||||
// failure this line exists to prevent.
|
||||
// falls back to that key and shows the operator the word "refused" with no
|
||||
// reason — which is exactly the greyed-control-with-no-explanation failure
|
||||
// this line exists to prevent.
|
||||
notice.value = { type: 'error', text: e.body?.detail || e.message }
|
||||
} finally {
|
||||
busy.value = null
|
||||
@@ -287,9 +316,24 @@ function setAutoscale(lane, value) {
|
||||
</script>
|
||||
|
||||
<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
|
||||
between one and two digits. */
|
||||
.fc-slots {
|
||||
.fc-lanes__slots {
|
||||
display: inline-block;
|
||||
min-width: 1.75em;
|
||||
text-align: center;
|
||||
|
||||
@@ -132,3 +132,50 @@
|
||||
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