CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m22s
CI and images / sign-extension (push) Successful in 4s
CI and images / build-web (push) Successful in 2m46s
CI and images / smoke-web (push) Successful in 50s
CI and images / build-agent (push) Successful in 6m35s
CI and images / promote (push) Successful in 2s
torch and onnxruntime both fall back to the CPU without raising, so the agent that ran CPU-bound for weeks after a driver update leased and checked in like a healthy one. - The agent sends its startup accel report on every lease and heartbeat. - The server keeps a bounded copy on the roster row. A running agent with a runtime off the GPU becomes `degraded`, with a sentence naming the runtime and the reason. - The top nav shows it amber. - The agent page carries a banner, and its pill reads "CPU only". Also: the bandwidth field gets the page's − / + stepper, and both number fields drop the browser's spin arrows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
438 lines
19 KiB
Vue
438 lines
19 KiB
Vue
<template>
|
|
<!-- A Settings tab, not a page of its own (operator 2026-09-02): the first
|
|
cut hung this off the health dot alone, which is a target you have to
|
|
already suspect something to look for. Settings is where someone goes to
|
|
ask the instance about itself.
|
|
|
|
ONE table, not two panes (operator 2026-09-23: "I feel that we can
|
|
probably combine the two sections into a single table and to format it
|
|
in such a way that it appears more bounded and less free-form or open").
|
|
|
|
They were the roster (milestone 365, what is running) above the worker
|
|
lanes (milestone 422, how hard) — and the four celery lanes appeared in
|
|
BOTH, once as a status row and again as a control row. The page said
|
|
"ML tagging is running" and then, four hundred pixels later, "ML tagging
|
|
1 / 1 busy". Two answers to one question, from two endpoints, free to
|
|
disagree with each other on screen.
|
|
|
|
So it is one row per part now, and a lane's row simply has controls
|
|
where a datastore's has none. -->
|
|
<div>
|
|
<div class="d-flex align-center mb-1">
|
|
<v-spacer />
|
|
<span class="fc-sys__checked">
|
|
{{ store.checkedAt ? `checked ${formatRelative(store.checkedAt)}` : 'checking…' }}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="fc-sys__lede text-body-2 mb-4">
|
|
Every moving part of FabledCurator: whether it is still checking in, and —
|
|
for the worker lanes — how much work it is allowed to take at once. Parts
|
|
are learned as they appear, so anything that has run at least once stays
|
|
listed. That is what lets a stopped one be noticed rather than simply
|
|
vanishing.
|
|
</p>
|
|
|
|
<v-alert
|
|
v-if="store.lastError" type="error" variant="tonal" density="compact"
|
|
class="mb-4"
|
|
>
|
|
Could not reach FabledCurator: {{ store.lastError }}
|
|
</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-card v-if="!store.lastError" variant="flat" class="fc-parts">
|
|
<v-table density="compact" class="fc-parts__table">
|
|
<thead>
|
|
<tr>
|
|
<th>Part</th>
|
|
<th>Queues</th>
|
|
<th class="text-right">Waiting</th>
|
|
<th class="text-right">Workers</th>
|
|
<th class="text-center" style="width: 160px;">Max workers</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!rows.length">
|
|
<td colspan="6" class="text-center py-6 fc-sys__muted">
|
|
Still gathering — this fills in on the first check.
|
|
</td>
|
|
</tr>
|
|
|
|
<tr v-for="row in rows" :key="row.key">
|
|
<td>
|
|
<div class="fc-parts__name">
|
|
<span class="fc-sys__dot" :class="`fc-sys__dot--${row.state}`" />
|
|
{{ row.name }}
|
|
<span class="fc-sys__kind">{{ row.kindLabel }}</span>
|
|
</div>
|
|
<!-- The sentence, not just a chip. At the moment someone is
|
|
deciding whether to go and open Portainer, "has not checked
|
|
in for 6 min" is the thing that answers them. -->
|
|
<div class="fc-parts__detail">{{ row.detail }}</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 a lane wedged on one slow
|
|
job is the useful half. -->
|
|
<div v-if="row.stuckFor" class="fc-parts__detail text-warning">
|
|
all workers busy for {{ row.stuckFor }}
|
|
</div>
|
|
<!-- The nudge. Operator: "there needs to be something that tells
|
|
the user to bump those numbers to improve processing rate or
|
|
they'd never know the controls exist." It fires only when
|
|
raising the cap would actually help — see `laneAdvice`. -->
|
|
<div v-if="row.advice" class="fc-parts__advice">
|
|
<v-icon size="14" class="mr-1">mdi-arrow-up-bold-outline</v-icon>
|
|
{{ row.advice }}
|
|
</div>
|
|
</td>
|
|
|
|
<td class="fc-parts__queues">{{ row.queues || '—' }}</td>
|
|
|
|
<!-- 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. -->
|
|
<td class="text-right fc-parts__num">
|
|
{{ row.lane ? (row.lane.pending ?? '—') : '—' }}
|
|
</td>
|
|
|
|
<!-- Running, and how many of them are busy. Both are
|
|
MEASUREMENTS — the sizing pass owns this number, nobody sets
|
|
it — which is why neither is a control. -->
|
|
<td class="text-right fc-parts__num">
|
|
<template v-if="row.lane && row.lane.live.present">
|
|
{{ row.lane.live.active }} / {{ row.lane.live.pool ?? '?' }}
|
|
</template>
|
|
<template v-else>—</template>
|
|
</td>
|
|
|
|
<td class="text-center">
|
|
<template v-if="row.lane">
|
|
<div class="d-flex align-center justify-center">
|
|
<v-btn
|
|
icon="mdi-minus" size="x-small" variant="text"
|
|
:disabled="busy === row.lane.name || row.lane.slots_cap <= 0"
|
|
:aria-label="`Allow ${row.name} fewer workers`"
|
|
@click="step(row.lane, -1)"
|
|
/>
|
|
<span class="fc-parts__slots">{{ row.lane.slots_cap }}</span>
|
|
<v-btn
|
|
icon="mdi-plus" size="x-small" variant="text"
|
|
:disabled="busy === row.lane.name
|
|
|| row.lane.slots_cap >= row.lane.ceiling"
|
|
:aria-label="`Allow ${row.name} more workers`"
|
|
@click="step(row.lane, 1)"
|
|
/>
|
|
</div>
|
|
<!-- The REASON a higher number is unavailable, always on
|
|
screen — and, just as importantly, the headroom that says
|
|
raising it is even possible. A greyed control with no
|
|
explanation reads as a bug. -->
|
|
<div class="fc-parts__sub">
|
|
{{ row.lane.ceiling === 0
|
|
? 'no room on this machine'
|
|
: `up to ${row.lane.ceiling}` }}
|
|
</div>
|
|
</template>
|
|
<span v-else class="fc-sys__muted">—</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</v-card>
|
|
|
|
<!-- Where the numbers came from, and when.
|
|
The worker counts are read by a background sweep and stored, not
|
|
fetched when this page loads (operator, 2026-09-23: *"is there a
|
|
reason this info isn't being tracked in the background and stored in
|
|
some way?"*). They are therefore up to one sweep old, and saying so
|
|
is the difference between a lagging number and a wrong one. -->
|
|
<p v-if="!store.lastError" class="fc-parts__measured mt-2 mb-0">
|
|
<template v-if="measured">
|
|
Waiting and worker counts were measured {{ formatRelative(measured) }}<span
|
|
v-if="sweepSeconds"
|
|
>, and are re-read every {{ Math.round(sweepSeconds) }}s</span>.
|
|
</template>
|
|
<template v-else>
|
|
Waiting and worker counts have not been measured yet — the first
|
|
reading lands within a minute of startup.
|
|
</template>
|
|
</p>
|
|
|
|
<!-- What the one control actually means. Operator, 2026-09-23: *"there's
|
|
nothing to describe what 'auto' means or why their needs to be or
|
|
should be on/off toggles."* There were three controls and no sentence
|
|
explaining any of them; there is one now, and it is explained. -->
|
|
<div class="fc-parts__legend mt-4">
|
|
<p class="mb-2">
|
|
<strong>Max workers</strong> is the only thing you set. How many
|
|
workers a lane is actually running at any moment is decided for you —
|
|
it grows to meet a backlog and gives the workers back when the queue
|
|
empties, so an idle instance settles down to one of each without being
|
|
told to. The cap is the ceiling on that, never a target, so raising it
|
|
costs nothing until there is work to spend it on.
|
|
</p>
|
|
<p class="mb-2">
|
|
<strong>A cap of zero turns the lane off.</strong> It keeps one parked
|
|
process and stops taking work, so it stays listed here rather than
|
|
looking like a crash. Three of the four lanes need to be running for
|
|
FabledCurator to work at all; ML tagging is the one that is genuinely
|
|
optional, and it ships at zero because switching it on downloads a
|
|
model. <em>Up to N</em> beneath each dial is what this machine can
|
|
hold — processor cores for every lane, and for ML tagging whichever
|
|
runs out first, its cores or its memory. It is recalculated from the
|
|
container's real limits every time this page loads.
|
|
</p>
|
|
<p class="mb-2">
|
|
The shipped caps are one of each, which is right for a first boot and
|
|
wrong for a busy library. A lane that is running everything its cap
|
|
allows while work piles up <strong>will say so in its row</strong>, and
|
|
raising that cap is the answer when it does.
|
|
</p>
|
|
<p v-if="store.thresholds" class="mb-0">
|
|
A part is called <strong>stale</strong> after
|
|
{{ Math.round(store.thresholds.stale_after_seconds / 60) }} min without a
|
|
check-in and treated as <strong>stopped</strong> after
|
|
{{ Math.round(store.thresholds.down_after_seconds / 60) }} min. The window
|
|
is deliberately wide: a rolling deploy briefly runs two of a service and
|
|
then neither, and an indicator that reddened on every update would stop
|
|
being read.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- What an optional, off lane will cost BEFORE it is given a slot. 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. -->
|
|
<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">Raising its cap above zero 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 worker loads its own copy and asks for several processor cores
|
|
while it runs, which is why this machine allows it at most
|
|
{{ 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, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
import { laneStuckFor, useSystemActivityStore } from '../../stores/systemActivity.js'
|
|
import { useSystemHealthStore } from '../../stores/systemHealth.js'
|
|
import { formatRelative } from '../../utils/date.js'
|
|
import { laneAdvice, measuredAt, mergeParts } from '../../utils/systemParts.js'
|
|
|
|
const store = useSystemHealthStore()
|
|
const lanesStore = useSystemActivityStore()
|
|
|
|
// 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)
|
|
|
|
// The roster half needs no timer: TopNav already polls that same store every
|
|
// 15s for the health dot, on every route this tab is reachable from. The LANES
|
|
// have no such poller, so this tab owns them — 15s and `document.hidden`
|
|
// guarded, because `/api/system/workers` costs a broker round trip and
|
|
// v-window keeps a visited tab MOUNTED, so a naive interval would keep firing
|
|
// behind Maintenance and behind a backgrounded browser tab.
|
|
const LANE_POLL_MS = 15000
|
|
let pollId = null
|
|
|
|
onMounted(() => {
|
|
store.refresh()
|
|
lanesStore.loadLanes()
|
|
pollId = setInterval(() => {
|
|
if (document.hidden) return
|
|
lanesStore.loadLanes()
|
|
}, LANE_POLL_MS)
|
|
})
|
|
onUnmounted(() => { clearInterval(pollId) })
|
|
|
|
// One row per moving part, lanes joined to their roster entry. The join lives
|
|
// in `utils/systemParts.js` because its failure is silent: a lane that stops
|
|
// matching its part does not throw, it grows a SECOND row for the same worker
|
|
// — the duplication this table was built to remove, returning through the code
|
|
// that removed it.
|
|
const rows = computed(() => mergeParts(
|
|
store.parts, lanesStore.lanes?.lanes ?? [], laneStuckFor,
|
|
).map((row) => ({ ...row, advice: laneAdvice(row.lane) })))
|
|
|
|
// When the stored worker numbers were last read, and how often that happens.
|
|
// Both come from the endpoint rather than being restated here — the cadence
|
|
// is `SWEEP_PERIOD_SECONDS`, and a second copy of it in the UI would be free
|
|
// to drift from the schedule it is describing.
|
|
const measured = computed(() => measuredAt(lanesStore.lanes?.lanes ?? []))
|
|
const sweepSeconds = computed(() => lanesStore.lanes?.sweep_period_seconds ?? null)
|
|
|
|
const offOptionalLanes = computed(() =>
|
|
(lanesStore.lanes?.lanes ?? []).filter(
|
|
(l) => l.optional && l.slots_cap === 0 && l.models?.length,
|
|
),
|
|
)
|
|
|
|
function gb(bytes) {
|
|
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
|
}
|
|
|
|
// An unmeasured figure says so. It decides whether someone's server survives,
|
|
// and presenting an estimate as a measurement is the failure this guards.
|
|
function approx(measured) {
|
|
return measured ? '' : 'about '
|
|
}
|
|
|
|
async function apply(lane, fields) {
|
|
busy.value = lane.name
|
|
notice.value = null
|
|
try {
|
|
const reply = await lanesStore.setLane(lane.name, fields)
|
|
if (reply && reply.fetching_models) {
|
|
notice.value = {
|
|
type: 'info',
|
|
text: `${lane.display_name} is on. Downloading its model now — watch `
|
|
+ 'progress under Activity. It only happens once.',
|
|
}
|
|
}
|
|
// There is no "saved but not applied" message any more, because there is
|
|
// nothing to report yet: the endpoint answers once the cap is STORED and
|
|
// tells the running lane afterwards, so the press cannot wait on a broker
|
|
// round trip (operator: "we shouldn't have to wait for the validation
|
|
// live"). Whether the lane has caught up is what its row says, and the
|
|
// row updates on the next poll a few seconds later.
|
|
} catch (e) {
|
|
// The endpoint's `detail` is written to be read by a person ("cap 10000 is
|
|
// above what this container can hold"). Surface it rather than a status
|
|
// code, and never swallow it — a control that silently does nothing is
|
|
// worse than one that refuses out loud.
|
|
//
|
|
// `e.body`, not `e.detail`: ApiError puts the parsed response on `.body`
|
|
// and sets `.message` to the short `error` key.
|
|
notice.value = { type: 'error', text: e.body?.detail || e.message }
|
|
} finally {
|
|
busy.value = null
|
|
}
|
|
}
|
|
|
|
// The cap is the only thing an operator sets, and it doubles as the switch:
|
|
// zero means no consumers. How many workers actually run is the sizing pass's
|
|
// business — always on, reading the live pool every minute — so nothing here
|
|
// sends a worker count.
|
|
function step(lane, delta) {
|
|
return apply(lane, { slots_cap: lane.slots_cap + delta })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* These lived in app.css for one commit, globalised so the roster and the
|
|
worker lanes could share them. Merging the two into one table left a single
|
|
consumer, so they come back here — a global utility with one caller is a
|
|
rule nobody can find from the markup that uses it. */
|
|
.fc-sys__lede, .fc-sys__muted, .fc-sys__checked {
|
|
color: rgb(var(--v-theme-on-surface) / 0.66);
|
|
}
|
|
.fc-sys__checked { font-size: 0.78rem; }
|
|
|
|
.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--degraded { 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__kind {
|
|
font-weight: 400; font-size: 0.72rem; text-transform: uppercase;
|
|
letter-spacing: 0.04em; color: rgb(var(--v-theme-on-surface) / 0.5);
|
|
}
|
|
|
|
/* Bounded rather than free-form (operator 2026-09-23). What was here before was
|
|
dotted rows floating on the page background with no header and no edge, so
|
|
nothing said where the list began or what any column meant. */
|
|
.fc-parts {
|
|
background: rgb(var(--v-theme-on-surface) / 0.04);
|
|
border: 1px solid rgb(var(--v-theme-on-surface) / 0.12);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.fc-parts__table :deep(thead th) {
|
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
color: rgb(var(--v-theme-on-surface) / 0.55);
|
|
border-bottom: 1px solid rgb(var(--v-theme-on-surface) / 0.18) !important;
|
|
white-space: nowrap;
|
|
}
|
|
.fc-parts__table :deep(tbody td) {
|
|
border-bottom: 1px solid rgb(var(--v-theme-on-surface) / 0.08) !important;
|
|
padding-top: 8px; padding-bottom: 8px;
|
|
}
|
|
.fc-parts__table :deep(tbody tr:last-child td) { border-bottom: 0 !important; }
|
|
|
|
.fc-parts__name {
|
|
display: flex; align-items: center; gap: 8px; font-weight: 600;
|
|
}
|
|
/* Clears the dot, so the sentence lines up under the name rather than under
|
|
the indicator. */
|
|
.fc-parts__detail {
|
|
font-size: 0.8rem; color: rgb(var(--v-theme-on-surface) / 0.72);
|
|
padding-left: 17px;
|
|
}
|
|
.fc-parts__advice {
|
|
font-size: 0.8rem; padding-left: 17px; margin-top: 2px;
|
|
color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-parts__queues {
|
|
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface) / 0.6);
|
|
}
|
|
.fc-parts__num { font-variant-numeric: tabular-nums; }
|
|
.fc-parts__measured {
|
|
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface) / 0.55);
|
|
}
|
|
.fc-parts__sub {
|
|
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-parts__slots {
|
|
display: inline-block; min-width: 1.75em; text-align: center;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.fc-parts__legend {
|
|
font-size: 0.82rem; line-height: 1.55;
|
|
color: rgb(var(--v-theme-on-surface) / 0.66);
|
|
}
|
|
</style>
|