Found while installing two LAN-only Bookstack instances — both in the same
no-domain path as the previous commit:
- apps-services.json advertised every app at http://localhost:<port>. The
CFG_SERVER_IP override it reads is defined in no config file, so the lookup
always fell through to the "localhost" default — a URL that only resolves for
someone browsing on the server itself. Now falls back to $local_ip_v4, the
same host APP_URL is stamped with.
- traefikManaged was inferred from `access == public`, a stated placeholder.
Public only means the port is published on the host; it says nothing about a
router. It reported true for both new instances despite their compose having
traefik.enable:false. Now read from the port's own traefik column, gated on
the app's domain actually being set — resolved per-app here rather than from
$domain_full, which this generator never populates.
- The "New instance" modal led with bare text. It now shows the type's icon in
the same .app-card-icon holder the grid cards use, so it's visually tied to
the app the user clicked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two display bugs the newer-version work made visible.
A fleet row showed a green "✓ Up to date" directly beside a
"1.159.0 available" chip. Both statements are individually true — you
ARE current on the line you track, and changing lines is a deliberate
act — but a row is a glance, not a place to reconcile two chips that
appear to disagree. The green all-clear now gives way to a neutral
"Newer version" whenever a newer release line exists; the chip still
carries the number and the tooltip still explains the move. The per-app
detail deliberately keeps "up to date" and is left alone: there the
badge arrives with a sentence explaining the distinction and an Upgrade
button, which is what makes it readable.
updaterDisplayVersion preferred the OCI version label unconditionally.
That label is inherited from the vendor's base image unless they
overwrite it, so it can describe the OS rather than the app: mongo:8.0
carries org.opencontainers.image.version=24.04, its Ubuntu base, and
Stoat's row read "24.04 → 8.0 · 02a0cc7" — not a version transition at
all. When the tracked tag is versioned we already hold an authoritative
version, so the label now wins only if the two agree on their leading
number. Keeps nextcloud 34 → 34.0.1, rejects mongo 8.0 vs 24.04, and
leaves rolling tags untouched since the label is the whole point there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance's isolation never needed a domain — its own slug, dir, secrets,
IP and randomly-allocated host port already make two copies independent. But
the routing layer assumed one, so a LAN-only box got a broken instance rather
than a port-served one. Four fixes:
- instanceCreate now rewrites the parent-service column of the cloned config's
PORT_ rows to match the service names it stamps into the compose. That value
is stored as network_resources.parent_service and joined against the
compose-derived service names, so an instance left carrying the TYPE's
service name matched nothing: it rendered in the WebUI with no port, no URL
and no login row despite being up and reachable.
- `instance create --local` (plus a LAN-only toggle in the modal) forces every
port to access=private, traefik=false, for a second copy that should stay
off the domain even when one is configured.
- initializeAppVariables forces the traefik column false when no CFG_DOMAIN_n
is set. Previously a traefik=true port with an empty domain stamped
Host(`app.`) — a trailing-dot host matching nothing — and dragged APP_URL to
https://app. with it, breaking every app that builds its links from APP_URL.
host_setup is blanked for the same reason. The published host port is
untouched; access type, not the traefik flag, gates allocation.
- APP_URL's direct host-port branch now prefers a new $local_ip_v4 (the source
IP for the default route) over $public_ip_v4, which is the WAN address from
an external resolver. LibrePortal never forwards ports, so the WAN address
was unreachable for exactly the LAN/VPN clients that branch serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Locking every tab but Tasks is right for install/restart/backup: those jump
to the task log, so Tasks is the one tab you need. A tool run deliberately
stays where it was launched and brings its result back to Tools — greying
Tools out stranded the user on a tab they could no longer return to.
disableTabs() now takes the tab to leave alone, chosen per task type by
keepTabFor(). Same rule on the page-load path, which also stops yanking a
reload mid-tool-run over to the task log.
Running a tool from the Tools tab raised two full-size toasts around a few
seconds of work — "task started!" as the run began and "task completed!" as
it ended — and then opened the result modal that actually carried the answer.
The started one was stale by the time it was read and the finished one said
what the modal was already showing.
Tool tasks now go through LP_BACKGROUND_TASKS with a new `silent` flag (no
started toast, no finish line), and tools-manager raises a compact
"Running <tool>…" spinner toast for the duration instead, dismissed the
moment the result modal or the user list opens.
A failed list_users now falls through to the result modal too — it has no
account list to open, and the completion toast that used to report the
failure is gone.
The app-scoped Tasks tab never sorted. It rendered straight from
tasksManager.tasks and relied on loadTasks() having ordered it, so any path that
appends after the load — a task arriving from the event bus, a retry, a queue
merge — put that task wherever it happened to land rather than at the top. Sort
where the list is rendered instead of trusting it from three callers away.
Honest note on the reported symptom: a list_users task appearing mid-list could
not be reproduced from the stored records — replaying the sort over all 96 task
files puts the newest tool tasks first. What is demonstrably wrong is the
missing sort above, and a second latent fault it would mask: 8 of those 96
records carry a null createdAt (cron-created backups), and `new Date(null)` is
the epoch, so they sort as if from 1970 rather than as unknown.
Adds window.taskSortTime for that: createdAt when it parses, otherwise the
timestamp already embedded in the task id — the WebUI mints
task_<epoch_ms>_<rand> and the backend task_<epoch_s>_<hex>, distinguishable by
digit count. All three sorts now use it, so the global list, the app list and
the loader agree.
The filter bar is client-side over the already-loaded per-app array, so it is
instant and needs no reload: status chips (built from the statuses actually
present, with counts, so a chip can never return zero) plus a search over the
command and the task id — the id being what a deep link and a log URL both
carry, so pasting one finds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.
Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.
Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.
And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both _titleBlock implementations title-cased the slug themselves instead of
calling getAppDisplayName, so the Tools tab read "Run app-specific actions for
Rocketchat" and Services read "the docker compose services that make up
Speedtest".
getAppDisplayName already resolves a slug to the app's declared title through
window.apps. Using it fixes four apps beyond Rocket.Chat:
rocketchat Rocketchat -> Rocket.Chat
speedtest Speedtest -> LibreSpeed
ipinfo Ipinfo -> IPinfo
libreportal_catalog Libreportal Catalog -> LibrePortal Catalog
The slug casing is kept as the fallback for the window.apps-not-loaded-yet case,
which is what the helper does internally anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tools tab has an interactive modal: when a list_users task completes it
parses the task log for EZ_USER lines and renders one row per account with
reset / promote / delete buttons. All four new apps failed its contract in every
respect, so running List Users produced log text and nothing else.
- The marker is EZ_USER, tab-separated as email, username, roles. Matrix and
Stoat emitted LP_USER in a different field order; Mattermost and Rocket.Chat
emitted no marker at all.
- Matrix and Stoat then consumed their own marker lines in the formatting loop
and printed only the pretty version, so nothing reached the log to parse.
- The row buttons look up tools by id: reset_password, set_admin, delete_user.
The deactivate tools were named deactivate_user / disable_user, so no delete
button rendered.
- Prefill only fills a field named email or username. Rocket.Chat's and Stoat's
identifier field was called user, so a row action would have opened with an
empty box.
- '-' placeholders are truthy, so the modal's `email || username` fallback
picked '-' over the real username for accounts without an email (rocket.cat).
The EZ_USER line now carries an empty string; '-' stays in the readable line.
Mattermost's listing is rebuilt on `mmctl --json`, which carries roles and
delete_at. The text listing has neither, and there is no --system-admin filter
on user list, so every account was reported as a plain user. Two parsing notes
that cost time: mmctl prints status lines both before and after the JSON, so it
needs raw_decode rather than json.loads; and --per-page above 200 makes it emit
a warning line ahead of the payload.
The modal's delete button also stops asserting "Delete user" over whatever the
tool actually does — it takes its label and icon from the tool, because most of
these deactivate and Matrix cannot delete at all.
Verified by replaying the modal's own parser over real tool output: 2 rows for
Matrix, 4 for Mattermost, 3 for Rocket.Chat, with admin and deactivated states
resolving correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each action-board row on /apps/overview already carries a navigation
button (Review / View / Open Backups). The whole row now triggers that
same navigation, so the small button is no longer the only hit target.
Rows opt in via a `nav` descriptor, so only plain go-to-that-tab rows
become clickable — rows with no action, and the "Update all" button
inside the updates row, are unaffected (updater actions are matched
first in the delegated handler, so Update all never double-fires).
The row is not given role="button": nesting the real buttons inside a
button role would break them for assistive tech. The inner button stays
the focusable, keyboard-reachable control; the row is a mouse-only
widening, with a hover state that lifts row and button together. A
whole-row click is suppressed when it ends a text selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Login Details card fell back to the literal 'admin' when an app declared no
user or email key, so speedtest — which has a single CFG_SPEEDTEST_PASSWORD_1
and no user concept at all — advertised "User: admin" to anyone reading its
card. There is no such account; the field was fabricated by the fallback.
Show the username row only when a user or email key actually exists, and the
password row only when a password key does. eoCredList already omits any row
whose value is null, so passing undefined for either half renders just the half
that is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every app with a generated admin password displayed "(not generated)" in its
Login Details. The credential matcher anchored on
^CFG_<APP>_(ADMIN_)?(PASSWORD)$, which stopped matching when generated keys were
given slot numbers — CFG_MATRIX_ADMIN_PASSWORD_1 and friends no longer hit the
regex, so the lookup fell through to its placeholder.
All ten apps carrying an admin password were affected: adguard, authelia,
bookstack, gitea, invidious, matrix, nextcloud, owncloud, pihole and stalwart.
Allow an optional _<n> tail. The anchor is kept otherwise, so a database or
upstream credential still cannot be mistaken for a login — CFG_<APP>_DB_PASSWORD_1
does not match, which is the case the anchor was added for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The category pill on the app detail header was rendered inert — no click
handler at all — while the identical pill on the app cards navigated to that
category's filter view. Both now come from one AppsManager.renderCategoryTag(),
so the detail pill behaves like the card pill and the two can't drift again.
The pill's glyph was an <img> of a category SVG, and those SVGs hardcode
#1e90ff. That only ever matched the dark-blue theme; on nebula (the default,
accent #00d4ff) and any other theme the icon read as a dark smudge next to its
own label. It's now painted as a CSS mask filled with currentColor, so it
always matches the pill's text on every theme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Up to date" answers one question — has the tag I track moved? — and an
abandoned project answers it reassuringly forever. The tag stays put, the
digest never changes, and the app reports as current while receiving no
security patches at all. Nothing in the UI could tell a healthy stable
app from a dead one.
An audit of all 34 anchor images found five in exactly that state:
speedtest (4.4y since rebuild), focalboard (2.8y — Mattermost dropped
support in 2023), pihole-unbound (2.3y), trilium (2.2y), unbound (1.8y).
The scan now records image_updated_at per app (one cheap Hub call inside
the existing registry window, cached between windows like everything
else) and emits stale_after_days from CFG_UPDATER_STALE_DAYS (365, 0
disables) so the UI and the config agree on one number.
Surfaced as an "unmaintained?" severity chip on the fleet row and a
dated explanation in the app detail. Phrased as an observation rather
than an accusation — plenty of small tools are simply finished — but it
does spell out the security consequence, because that is the part a user
cannot infer from "up to date".
Deliberately NOT a "needs action" row on the Overview board: it is not
fixable by pressing anything, and a permanently amber board teaches
people to ignore the board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.
The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.
Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.
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>
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>
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>
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>
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 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>
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>
"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>
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>