|
|
|
@@ -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;
|
|
|
|
|