Stepping 31 -> 32 -> 33 is arithmetic. Knowing 32 FINISHED before
touching 33 is the whole safety story, and it is invisible from outside
the app: Nextcloud runs its migration on boot and sits in maintenance
mode — or fails halfway — while Docker reports the container perfectly
healthy. Advance a rung there and a migration has been skipped on live
data.
Contract: <app>_upgrade_verify <app> <expected-tag> <deadline> -> 0
Returns 0 ONLY on positive confirmation that the app serves at the
expected version with nothing outstanding. Unhealthy, indeterminate and
timed-out all return non-zero — uncertainty is a failure, not a maybe,
because the alternative gambles with data.
nextcloud `occ status`: installed, NOT in maintenance, no pending DB
upgrade, and the running major matches the tag. Maintenance
mid-migration is expected and simply keeps waiting.
mastodon /health serving, ZERO "down" rows in db:migrate:status, and
the version from /api/v1/instance matching. /health alone is
insufficient — Puma answers before migrations finish.
stalwart /healthz/ready (per its documented probes), required to hold
stable rather than flash once. Weaker by design: the probes
confirm serving but report no version, and the file says so
rather than implying more.
updaterVerifyGeneric (running + healthy + no restart during a settle
window) is the fallback for everything else, and is explicitly NOT
sufficient to justify climbing a rung — the engine will refuse to ladder
an app with no declared verifier.
9 tests drive the dangerous states directly: maintenance mode, pending DB
upgrade, and a wrong major all correctly REFUSE to verify; clean states
pass. Those three negatives are the ones that would have corrupted data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both templates were stale in different ways, and the new tag enumeration
surfaced it: nextcloud sat on 31-fpm-alpine with 34 out, mastodon on
v4.2.0 with v4.6 out.
nextcloud 31-fpm-alpine -> 34-fpm-alpine
mastodon v4.2.0 -> v4.6
The mastodon one was the real problem: v4.2.0 is an EXACT patch pin, so
it never moved at all — no security patches, ever. v4.6 is a moving
minor-line tag (the same shape as stalwart's v0.16), so auto-update now
delivers patches within the line.
Deliberately NOT floated to :latest or :stable. Both projects require
stepped upgrades — Nextcloud in particular refuses to skip a major — so
a tag that crosses majors on its own would break the app on a routine
container recreate. A major-pinned, patch-moving tag is the correct
shape here, not a limitation.
Verified against the registry: all three pinned apps now report nothing
newer, and each tag still moves (v0.16 2026-08-10, 34-fpm-alpine
2026-08-03, v4.6 2026-08-06).
Templates only, so this changes NEW installs. Existing installs keep
their tag and will now be told a newer line exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The digest compare only ever asks about the tag already pinned, so it
answers "has my tag been rebuilt?" and can never answer "does a newer
version exist?". An app on v0.16 reports up to date forever while 0.17
ships. That is the gap between an app that updates and an app that is
current, and it silently affects every pinned app.
Adds tag enumeration for VERSIONED tags only (rolling tags already move
on their own): list the repo's tags, keep those sharing the current tag's
SHAPE, and pick the numerically greatest.
Shape matching is the whole safety story — v0.16 -> v#.# so it can never
"upgrade" you onto v0.16-alpine, 31-fpm-alpine onto 31-apache, or a date
tag onto a semver one. Comparison is component-wise numeric, so 0.10 > 0.9
and 1.0 > 0.99 (a string sort gets both wrong), with 10# forcing base ten
so an upstream "08" cannot be read as octal. 15 unit tests cover it.
Docker Hub only, deliberately: all three pinned apps live there, it needs
no auth, and the generic OCI tags/list wants a per-registry token dance.
Other registries stay quiet rather than guess. Throttled inside the
existing registry window and cached between windows so it cannot flicker.
Surfaced as INFORMATION, never an action: no button applies it, because a
version move can carry a data migration. `update_available` and the "up
to date" badge keep their exact meaning; the new state sits beside them
and points at the Version field.
Against the live registry: stalwart v0.16 is current, nextcloud is on
31-fpm-alpine with 34-fpm-alpine out, mastodon on v4.2.0 with v4.6.5 out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverses the manual default from 4ee2529, which was over-cautious once
the tag pin is taken into account.
Two things were conflated. Auto-update does not reinstall anything: it
snapshots, `compose pull`, `up -d` — the container is recreated from the
new image and the data volume is untouched. And because the image is
pinned to v0.16, the updater compares the digest of THAT tag, so auto
can only ever apply rebuilds of 0.16 (security/bug patches). It cannot
jump to 0.17. That is the safe half of updating, and there is no good
reason to withhold it.
Adds CFG_STALWART_VERSION=v0.16, which drives the image tag through the
existing #LIBREPORTAL|STALWART_VERSION_TAG| sentinel (verified: setting
it to v0.17 rewrites the image line). Moving between releases is now a
config change a user can make from the app's config page — the roadmap's
config-first version identity, used for real.
Net behaviour: patches land unattended inside the update window; a
version jump stays a deliberate decision, which is what pre-1.0 software
with a settling storage schema warrants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every themed dropdown on the app config page was dead: it rendered, but
clicking did nothing. Only that page — every other dropdown in the WebUI
worked.
renderAppDetail captured the config section's own innerHTML right after
displayConfigForm() had rendered it...
const configHTML = document.getElementById('config-section')?.innerHTML;
...67 lines later...
configSection.innerHTML = configHTML;
...and wrote the same string straight back. That is a no-op for the
markup and a catastrophe for behaviour: re-assigning innerHTML re-parses
the subtree, so every listener in it is destroyed.
custom-select.js had already wrapped each <select>, so the captured
string contained the .custom-select wrapper and the custom-select-native
class. The re-inserted copy therefore looked enhanced — which made the
enhancer correctly skip it as already-done — while having no click
handler at all. A dropdown that renders perfectly and does nothing.
The container is never wholesale-rewritten in that function (it updates
header/config/console individually), so the capture-and-restore had no
purpose. Both lines removed, with a comment stating that anything added
there must mutate the section rather than re-assign its innerHTML.
Diagnosed in a real browser rather than by reading: instrumenting
CustomSelect.build() showed the enhancer DID build a widget for the field
while the wrapper in the DOM was not the one it built, and patching the
innerHTML setter named apps-manager.js:785 as the writer. Verified live:
the dropdown opens, and picking an option sets the value (auto) and the
label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both found in a user's console log.
1. ReferenceError: setupMobileMenu is not defined (dashboard.js:98)
core/topbar/js/mobile-menu.js defines that global, and index.html
never loaded it. dashboard.js called it unguarded as the FIRST line
of setupEventListeners, so dashboard init threw every page load and
took loadInstalledApps() with it — and the burger menu was dead on
mobile. system-loader already guarded its own call with a typeof
check, which is why this survived unnoticed.
Loads the script (before dashboard.js) and guards the call, so
optional nav chrome can never take down the page below it again.
2. Endless 404s on /api/tasks/<id> for tasks that no longer exist
queue.json is append-only from the enqueue side and nothing ever
pruned it, so any task file removed afterwards left an id the WebUI
re-fetched forever, one 404 per poll per orphan. Adds
cleanupOrphanQueueEntries to the idle housekeeping pass: entries with
no task file are dropped and logged. Self-heals existing strays.
(Provoked by my own clean-up of two test tasks earlier in this
session, but the gap is real and predates it.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: dropdowns dead on the app config page only, in a private window
(so not cache), with the served frontend confirmed identical to source.
The enhancer ran in a bare forEach with no try/catch anywhere, and
build() inserts its wrapper via `select.parentNode.insertBefore(...)`.
A detached select makes that a null deref, and one throw abandoned the
rest of the pass — every select AFTER it silently stayed native. The
MutationObserver callback had the same exposure for the remaining
mutation records in a batch.
The app config page is the one that can produce a detached select: it
builds its category panels in an async loop, so the observer can see a
node a later render already replaced. That matches "only app config".
Worse than losing the theme: if the throw landed after build() added
.custom-select-native, the select was left opacity:0 / pointer-events:
none behind a button with no listeners — a dropdown that looks right and
does nothing.
Now: detached/unconnected selects are skipped (they get enhanced when
their subtree is attached and the observer fires again), every
enhancement is individually guarded, a failed one is rolled back so it
can never be left invisible, and failures console.warn with the field
name instead of vanishing.
Verified with jsdom against the real file: detached nodes skipped without
throwing, a failure mid-pass leaves later selects working, and the
survivors open and set their value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One container providing SMTP/IMAP/POP3/JMAP plus CalDAV/CardDAV, an admin
UI and spam filtering — chosen over mailcow (owns its own installer, which
is what killed the earlier attempt now sitting in scripts/unused/) and
over Mailu (~7 containers) because a single image with a single data dir
is the only shape that fits the existing conventions cleanly: one anchor
service the updater can version, one path the backup engine can snapshot.
Mail-specific departures from the usual app template, each deliberate:
* Ports are FIXED, not random. Other mail servers connect to :25 by
number and clients expect 465/587/993 — a randomised external port
would silently make the server unreachable. Only the admin UI takes a
random port, since that one really is just a browser behind Traefik.
143/995/4190/443 ship disabled; the port processor comments them out.
* UPDATE_TYPE=manual and the image pinned to v0.16, not :latest.
Stalwart is pre-1.0 and has said the storage schema is still being
finalised, so an unattended minor bump could carry a data migration on
the message store. This is the one app where the auto default is wrong.
* BACKUP_STRATEGY=stop-snapshot-start. The message store is written
continuously; a live copy can land mid-transaction. Seconds of queued
delivery (senders retry) buys a consistent snapshot.
* The install hook checks outbound port 25 and reverse DNS, then prints
the MX/SPF/DMARC records with real values. A mail server whose
container started is not a working mail server, and every remaining
requirement lives at the registrar or the VPS provider.
Admin credentials are seeded via STALWART_RECOVERY_ADMIN from the app
config rather than left to Stalwart's first-run random password, which
would otherwise exist only in the container log.
Icon is a drawn placeholder, not the upstream trademark.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getFieldMappings/getConfigCategories fetched host-GENERATED files with
default caching, so a browser that had the page open before a release
kept rendering the previous release's config UI — a newly shipped field
(UPDATE_TYPE) simply never appeared, with nothing on screen to hint the
page was stale. Only a hard refresh fixed it.
Adds {cache:'no-store'} to both, plus the one configs.json read in this
file that was missing it while two others already had it. Cost is a
conditional request per config-page open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes that make the auto-updater a trustworthy background system:
* CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after the
05:00 backup cron; HH:MM-HH:MM wraps midnight, 'always' = any time).
Gates only the enqueue — scans keep running all day, so the Updates
page stays current and pending updates visibly wait for the window.
Malformed values fail closed and are rejected by the WebUI validator.
* "Check now" actually checks: an explicit `updater check` sets
UPDATER_REGISTRY_FORCE=1. The flag existed but nothing ever set it,
so the button silently reused the 6h digest cache and could not find
a build the user knew had shipped. Force also overrides interval 0,
which now means "manual-only" as documented in the roadmap.
* Registry stamp moved from /tmp to <system>/logs: the task processor
runs under PrivateTmp, so daemon and CLI each kept a separate 6h
clock and the daemon's reset on every service restart.
* A failed automatic attempt is no longer invisible: the scan emits
auto_attempted_digest (the one-shot no-retry stamp), and when it
matches the available build the UI stops promising an install that
will never come — per-app detail explains, the fleet row gets an
"auto failed" chip, and the Overview board counts it as needing you.
Also corrects the CFG_TIMEZONE label: it sets the containers' TZ only;
scheduled tasks follow the host clock (timedatectl), and the old
"Timezone for scheduled tasks" wording promised a knob that never
existed. The window + auto_window display state plainly WHEN updates
land, answering "how does the user know when the next update happens".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First real end-to-end auto-update on a live install failed like this:
Automatically updating trivy (a recovery snapshot is taken first)
Snapshotting trivy before update…
Pulling new image(s) for …
Update of failed — rolling back…
Could not roll back automatically
The app name went empty after the snapshot. Cause: bash is dynamically
scoped, so a callee assigning an undeclared variable writes the CALLER's
local of that name — and a `while read app` loop leaves it EMPTY at EOF.
webuiBackupAppStatus's dashboard generator runs at the end of every backup
and did exactly that to updaterApplyApp's `app`.
Nothing was damaged: the pull ran against an empty name, failed before
touching the image, and the rollback was a no-op on a nonexistent app.
Fixed both ends. The generator (and three gluetun loops with the same
latent leak) now declare `local app`. updaterApplyApp/updaterRollbackApp
hold the name in `_upd_app` so they no longer depend on every callee's
hygiene, and updaterApplyAll stops leaking its own loop var.
This is exactly the untested path the roadmap flagged: "apply/revert not
yet exercised end-to-end on a live install with a pending update."
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the decision half of the app updater. Detection (P2) and the
snapshot-first apply/revert (P3) were already real, but nothing ever
pressed the button — every update waited for a click.
CFG_<APP>_UPDATE_TYPE=auto|manual per app, default auto (33 templates)
CFG_UPDATER_AUTO=true|false master switch, default true
updaterAppPolicy resolves the two the way backupResolveStrategy already
resolves backup strategy: the global switch can only make things more
manual. updaterApplyAuto runs at the end of `updater check` and enqueues
the ordinary updater_apply task for each auto app that has an update —
never applies inline, so an automatic update is the same code path, task
log, History entry and Roll back button as a manual one.
Safety: each attempt stamps its target digest under generated/auto/, so a
build that fails is rolled back and then left alone rather than retried on
every scan; in-flight updater tasks are skipped so scans can't stack.
Tracked end to end: updates.json carries each app's resolved update_type,
History entries carry trigger=manual|auto. The WebUI says whether updates
install themselves, chips only the apps that opted out, labels automatic
history, and — since an auto app's pending update needs no decision — keeps
it off the Overview board's "Needs action" view.
Also fixes artifactApplyAuto enqueueing without --detach: it runs inside
the single-threaded task processor's own poll, so following the new task in
the foreground waits for a task that cannot start until it returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board landed on "Everything", so the rows that want a decision were
mixed in with the healthy one-liners on arrival. Default to the "Needs
action" chip instead, falling back to "Everything" when nothing is
pending (that view would otherwise be empty). An explicit chip click
still sticks for the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut the 36-word blurb to 23 without losing either fact that matters: it's
DNS-based, and blocked domains resolve to a local blackhole.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tags and Paths were stacked full-width blocks, so a single tag chip and a
single path each burned a whole row and left the panel mostly empty. Wrap
them in a .bsm-blocks auto-fit grid that seats them side by side and falls
back to stacking under ~460px. The wrapper now owns the divider, so it
renders once for the pair instead of once per block, and is omitted
entirely when neither block has content.
Applied to both renderers of this markup: the global Backups view and the
per-app backup card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The App Center blurb ran three sentences, two of which restated things
the Security view already makes obvious. Keep the what, drop the rest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Security triage implied 'fix available · update the app to clear these'
even when the app was already on the newest image — where updating does
nothing and the patch only lands when the maintainer rebuilds. That read as a
false to-do. Reframe honestly:
- Status line keyed on update_available, not Trivy's fixed_in: up to date =>
'nothing to apply; clears when the maintainer ships a rebuilt image'; update
available => 'updating may pull in patched packages'.
- Groups relabeled to describe reality: 'Patch released upstream' (hint: lands
on rebuild / may be cleared by updating) and 'No patch yet'. Counts go
neutral so a big number doesn't read as either alarm or all-clear.
- Row 'no fix yet' -> 'no patch'; use getAppDisplayName so it's 'Trivy' not
'trivy'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consistency pass across the App Center Overview area: no tab has a top-level
manual "Check" button anymore — scans are automatic.
- Overview tab: remove the header "Check now"; the hero already reports last-scan
time, and the unscanned sub-copy no longer points at a button that's gone.
- Improvements tab: remove the header "Check"; lead its body with the same
auto-check line (right-side Check-now nudge) as the Updates tab.
- Remove the now-unused checkBtn helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A user rightly noted the Security section read as a wall of unrelated
dependency CVEs against an 'Up to date' app — no cue for what, if anything,
to do. Make it answer 'is this my problem, and will updating fix it?':
- Scanner (trivy_scan.sh): stop discarding Trivy's Class/Type/Status at the
jq flatten — bind them onto each vuln so the UI can tell an OS package from
the app's own bundled dependency, and a real fix from a won't-fix.
- Security section (updater-page.js): explain these are vulnerabilities in the
packages bundled in the image (not the app version), tally 'N with a fix ·
M no fix yet', then split the list into a 'Fix available' group (worst-first,
each row tagged OS/dependency and showing installed -> fixed) and a dimmed
'No fix yet' group. No fabricated 'this update fixes N' claim — fixed_in vs
the image tag isn't a reliable join, so we only state fix availability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the per-app Updates treatment to the fleet App Center → Overview →
Updates list, and folds the manual check into the status line so it's the one
canonical (secondary) affordance rather than a top-level tab button:
- updater-page.js: renderAutoCheckLine() now ends with a right-aligned "↻ Check
now" button (data-updater-action="check" — both surfaces already wire it).
- overview-manager.js: drop the "Check"/"Check now" button from the Updates tab
header (keep "Update all", only when updates exist); lead renderUpdates() with
the auto-check line.
- overview.css: .updater-autocheck wraps on narrow widths; .updater-autocheck-btn
sits right (margin-left:auto), smaller.
- app-tabbed-manager.js: friendlier no-data copy ("You're all caught up — no
updates found for this app.").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-app Updates tab pushed a manual "Check" button (header + empty state)
even though scans run automatically on CFG_UPDATER_SCAN_INTERVAL — so an app with
nothing to update read like an empty/actionable page. Replace the manual Check
with a calm status line inside the panel: "Checked automatically · last checked X
· next check ~Y", backup-schedule style. The genuine Apply/Roll back actions stay
(applying is still manual and safe). No auto-apply.
- webui_updater_scan.sh: stamp scan_interval_minutes alongside generated_at in
updates.json so the display needs no separate config fetch (0 = auto off).
- updater-page.js: renderAutoCheckLine() + fmtRelFuture().
- app-tabbed-manager.js: drop the header/empty-state Check buttons; render the
auto-check line; friendlier no-data copy.
- overview.css: style .updater-autocheck (green dot live / muted when off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The expanded-row deep-link pushed /overview/<tab>?app=<app> — wrong prefix
and a ?query the SPA's path-based router drops on a cold load, so the row
never reopened from a shared URL. Switch to /apps/overview/updates/<app>,
matching the Migrate/Backups sub-tab path pattern, and parse the app from
that path segment in _honorAppDeepLink so the row expands on cold load too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Security section in the per-app detail panel dropped the CVE list flush
against the panel edges (no side padding, no visible frame). Wrap it in an
inset dark rounded .updater-cve-box (side padding + border) so it reads as a
contained block matching the app rows, and make the scroll thumb more present
(wider, higher-contrast, padded track) so a long list clearly scrolls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce .ov-row-head vertical padding (12px -> 5px) and shrink the app icon
tile (32px -> 24px) so each Updates row is roughly half as tall.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the Fleet Overview Updates list rows to match the Tasks page:
each app now shows its icon tile, a status pill ('✓ Up to date' /
'↑ Update available' / '• Unscanned'), and a dedicated 'Details' toggle
button (chevron) in the row actions instead of the bare leading chevron.
Resolve the app icon from the slug (/core/icons/apps/<slug>.svg, hidden on
error) and prefer window.getAppDisplayName for a prettier title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The task-log "Loading logs..." state rendered as a bare left-aligned text
line (or a tiny ad-hoc 16px spinner overlay), which read as unfinished next
to the rest of the UI. Swap all three log-loading spots (initial placeholder,
toggle-open fetch, running-task stream placeholder) to the canonical
window.lpLoadingBox('Loading logs…'), and scope .lp-loading inside the log
terminal box to fill it and drop its own card chrome so the spinner sits dead
centre over the terminal surface instead of a box-in-a-box. Widen the stream
overlay-removal selector to also clear .lp-loading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-app Updates tab's recessed panel used padding: 4px 16px, so the first
section (VERSION) sat 4px from the top edge — looking unpadded — while the last
section ended 20px from the bottom. Match the sibling .backup-snapshots-container
idiom (uniform 16px) and zero the first/last sections' outer padding so the
container's inset sets an even top/bottom margin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fresh install ran webuiLibrePortalUpdate twice back-to-back: once in
installLibrePortal step 11, then again in startScan at the end of preinstall.
The 30s time-debounce meant to collapse them is fragile (it never fired on a
recent install — >30s elapsed between the two), and debouncing is the wrong
lever anyway: startScan's pass runs AFTER scanConfigsForRandomPassword
finalises app passwords, so it — not the step-11 pass — is authoritative.
Defer the step-11 generation deterministically during a bootstrap install
(libreportal_bootstrap_install=true), leaving startScan's single pass to do the
work. Standalone reinstalls (no bootstrap flag) still generate in step 11. The
time-debounce stays as a general back-to-back backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The authoritative icon source containers/trivy/trivy.svg held a generic
hand-drawn shield/magnifier placeholder, while the frontend copy had the
official brand mark. The icon sync (webuiSyncAppIcons) treats the source as
canonical, so it was clobbering the official logo with the placeholder on
every full WebUI update/install — leaving the setup wizard's app tile
showing the generic icon. Replace the source with the official logo so it
sticks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trivy had no /core/icons/apps/trivy.svg (the canonical app-icon path used by
task rows, tiles, tools, services, routing), so it fell back to the default.
Add the official Trivy mark, white on its #1904DA brand tile so it stays legible
on the dark, near-transparent icon tiles across every theme.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The type glyph (🩺/🔄/✅…) sat bare next to the framed .task-app-icon tile. Give
it the same 32px rounded tile (border + surface-elevated bg) so the two read as
a matched pair; spacing now comes from .task-info's gap like the app icon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Previously retry silently created a new task, left the failed one in place with
no link, and didn't take you to the retry. Now:
- The new task is linked to the failed one via `retryOf`; the server stamps the
original `retriedBy` and KEEPS it (its log is the failure record — never
deleted), so history survives and there's no confusing bare duplicate.
- The failed row shows a muted "↻ Retried" pill, hides its now-stale Retry
button, and offers "View retry" to jump to the new run. The new run shows a
"Retry of:" backlink to the original.
- After retrying, the UI selects the new task and opens its live log so you
follow the retry instead of hunting for it.
retryOf rides through the existing POST /api/tasks (no new endpoint); createTask
gained an optional extra-body arg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add a `#LIBREPORTAL|<APP>_VERSION_TAG|<current-tag>` sentinel to each app's
anchor (`<slug>-service`) image line, so a `CFG_<APP>_VERSION` config drives
that tag through the existing tagging system — the config becomes the source,
the compose tag is derived (like ports/IPs/domains already are). This is the
"install/pin a specific version" knob.
- Placeholder is the literal current tag (e.g. `31-fpm-alpine`), NOT a `*_DATA`
token: an unset var leaves the line untouched (inert comment, keeps the real
tag) and never trips the up_app stale-tag scanner.
- Only the anchor line is tagged (sidecars mariadb/redis/nginx stay
tracked-by-digest, not user-version-picked). ollama correctly targets
`ollama/ollama`, not the companion open-webui.
- Untagged anchors normalized to `:latest` (semantic no-op) so they're
templatable too. The manager (libreportal) is skipped — it updates via its
release channel, not a docker tag.
32 apps wired. Verified: CFG_NEXTCLOUD_VERSION=32 → `nextcloud:32` (prefix
preserved, idempotent, sidecars untouched); all compose files still valid YAML.
Inert until a version config is set, so no behaviour change on existing installs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
"Control plane" is jargon. Rename the self-heal task and de-jargon all the
user-facing copy: task titles ("LibrePortal - Fix System Issues" / "System
Health Check"), the action label, the badge/banner/panel text and "Fix now"
button, the status summaries, and the heal task-log messages. Behaviour
unchanged; code comments keep the technical term where accurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The task-list row only paints the LibrePortal app-logo for commands matched by
isLibrePortalSystemTask's whitelist (appless tasks otherwise get just the type
icon, so arbitrary custom commands don't get a spurious logo). `libreportal
system health/network heal|check` weren't in it — only `system reclaim|image` —
so the heal rows showed a bare function icon instead of the logo like "Check for
Updates". Add health|network to the whitelist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Crash-loop detection keyed on `docker ps --filter status=restarting`, but a
backed-off loop sits "exited" between restarts once docker's backoff grows to
tens of seconds — so a slowed loop is missed. Detect via RestartCount CLIMBING
between scans (what a crash loop actually is), unioned with the instantaneous
restarting signal for fast loops. Baseline counts persist in
.health_restart_counts, written only by the throttled check so the heal's
re-scans don't disturb the delta.
Also give the system_health_heal / system_network_heal tasks proper display
(they fell through to the raw command + generic ⚙️): friendly titles in
formatCommandForUser, type icons (🩺 / 🌐) in getTaskTypeIcon, and action
labels in formatActionTitle — so they read as "LibrePortal - Repair Control
Plane" etc. with an icon, like install tasks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
An offline trivy install crash-looped (server FATALs when it can't fetch the
vuln DB), and on rootless docker the restart storm churned the shared network's
port-forwarder until the WebUI's own published host port was torn down — the
WebUI stayed healthy INSIDE its container but was unreachable from the host, with
nothing detecting or healing it.
Three fixes, in the house self-healing style (mirrors the network-drift trio):
1. Control-plane health checker wired into the existing task-processor idle poll
(maybeRegenPoll), no new daemon. dockerHealthScan (read-only) detects daemon
down, a WebUI running-but-host-port-unreachable (the port-forward corruption),
and crash-looping containers. webuiSystemHealthCheck writes
frontend/data/system/health_status.json + self-dispatches a heal — the user
can't click a button on a dead WebUI, so the poll drives the fix. Frontend
health-notifier surfaces a topbar badge + dashboard banner + details panel.
2. Failure cap, enforced centrally by dockerHealthHeal (task-gated): stops
crash-loopers (removing the churn), restarts the WebUI to re-publish a lost
port forward, and — only if that fails — recycles the rootless daemon and
restarts the core container. Caps every app immediately, no template churn.
3. Trivy no longer crash-loops offline: the server runs in a shell retry-loop so
the container stays Up and quietly retries on a backoff instead of exiting
FATAL. Verified: container stays Up across repeated DB-download failures.
Core WebUI compose gains restart: unless-stopped so it self-recovers after a
reboot / daemon recycle instead of staying down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Follow-up to the backup-refresh throttle/dedupe, cutting the cost of the
remote pulls that do still happen.
* restic stats now runs in --mode raw-data (restic_check.sh). The default
restore-size mode walks every snapshot's tree to sum logical file sizes —
the slowest restic op — just to fill a size readout. raw-data reads the
index only and reports the repository's actual deduplicated on-disk size,
which is exactly what the dashboard already labels "deduplicated,
encrypted". raw-data omits total_file_count, so the per-location card now
shows that location's snapshot count (already loaded client-side, and more
useful for a backup repo) instead of a file count.
* engineLocationStats now shares the same per-refresh memoiser as
engineSnapshotsJson (engine_dispatch.sh). Both the locations and dashboard
generators call it per location, so repo stats went from two restic calls
per location per refresh to one. Factored the cache into _engineCachedPull.
* SSH connection reuse for SFTP locations (backup_ssh.sh): ControlMaster=auto
with a self-reaping ControlPersist master, so the several restic
subprocesses a refresh/backup spawns against one location share a single
authenticated connection instead of a fresh handshake each — the dominant
per-call cost on a high-latency link. Toggle via CFG_BACKUP_SSH_MULTIPLEX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The prior .ov-row override tied .updater-row on specificity, and updater.css
loads later, so `display:grid` won and the detail stayed boxed. Use the
two-class selector .updater-row.ov-row to win regardless of load order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fleet Updates expander row inherited .updater-row's 3-column grid, so an
open row's detail body (CVEs, recovery, history) landed in a narrow right-hand
`auto` column with dead space to its left. Make .ov-row a block: the head stacks
on top and the detail body uses the full width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An app with many CVEs (e.g. Trivy's 28) rendered every row full-height, pushing
the page down. Extract one renderCveList() shared by the standalone Security tab
and the per-app expander: sort worst-severity-first, and once past ~6 rows cap
the height (260px) with an internal scroll + themed scrollbar and a bottom fade
hint. Add a count pill to the expander's "Security" heading. Per-app sections
stay stacked and independently collapsible as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client scan resolved images by pulling from Docker Hub, which fails on an
offline/privacy box ("index.docker.io ... network is unreachable") — yet every
installed app's image is already present locally. Point Trivy at the docker
socket that's already bind-mounted into the container (--image-src docker, plus
DOCKER_HOST=unix:///run/user/<uid>/docker.sock for rootless, derived from the
install user — rooted's default path is found automatically). Scans local
images with zero network. Verified: aquasec/trivy:latest -> 28 CVEs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standalone `docker exec trivy-service trivy image ...` opens the vuln-DB cache
the long-lived server already holds, failing with "cache may be in use by
another process: timeout" — so every scan silently returned [] (apps: 0 even on
vulnerable images). Point the exec'd client at the server (--server
http://localhost:4954, the container's fixed --listen port); the server owns the
DB, the client just submits the image. Verified against alpine:3.10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trivy runs as a server whose vulnerability DB downloads on first boot; until it
lands no scan can produce results. Previously the updater generator wrote an
empty-but-valid cves.json the moment the file was missing, so installing Trivy
painted a green "no known vulnerabilities" all-clear that was actually a lie —
the DB hadn't even downloaded, and the Updates/Security view gave no signal.
Add an honest scanner state the WebUI branches on:
- containers/trivy/scripts/trivy_scan.sh — trivyScannerState (absent |
db_updating | ready) via `trivy version -f json`, trivyDbUpdatedAt, and
trivyScanImageCves (per-image scan normalized to {id,severity,package,
installed,fixed_in,url}, deduped). All degrade safely on error.
- webui_updater_scan.sh — stamp cves.json with scanner.state; only run real
per-image scans once the DB is ready. Always rewritten so state tracks live.
- updater-page.js — Security tab shows a loading box while the DB updates, an
install nudge when absent, and the genuine 🎉 only when ready+empty; Overview
CVE card sub + hint reflect the state.
- overview-manager.js — fleet Security row surfaces the "building CVE database"
pending state instead of silently omitting.
- function_manifest.sh — regenerated for the new trivy_scan.sh functions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New containers/trivy app (aquasec/trivy in server mode, private API port,
docker socket + cache volume) in the security,recommended categories, plus
placement in the setup wizard recommended step, the server-side install
tier after crowdsec, and the CLI first-install prompt. The updater's CVE
scan (design doc P4) will gate on this app being installed; §5 of the
design doc updated to record the app-based decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
With no apps tracked the sub line read '0 apps tracked · 0/0 backed up ·
last scan …' — pure noise. Show only the scan time in that case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
.backup-retention-preset-block carried a permanent border-bottom (+ padding),
but the Keep-* fields it separates the dropdown from are hidden for every
preset except Custom — so on Self-hosting/etc. it was an orphaned line with
empty space beneath it. Moved the divider + spacing onto an .is-custom state
that applyVisibility() toggles alongside the fields.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A background task like the updater check surfaces its result in the page it was
launched from (the Updates/Overview tab repaints itself), yet it still fired the
full "View Task" pair — "Check task started!" then "Check task completed!" —
on top of the launcher's own small "Checking apps for updates…" line. Three
notifications for something you have no reason to open the Tasks page for.
Add a small, generic classifier (LP_BACKGROUND_TASKS) keyed by task action/type
(with a command regex that also catches the backend `updater check auto` run).
For a classified task:
- executeTask() skips the standard "task started!" toast and, when the run is
hand-launched, records its id as pending.
- the taskCompleted handler skips the standard "task completed!" toast; if the
id was pending (user launched it) it shows one small plain result line
("Apps checked for updates & vulnerabilities."), otherwise stays silent so
the periodic auto-scan makes no noise at all.
Non-background tasks are unaffected — the standard task-notification style still
applies. Extend byAction to quiet another self-surfacing action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>