554 Commits

Author SHA1 Message Date
librelad
6d31da917f feat(setup): move the Storage explainer into a "?" tooltip
The step opened with a two-line paragraph explaining what storage
locations are. Every other field in the wizard puts that kind of
background behind a "?" — same markup, same component — so this now does
too, and the step is a title, two one-line drive cards, and the buttons.

The footer line was repeating the first half of that paragraph. It now
carries only what is actionable while choosing: that a greyed drive has
its reason in Details, or that no other drives were found.

Verified the bubble renders with the full text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:25:54 +01:00
librelad
d8c9f2486b feat(setup): show the system disk, and slim the drive cards to one line
Three changes to the Storage step.

The system disk is now a first-class entry — pinned first, ticked, and
locked, since apps fall back to it and it therefore cannot be deselected.
Its Details work like any other drive's, which is the whole point on a
single-disk box: the step now answers "where does my data actually go?"
instead of being skipped and answering nothing. The step is consequently
unconditional; the note changes to explain that no other drives were
found rather than the step vanishing.

The system entry is excluded from the submitted payload — it is already
the primary root, and asking the helper to register it would (correctly)
be refused for nesting.

Cards are one line again. Listing every warning under each drive pushed
them to three lines and made the step tall for no gain: the badge already
carries severity and Details carries the explanation. The note now says
to open Details for the reason rather than claiming it is on the card.

Badge colours were dark-on-light, which against the wizard's mid-blue
glass read as muddy grey — the "needs care" pill in particular. Switched
to light-on-dark, legible without shouting over the drive name.

Verified with lp-shot in both states: system disk alone, and system disk
plus a second drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:19:40 +01:00
librelad
e71fc15f38 fix(setup): use the shared eo-modal for drive details
The details popup was a hand-rolled overlay instead of openEoModal, which
every other modal in the WebUI uses. Two things were wrong with that.

It was appended to the wizard container rather than document.body. The
wizard has a backdrop-filter, which makes it a containing block, so the
modal's position:fixed resolved against the wizard instead of the
viewport — the backdrop covered part of the screen and the panel sat
off-centre.

And once switched to eo-modal it still didn't appear at all: .eo-modal is
z-index 1100 while the wizard overlay is 9999, so it rendered behind the
wizard and the Details button looked inert. Raised only under
body.setup-wizard-open, so no other overlay's stacking is touched — the
wizard is the exceptional full-screen surface, so it is the wizard's
stylesheet that declares modals must sit above it.

The bespoke modal chrome is gone from setup-wizard.css; what remains is
the storage-specific content (spec table, checks list, fstab box) that
renders inside .eo-modal-body. Header, sections, backdrop, Escape,
click-outside and the footer action now all come from the shared helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:57:57 +01:00
librelad
7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00
librelad
f5238f21ca feat(setup): wire storage data into regen, split candidate warnings
webuiGenerateStorageCandidates now runs as part of webuiSystemUpdate, so
frontend/data/storage.json exists without anyone remembering to generate
it — the wizard reads it to decide whether its Storage step appears, and
the Disks view reads the same file, so the two can never disagree.

Warnings arrive from the shell joined with "; ". Rendering that verbatim
produced one run-on paragraph that buried the fstab line the user is
supposed to copy, so the card splits them back onto separate lines.

Verified on the live install with lp-shot: with one filesystem the wizard
shows "Step 1 of 4" and the Storage step is correctly absent; with a
second filesystem attached it becomes "Step 4 of 5" with the drive
carrying a "needs care" badge and both warnings legible. That also
exercises the visible-step mapping in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:14:56 +01:00
librelad
cea653f67b feat(setup): Storage step in the first-run wizard
Phase 3 of docs/roadmap/storage-locations.md.

The step appears only when the candidate scan finds a filesystem
LibrePortal isn't already using, so the single-disk case — which is most
boxes — is completely unchanged. It sits before Recommended because a
location has to exist before an app can be placed on it.

Supporting two conditional steps meant the wizard could no longer treat
'position in the DOM' and 'step index' as the same number: Metrics was
advanced-only and got away with 'length minus one', but a step hidden in
the MIDDLE leaves a gap. Navigation, progress, validation and submit now
all run off _visibleSteps(), and section matching is by data-step rather
than DOM position.

Unusable candidates render greyed WITH the reason rather than being
filtered out — 'why isn't my drive listed?' is a support burden, and
'exFAT can't store file ownership' is actionable. The step is skipped
only when nothing usable was found at all.

What the wizard sends is a request, not an instruction: setupApplyConfig
feeds each path through storageAdd, so the fitness checks and the root
helper's admission rules both re-run regardless of what arrived in the
payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:45:10 +01:00
librelad
26e98698d8 feat(storage): per-app placement, app move, and the READONLY marker
Phase 2 and 4 of docs/roadmap/storage-locations.md. Apps can now be
placed on a location and moved between them.

CFG_<APP>_STORAGE lands in all 37 app templates, holding a location NAME
rather than a path: names survive a migrate to a host with different
disks, paths do not. The 11 infrastructure apps that other apps reach by
literal path (traefik, prometheus, grafana, adguard, gluetun, crowdsec,
headscale, dashy, pihole, unbound, wireguard) are pinned. libreportal
itself never gets the key — it is pinned structurally by webuiDir.

Pinning needed no second config key. "Pinned" is not a fact about a value,
it is a statement about whether the field may be edited, so it goes in the
comment beside **ADVANCED** and **DEV** as **READONLY**, and the field
factory renders those disabled. That marker earns its keep beyond this
feature: derived fields already warned in prose that editing them does
nothing (crowdsec.config:72) next to a perfectly editable input.

storage_app_config.sh keeps the comment honest — it carries the resolved
path for hand-recovery and regenerates the dropdown from the registry, but
only writes when something actually changed, since the app .config is
user-editable and lives in the container-owned tree.

app move stops the app (a live copy of a running Postgres is a corrupt
copy), snapshots it, copies, verifies, and only then removes the source.
The copy runs in libreportal-ownership because it must: app data holds
rootless sub-UID files the manager can neither read nor recreate.
Verified against two real ext4 filesystems that a cross-device move
preserves uid 231141 and the payload, and that the source survives every
refusal path — unregistered destination, the WebUI app, a traversal in the
app name, and an occupied destination.

Task titles registered in both tables, with a specific rule so a move
renders as "Nextcloud - Move to bigdisk" rather than the generic fallback
dropping the destination. lp-task-names could not be run to confirm — it
borrows the WebUI container's node and no containers are running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:37:35 +01:00
librelad
8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00
librelad
2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00
librelad
0e6eb841bd fix(webui): refresh the Instances bar when an instance task completes
The post-task handler only repainted the app detail page when the finished
task's app WAS the app on screen, and only ever re-rendered that same slug.
An instance removal is neither: it is dispatched for the instance, while the
list that changed — the Instances bar — is drawn on the parent and on every
sibling. Removing an instance from a family page left its pill sitting there.

Now the family (INSTANCE_OF) is read before the apps.json reload — a removed
instance is gone from it afterwards — and the page ON SCREEN is repainted
whenever it belongs to the same family as the completed task, so a pill
appears on create and disappears on remove without a manual refresh.

Also handles the page whose app no longer exists: an instance removal deletes
the app outright (a plain uninstall only flips it to not-installed), and the
removal flow parks the user on the instance's own Tasks tab, so re-rendering
printed "App not found". Land on the type it was an instance of instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:48:18 +01:00
librelad
facf764c4b feat(webui): bulk selection on app tasks, updates overview, and app backups
The /tasks page's right-side tick + dynamic Select all / Clear All ⇄
Delete Selected layout now covers the other three management surfaces:

- App detail → Tasks tab: filter bar gains the Clear All button and
  master tick; Clear All there scopes to that app's tasks only. The
  selection set is resolved through window.tasksManager everywhere —
  TasksManager is constructed in several places, and ticks previously
  landed on one instance while Delete Selected read another's empty set.
- Apps overview → Updates: the header's Update all button now morphs to
  Update Selected (N) + Clear in place as rows are ticked, replacing the
  separate selection bar between toolbar and list.
- App detail → Backups: each snapshot row gains Delete + a right-side
  tick; a toolbar atop the list morphs Delete All ⇄ Delete Selected (N).
  The whole selection rides in ONE task (delete <app> 1:a,2:b,…) since
  the backup surfaces hold one task per subject at a time.
- CLI: backup app delete accepts comma-separated <idx>:<snap> pairs, and
  both delete and delete_all now regenerate the WebUI backup JSON so
  deleted snapshots leave the screen instead of lingering until the next
  backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:59:19 +01:00
librelad
dd68c04fec fix(updater): stop showing ghost rows for uninstalled apps
Matrix was uninstalled and the Updates tab kept listing it as up to
date. Not an instance problem — updates.json and cves.json are
scan-time snapshots on a 30-minute cadence, and nothing rewrote them at
uninstall, so any removed app haunted every updater surface until the
next scan happened to run. The backend was never wrong: the DB, the
apps data and the app's own page all said uninstalled within seconds.

Fixed at both ends. Uninstall now deletes the app's rows from both
generated files, surgically — a full rescan re-runs CVE checks against
every image and has no place inside an uninstall. And the updater's
merge drops any row whose app window.apps does not list as installed,
which covers every other way the snapshot can go stale (a crashed
uninstall, a hand-edited file, the next bug). The filter only applies
when the installed list has actually loaded, preserving the page's
degrade-gracefully contract when it has not.

The stale Matrix rows on this install were purged the same surgical
way; the tab now shows 14 rows with the merge still intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:50:11 +01:00
librelad
a86c142e74 fix(backup): honest failures, rootless docker in capture paths, speedtest key capture
- backup_files.sh / backup_db.sh: every docker exec/run in the capture,
  sidecar-discovery, rehydrate and DB-import paths now goes through
  runFileOp — bare docker can't reach the rootless daemon socket, which
  made live capture fail (and silently bounce containers) on every
  rootless install, and would have broken DB restores the same way.
- capture/rehydrate stderr is kept and printed on failure instead of
  being discarded, with a clear message when the image has no tar.
- backup_app_start.sh: when no location produced a complete snapshot the
  backup now returns 1 — the task is marked failed instead of logging a
  nonexistent/incomplete backup as a success and skipping verification.
- restic engine: on restic exit 3 the orphan incomplete snapshot is
  called out explicitly so nobody restores it believing it is whole.
- speedtest: capture /config through the container (root-owned TLS key
  and logrotate state are unreadable from the host), which also flips
  its auto strategy to live — no more container stop per backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:31:46 +01:00
librelad
b3b9f9a18b fix(webui): stop the loader double-loading scripts, and the sidebar noise
Two recurring console errors, each masking real ones.

"Identifier 'BackupAppCard' has already been declared": the app has two
script loaders that could not see each other's work. The boot-time
system loader injects tags with NO id and dedupes by script[src]; the
SPA's loadScript injects with an id and deduped ONLY by that id. So a
file both of them load — backup-app-card.js is on the boot list AND in
the overview Backups tab's asset chain — loaded twice whenever the SPA
loaded second: its getElementById check cannot see an id-less tag. The
class redeclared with an uncaught SyntaxError. The blindness was
one-directional, which is why it looked sporadic. spaClean.loadScript
now also matches by src, closing the asymmetry for every shared file,
not just this one.

"ConfigSidebar: config-categories-list element not found", three to
four times per navigation: renderConfig populated the admin sidebar
unconditionally, but the backup center's Configuration tab EMBEDS that
renderer inside its own pane (renderConfig('backup', <target>)), where
the sidebar element legitimately does not exist — and the refresh
coordinator re-renders that tab on every task event. Populating is now
skipped when a target is passed: an embedded render does not own the
admin shell. The error stays for target-less renders, where a missing
sidebar genuinely is a broken shell.

Verified in a live session: repeated sweeps across /tasks, the overview
Backups tab (Configuration sub-tab included) and a per-app Backups page
produce zero new occurrences of either error — confirmed against a
live console buffer via a sentinel, since the buffer retains the
pre-fix history and re-reading it can look like recurrence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:28:36 +01:00
librelad
429e750266 feat(webui): backup, restore and delete buttons show their task running
Same treatment the updater buttons got. Confirming a backup produced a
toast and a blind 1.5-second refresh; the tile's "Back up" pill stayed
armed and nothing said work was happening. Restore and Delete on a
snapshot row behaved the same.

runTask now derives the SUBJECTS a command holds from the command
itself: an app backup busies that app's tile, `backup system` the
system tile, and `backup all` busies every tile at once — one task,
honest feedback everywhere it acts. Restore and delete also parse the
exact snapshot out of the command, so only the clicked row's button
spins while the app's other snapshot buttons merely disable: the
spinner marks the action running, not the ones waiting on it.

Starting a second task on a busy subject is refused with a notice — a
second restore of the same app queued behind the first is a footgun,
not a feature.

The face survives re-renders: the refresh coordinator repaints this
page on every task event, so the tile and row renderers consult the
busy map rather than relying on the instant DOM patch alone. Cleared on
the task's terminal state, before the coordinator's debounced repaint
lands, so that repaint shows the finished state. The leak backstop is
two hours — a big app's backup is legitimately slow.

Verified end to end against the live install with two real backups:
the pill flips to "Backing up…" (disabled, spinner) in the same tick as
the confirm click, holds through the run, and restores within a second
of the task completing, with the busy map empty. Subject derivation
unit-tested across all five command shapes, including `backup all`
fanning out to every tile and snapshot-level targeting for restore and
delete. Reuses the updater's .btn-spin — both stylesheets are global,
so busy looks the same everywhere in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:14:11 +01:00
librelad
156c7fcc08 fix(webui): stop the styled tooltip doubling with the browser's own
Four rules rendered their bubble with `content: attr(title)` while leaving the
title attribute in place, so the browser drew its native tooltip over the styled
one — two bubbles, offset, one of them unthemed. That is the overlap.

setup-wizard.css already had it right with attr(data-tip). Everything else now
matches: the text moved to data-tip, which the browser does not render, and
aria-label carries it for assistive tech. 24 emitters migrated across 9 files,
including two that assign the text at runtime — those set both attributes so
neither goes stale.

The same sweep found the bubble was defined twice globally. core/forms/css had
the plain one; port-manager.css carried a nicer bordered, wrapping version
commented "Enhanced tooltips for main config help icons" — which never applied
to them, because forms.css loads afterwards at equal specificity and won. So
every help icon outside the port manager had been getting the plain bubble, with
`white-space: nowrap` and no max-width: a sentence-length tooltip rendered as one
unbroken line running past its own background. The wrapping version is now the
one in forms.css, the shadowed copy is gone, and the .port-manager-scoped rule
stays.

Verified on Bookstack's config page: 26 help icons, none carrying a title, all
carrying data-tip, and hovering one flips ::after content from `none` to the text
with a single themed bubble on screen.

No port-descriptor tooltip, deliberately. PORT_N fields are replaced at runtime
by the port manager, whose hidePortFieldLabels() sets the field's help icon to
display:none — anything put there is unreachable, confirmed in the DOM. The
format is already documented column by column by the port manager's own 13 help
icons, several in more detail than a single descriptor string could manage.
2026-08-21 00:01:18 +01:00
librelad
785df3dcd8 fix(tasks): name upgrade tasks, humanise dead instance slugs, keep icons
Three visible faults on the Tasks page, one shared root.

Upgrade tasks rendered as their raw command — "libreportal updater
upgrade rocketchat 8.7.1" beside properly named neighbours. The title
table had rows for updater check/apply/apply-all/rollback and none for
upgrade, because the upgrade command is assembled in task-actions.js
rather than task-commands.js — and lp-task-names, the guard built to
catch exactly this, only read task-commands.js. It certified 16 commands
and reported that as the whole surface; the surface was 29. The guard
now reads both dispatch sites (JS ${expr} interpolations become sample
placeholders; commented-out prose mentioning commands in backticks is
skipped, or it reports fictional commands), and all 29 pass.

A removed instance's tasks outlive it, and its slug rendered as a tech
identifier: "Bookstack_uitest - Remove Instance". getAppDisplayName
cannot help — it capitalises as its own fallback, so unknown is
indistinguishable from known-and-plain. The formatter now does the same
membership test the helper uses internally: slug absent from
window.apps, prefix before the underscore present -> render the way
live instances are shown, "Bookstack · uitest".

Same story for the icon: bookstack_uitest.svg is deleted with the
instance, and onerror="display:none" left a bare gap in the row. Now a
fallback chain — the TYPE's icon (which survives), then the LibrePortal
logo. Verified live: the dead instance's rows show bookstack.svg with
the fallback marker set, everything else keeps its own icon.

Verified in a real browser session — full render, zero console errors.
The 'add' verb also joins the app-action map so "Add Application" is
deliberate wording rather than the blind "<Verb> Application" compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:57:30 +01:00
librelad
c6e3997382 feat(webui): update buttons show their task running, in place
Replaces the follow-the-task navigation from the previous commit —
carrying the user to the tasks page and back was feedback by relocation.
This is feedback where the click happened: the button becomes
"Updating…" with a spinner and stays disabled until the task reaches a
terminal state, then the data refetches and the row repaints with the
result. Update, Update all / selected, the stepped Upgrade and Roll back
all get it (verbs keep their own labels: Upgrading…, Rolling back…).

Two paths keep the face honest. A DOM patch flips the buttons the moment
the click lands — no waiting for a render pass — stashing the original
face on the element so a failed dispatch can restore it. And every
renderer now builds these buttons through one busy-aware helper, so a
repaint landing MID-task (the auto-refresh poll, a filter change)
reconstructs the spinner instead of silently re-enabling the button.

Sibling actions are held while a task runs: Roll back on an app that is
mid-update is disabled — without a spinner, which marks the action that
is running, not the ones waiting on it. Entry points also guard on the
inflight set, so a keyboard-triggered duplicate is inert.

Correlation is by task id (routeAction resolves to the created task), so
another task finishing cannot end this button's busy state early. Only
completed/failed/cancelled end it; listeners are removed on the first
terminal event, with a 30-minute timeout so a task that never reports
terminal cannot pin a spinner forever. cursor is "progress", not
"not-allowed" — the work is happening, the button is not refusing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:11:59 +01:00
librelad
5f95a0744a feat(webui): follow an update into its task, and come back when it lands
Pressing Update produced a toast and nothing else. The row did not
change, nothing on the page moved, and the work was real but invisible —
so the button read as though it had not done anything.

Update, Update all / Update selected, and the stepped Upgrade now carry
you to the task they started and return you to the Updates tab when it
reaches a terminal state.

Correlated by task ID, not by app name: routeAction resolves to the
created task, so a second update started elsewhere cannot bring this one
back early, and a completion for an unrelated app cannot either. Only
completed/failed/cancelled end the wait — taskUpdated also fires
mid-run, and treating that as done would bounce the user out of a task
still in progress.

Navigation is deliberately NOT locked, which is the one part of the
request I did not build. The task is a background job with its own
snapshot and rollback; it does not need watching, and a lock would
strand someone here if a task ever hung. Leaving is therefore treated as
a choice — the return only fires while the user is still on a tasks
page, so nobody is yanked back from somewhere they chose to be.

Both listeners are removed on the first terminal event, and a 30-minute
timeout removes them anyway, so a task that never reports a terminal
status cannot leak a pair of window listeners per click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:59:15 +01:00
librelad
dbe3153af9 fix(webui): centre the per-tile Back up button on the tile's right edge
It was pinned to the tile's top-right corner, level with the app name, leaving
the status line beneath it visually unbalanced. The tile is two lines, so the
right edge's midpoint is the natural anchor.

The centring has to share `transform` with the existing reveal — the button is
opacity:0 until the tile is hovered and slides in 2px — so the hidden state is
translateY(calc(-50% - 2px)) and the revealed one translateY(-50%), rather than
the previous -2px/0 pair.

Verified under a real pointer, since the button does not exist visually without
one: hovering a tile gives opacity 1 with the tile's and the button's vertical
centres both at 571px.
2026-08-20 22:56:39 +01:00
librelad
2964688fc7 fix(webui): View Task navigates instead of reloading the page
The button assigned window.location.href, which is a full page load: the
app re-boots, every panel re-fetches and open state is lost — for a
control whose whole job is "show me that task".

It fired more often than it looks. The two branches above it only handle
being ALREADY on an app page with an app name, or ALREADY on /tasks. A
system-wide task carries no app name, and its /tasks/<category>/<id>
target keeps the id in the path rather than a ?task= query, so from the
dashboard or the App Center every guard missed and the reload was the
outcome.

The router handles that URL fine — tasks-manager reads the category and
task id out of the path on mount, so /tasks/all/<id> arrives with the
task already highlighted. Both fallbacks now offer it to spaClean first
and keep location.href only for when the router genuinely is absent.
The URL is normalised to a same-origin path, since navigate() expects a
path and the incoming value may be absolute; anything off-origin is
refused rather than routed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:52:47 +01:00
librelad
67970a84e9 fix(webui): give backup rows room, and part the id chip from Restore
The per-app backup list borrows the Tasks list's .task-* classes, whose
.task-header ships 4px of vertical padding. That is right for a dense log and
too tight for these rows, which carry a 28px app icon — the content sat hard
against the card edges. And the backup id is the last thing in .task-info with
.task-actions starting immediately after, so the hash and the Restore button
touched.

Both scoped to .backup-snapshot-item so the Tasks page keeps its density —
confirmed after the change: Tasks rows still compute 4px, backup rows 10px.
2026-08-20 22:47:56 +01:00
librelad
2510b1a52f feat(webui): green Upgrade, trailing picker, select-all, icon in confirms
Four fixes to the Updates tab, all reported from the same screenshot.

Upgrade is green and just says "Upgrade". It was accent-styled and carried the
target version, which duplicated the confirmation it opens — the dialog already
names the version, so the button only had to name the action. Green also stops
it reading as the same control as the cyan Update beside it.

The picker moved from the head of the row to the tail, matching the Tasks list.
It used to lead with an `ov-pick-empty` spacer holding the column open, so with
nothing updatable every row carried a left gutter for a checkbox that was never
coming — the gap in the report. Unselectable rows now render no picker at all.

Select all joins the toolbar, right-aligned above that column, again mirroring
Tasks. It acts on the selectable rows in view, so a narrowing filter chip cannot
leave it quietly picking rows nobody can see.

The confirmation dialog takes an optional icon and the upgrade flow passes the
app's, so it leads with the same icon-in-holder the instance modals use. Styled
in base.css rather than borrowing .app-card-icon: this dialog is global and
appears on pages that never load the apps stylesheet. Its title now uses the
display name too — "Upgrade matrix to…" beside the Matrix logo read as a
different thing.

Verified against the live WebUI: green Upgrade with the gutter gone; Select all
and the trailing checkboxes with two apps temporarily flagged (data restored
byte-identical after); the master toggling 0 -> 2 -> 0; and the dialog showing
matrix.svg under the title "Upgrade Matrix to v1.159.0?".
2026-08-20 22:47:56 +01:00
librelad
325f8f3be8 fix(webui): make Remove instance readable on the dark themes
The button drew its text from --status-danger, which is tuned as a FILL
colour — the confirm button in the remove modal sits white text on top of
it, and there it is correct. Reused as text on the app's own background
it measures 3.79:1 on nebula, 3.84 on dark-blue and 3.95 on the example
theme, all under the 4.5:1 AA floor, which is why the control read as
barely there. Light is the only theme where the same value passes, at
4.53:1, so brightening it globally would have fixed three themes by
breaking the fourth.

Two jobs, two values. --status-danger-text is now defined per theme:
#f87171 on the dark ones (6.29-6.47:1) and #c92a37 on light (5.43:1, up
from 4.53). #f87171 is not a new invention — it is the red the CVE
severity chips already use for exactly this job, so danger-as-text now
looks the same wherever it appears.

The border follows the text so the two stay coherent, and both fall back
to --status-danger, leaving any theme without the token no worse off
than before. The filled modal button is deliberately untouched: white on
#dc3545 was never the problem.

Other danger-as-text call sites exist (loading screen, network notifier)
and would likely benefit, but they render on their own backgrounds
rather than the app surface, so they are not assumed to share the
measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:31:11 +01:00
librelad
597825e798 feat(webui): show Upgrade on rows with a newer release line
Three rows read "CRITICAL · V1.159.0 AVAILABLE" and offered no button, so the
Updates tab looked broken — reported twice as "I can't see an update button".
Nothing was broken: Update is gated on update_available, which is false for all
14 apps here. Those three have newer_version instead, whose action is `upgrade`,
and that button existed only inside the row detail.

Hiding it was justified as wanting more care than a row button. It does not buy
that. updater-page's upgrade() already opens a confirmation spelling out the
per-step recovery snapshot, the rollback-and-stop behaviour and the likely
duration, and nothing runs until it is accepted. So the care is in the dialog
either way; hiding the button only hid the action behind a Details click nobody
had a reason to make.

The row now shows whichever action applies — Update (primary) for a same-tag
image, Upgrade to <version> (secondary) for a new release line — keeping the two
visibly different. The handler was already wired for `upgrade`; only the button
was missing. Update all and the multi-select tickboxes stay gated on
update_available, since walking release lines in bulk is a different risk.

Verified against the live WebUI: Matrix, Rocket.Chat and Mattermost now carry
Upgrade to v1.159.0 / 8.7.1 / 11.10 beside Details.
2026-08-20 22:15:20 +01:00
librelad
60a2fc3b0b Add lp-task-names, a guard against unnamed task commands
Task titles come from one table whose final fallback returns the raw command
string, so a dispatched command with no matching row does not error — it just
renders as "libreportal instance remove bookstack_work" beside properly named
neighbours. That silence is why this kept being fixed and kept coming back.

The guard reads BOTH files as source — the command templates from
task-commands.js and the pattern table from tasks-format.js — so it fails on a
command added without a name rather than leaving it to be noticed in the UI.

Two checks, both from source rather than guessed from rendered text:

  1. Nothing falls through: a title equal to its command, or still starting with
     "libreportal ", means the raw fallback was reached.

  2. Every `libreportal app <verb>` verb has an actionMap entry. Without one the
     generic branch composes "<Verb> Application", which is how "Up Application"
     and "Down Application" shipped.

The second check reads the actionMap keys instead of pattern-matching the title,
which a first attempt did and which was wrong: "Reload Application" is both a
correct hand-written label and what the generic branch emits, so the rendered
text cannot distinguish them and the heuristic failed a title that was fine.

Verified by breaking it deliberately in both directions — adding a command with
no pattern, and deleting an actionMap verb. Each is caught, named, and pointed at
the file to edit; both files were restored byte-identical afterwards.

Lives in scripts/dev, which .gitattributes marks export-ignore, so it never ships
in a release tarball. Needs a node and borrows the running container's when the
host has none, the same constraint lp-shot works around for chromium.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:38:51 +01:00
librelad
9a74b841d9 Give every dispatched command a task name; finish instance removal
Task titles come from one declarative table in formatCommandForUser, whose final
fallback is the raw command string. Five of the sixteen command shapes the WebUI
dispatches had no row and hit that fallback, so they showed as
"libreportal instance remove bookstack_work" where a backup shows as
"Bookstack - Create Backup". Cross-checked every shape in task-commands.js
against the real formatter rather than fixing only the reported one:

  instance create  -> Bookstack - New Instance (work)
  instance remove  -> Bookstack · work - Remove Instance
  system status    -> LibrePortal - System Status
  system update    -> LibrePortal - System Update
  system reset     -> LibrePortal - Reset System

Instance create is named off the TYPE rather than the new slug: at create time
the instance is not in window.apps yet, so displayName() would fall back to
capitalising the slug and render "Bookstack_work".

Four more were not raw but read badly, because the generic
"<Action> Application" fallback has no entry for the compose verbs: up and down
rendered as "Up Application" and "Down Application". Now Start/Stop Containers,
Reload Application and Check Status. And per-service restart collapsed to
"Restart Application", losing the only thing that distinguished it — it now
names the service.

All seventeen shapes verified through the real formatter: zero fall through.

Instance removal also finishes properly now. It skipped the button lockout a
normal uninstall applies, so the instance's Config/Tools/Backups tabs stayed
live against an app being torn down underneath them; it now makes the same
disableAppButtons call executeUninstall does. And it bounced to the TYPE's
Config page, hiding the very task the click had just started — it now lands on
the removed instance's own Tasks tab.

Verified by driving the WebUI: creating an instance files a task titled
"Bookstack - New Instance (uitest)", removing it lands on
/app/bookstack_uitest/tasks, and disableAppButtons flips all five app tabs from
enabled to disabled. Test instance removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:27:27 +01:00
librelad
db4ef19698 fix(webui): make the backups filter placeholder readable
The snapshot filter's placeholder had no rule at all, so it fell through
to the browser default — a grey chosen for light backgrounds, rendering
near-invisible against a dark panel. "Filter by app, host, or backup id"
is the one hint telling you what the box accepts, so losing it costs the
control its explanation.

Written against --text-rgb rather than a fixed grey so it follows the
theme: white at 60% on nebula and dark-blue, dark at 60% on light, where
a hardcoded #ccc would have been unreadable the other way round. Firefox
dims placeholders on top of the colour, so opacity is pinned to 1 and the
alpha above owns the result. Slightly brighter than the task filter's
0.4, and in line with the shared .form-control rule's effective value,
because this string is instruction rather than decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:21:49 +01:00
librelad
7e828a2ec1 feat(webui): select and update several apps from the Updates tab
Updating one app and updating everything were both already there — a
per-row Update button and an Update all in the tab header. What was
missing is the middle: choosing three of eight. Both existing controls
are gated on update_available, so with a fully current fleet the tab
shows neither and reads as though it cannot update anything at all,
which is what prompted this.

Checkboxes appear only on rows that actually have something to apply.
Offering one on a current app would be a choice with no outcome, and
"selected 6, updated 2" is a worse answer than not offering the four.
The column is still reserved on every row, hidden — otherwise the icons
shift left and right as scans land and rows change state.

applySelected filters the chosen names against update_available rather
than trusting them: a selection can outlive the scan that justified it,
and asking to update an app with nothing to apply would spend a snapshot
to achieve nothing. Update all now routes through the same function, so
there is one path, one task, and the same per-app contract — snapshot
first, roll back on failure.

Two details that only show up when you use it. The checkbox sits inside
the row head, which is itself the expander target, so its handler has to
claim the click or picking an app also opens its detail panel. And the
count is refreshed in place instead of re-rendering the tab, because a
re-render rebuilds the checkboxes under the pointer mid-selection.

The bar holds its space at zero selected rather than appearing on the
first tick, which would shift the list down under the cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:17:37 +01:00
librelad
934193d901 copy: drop deployment trivia from app descriptions
App descriptions are read by people deciding whether they want the app,
not by people maintaining it. Several were spending their last clause on
facts the reader cannot act on and would not recognise — and in Stoat's
case actively talking the app down: "Heavy (16 containers) and does not
federate" is a maintainer's note, not a description.

Eight rewritten, all the same fault:

  stoat        "Heavy (16 containers) and does not federate", and LiveKit
               named as though the reader would know what it is
  vikunja      "Runs as a single container on SQLite, with no database sidecar"
  stalwart     "in a single container"
  gitea        "written in Go", plus "self-hosted Git service" twice in one line
  vaultwarden  "an alternative implementation of the Bitwarden server API
               written in Rust" — says what it is to a developer, not what it
               does for you
  speedtest    "implemented in Javascript"
  adguard      "resolving blocked domains to a local blackhole address"
  matrix       "Installs Synapse plus the Element web client"

Deliberately kept, because they change whether the app suits you rather
than merely describing how it is built: Rocket.Chat's free-edition user
cap, Mattermost's unlimited users, Navidrome's Subsonic compatibility
(it tells you which phone apps will work), Stalwart's protocol list, and
Gluetun's provider count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:07:59 +01:00
librelad
29fa8e8a60 Close Stoat registration by default, behind CFG_STOAT_INVITE_ONLY
Provisioning the owner account did not stop anyone else signing up. Stoat ships
open, with no captcha and no email verification, so a reachable instance still
accepted walk-in registrations.

Made a config option rather than hardcoded, because running an open community
server is legitimate — but defaulting CLOSED, which is the opposite of the other
registration toggle in the tree. Vaultwarden's SIGNUPS_ALLOWED defaults true for
a reason that does not apply here: it has to let you register to get in at all,
whereas Stoat's owner account is now created for you. Matrix's
ENABLE_REGISTRATION already defaults false for the same shape of app.

The section name is load-bearing and not guessable. invite_only under [features]
or [api.security.authifier] is accepted in silence and does nothing — the API
keeps reporting invite_only=false — so it goes under [api.registration], which
was found by testing all three against a running instance. Anything but an
explicit "false" closes registration, so a blank or misspelled value fails safe.

Closing it broke LibrePortal's own tooling, which is the part worth noting: the
API answers MissingInvite to create_account too. So account creation now mints a
single-use invite and retries when it sees that. Reactive rather than reading the
config, so it follows the instance's actual state — someone who edits Revolt.toml
by hand gets the same behaviour. Stoat stamps the invite used/claimed_by as it
consumes it, and a failed create deletes it, so no reusable invite is left behind;
verified that the collection holds zero unused invites after two creations.

Verified end to end: a fresh install reports invite_only=true, a walk-in signup
is refused with MissingInvite, and the Create User Account tool still succeeds.
Flipping the config to false and reinstalling flips the API to open, and the
provisioning guard correctly reports "already has accounts" instead of trying to
claim a second owner. The value is baked into Revolt.toml at install, so changing
it needs a reinstall rather than a reload — now said in the config comment.

Unrelated flake seen once during testing and not reproduced: an install left
stoat-rabbit with no IP row, so the compose died on a literal IP_DATA_3. A
straight uninstall/reinstall allocated all 16 cleanly. Untouched here — it is in
the IP allocator, not this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:05:51 +01:00
librelad
a1290b47a3 fix(ports,config): stop losing columns in the port descriptor
Four faults, all in the same 12-column format, all silent.

The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.

The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".

Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.

Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.

Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:48:08 +01:00
librelad
93ec260298 Provision a Stoat owner account, add create/reset user tools
Stoat shipped with no account and no way to make one from LibrePortal. It is
first-come-first-served, with invite_only=false, no captcha and no email
verification, so every install left a window between the API answering and
someone signing up in which anyone who could reach the port could take the
instance. The installer now claims the configured account as soon as the API
responds, and prints the credentials instead of "go and register".

Provisioning goes over HTTP, not Mongo: an account needs a login AND a
completed onboarding (accounts holds one, users the other) and passwords go
through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the
instance exactly as it was before this existed, which must not fail an
otherwise good install of sixteen containers.

Both obvious config defaults are rejected by Stoat, which is only visible as a
failed install, so both are chosen against its rules: example.com comes back
DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin"
comes back InvalidUsername (reserved) hence "administrator".

Two of the three missing adapter operations are now implemented:

- createUser: create, log in, complete onboarding. Without the last step an
  account can sign in and then sits on a pick-a-username screen forever.

- setPassword: previously excluded because hand-rolling argon2 risks writing a
  hash nothing can verify, locking the holder out with no error at the time.
  That objection is answered by refusing to hash at all — authifier already
  owns a reset flow, so this writes only its password_reset token to Mongo and
  lets PATCH /auth/account/reset_password do the hashing with the same code
  that verifies. Verified: reset by username and by email, new password logs
  in, token consumed.

setAdmin is still NOT implemented, and the header now says so with evidence
rather than assertion. Stoat has no instance-level admin flag: the user
document holds only _id/username/discriminator and GET /users/@me adds only
relationship and online. Permissions are per-server bitfields on server_members.
A "make admin" button would invent a concept the app does not have.

Also fixed two things found while testing:

- post_start returned early when the public URL needed no settling, which
  skipped everything after it — so provisioning would have been silently
  missed on exactly the domain-backed installs that guessed the URL right.

- _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external
  resolver, in URLs compiled into the web client. Same fix as the APP_URL
  processor: prefer $local_ip_v4, since LibrePortal never forwards ports.

Verified end to end on a clean install: the owner account is created and
onboarded, the generated password logs in, both new tools run through
`libreportal app tool`, and a created account survives a password reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:37:32 +01:00
librelad
701f014619 Hide zero-count filter chips on the Updates tab
An empty chip is a dead end: clicking it can only ever produce "Nothing matches
this filter". Hidden rather than disabled, because a greyed-out chip still
occupies the eye, and "no apps have CVEs" is better said by the row of chips
being short than by a control you are told not to press.

Two exceptions. `all` is never hidden — it is the way back. And the active chip
stays put even at zero: a rescan can empty the filter you are standing in, and
pulling it out from under the selection would leave the list filtered by a chip
that is no longer on screen.

Verified against the running WebUI: Updates sits at 0 here and its chip is gone,
leaving All 14 / Newer 3 / Security 11, with every remaining chip still
selecting correctly. Forcing the filter to `updates` re-shows that chip marked
active, so the zero-but-selected case renders as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:15:58 +01:00
librelad
e50adf8f97 Sort the Updates list by actionability, add a Newer filter
The list rendered in whatever order the updater emitted, so the handful of rows
that actually want a decision sat wherever they happened to land — on a long
list, below the fold, under a wall of "up to date".

Rows are now ranked by what you can DO about them rather than by how alarming
they look, since an app with an Update button is one click from resolved while
an app with only a Details link is not:

  0  update available, and the automatic attempt already failed
  1  update available
  2  a newer release line is published
  3  CVEs, but nothing to apply
  4  never scanned
  5  up to date

Auto-failed leads because that build was tried and rolled back and will NOT
retry itself — it is the only row in the update group that strictly needs a
person. Worst severity orders within a rank, then display name, so the ordering
is total and stable across repaints.

Filtering already existed (All / Updates / Security); this adds the one state
that was unreachable. `newer_version` is deliberately excluded from `updates` —
its action is Upgrade, inside the row detail, not the row's Update button — so
it had no chip of its own despite being the state most likely to need a
decision, because it never resolves on its own: automatic updates only track
the line you are already on. The chip excludes rows already counted under
`updates` so the totals partition the list rather than double-count.

Verified against the running WebUI. With real data the three newer-version rows
lead (critical, critical, high, then alphabetical), followed by the eight
remaining CVE rows and the three clean ones — 3 + 8 matching the Security count
of 11. Temporarily flagging two apps update-available confirmed ranks 0 and 1
take the top, with the auto-failed row above the plain one; that data file was
restored byte-identical afterwards. Every chip was exercised by clicking it:
All 14, Updates 0, Newer 3, Security 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:03:53 +01:00
librelad
acc91777c4 refactor(prometheus,stoat): prefix service names so instances are possible
Both apps were barred from multiple instances by the same rule: a
service or container name with no <app> prefix cannot be made unique per
instance, so a second copy would fight the first for it.

Prometheus is now fully unblocked. node-exporter and cadvisor became
prometheus-node-exporter and prometheus-cadvisor — service key,
container_name and SERVICE_TAG default together — and prometheus.yml's
scrape targets follow them. The image references (prom/node-exporter,
gcr.io/cadvisor/cadvisor) and the job_name labels are deliberately
untouched: the first are upstream coordinates, the second are labels
dashboards and alert rules key off, and renaming either would break
something to fix nothing.

Stoat's sixteen services are now all stoat-prefixed. Its container_names
already were, so only the service keys and their depends_on references
moved. The subtlety is DNS: compose registers the service name as a
network alias, so renaming a key silently retires the name anything
else resolves it by — Revolt.toml reaches the broker as "rabbit", the
Caddyfile proxies to api, events, autumn, january and gifbox, and the
bucket job talks to http://minio:9000. Ten services already carried an
explicit alias that survives the rename; the six that did not
(database, redis, rabbit, createbuckets, crond, pushd) have been given
one holding their old short name. Every name that resolved before still
resolves. Verified by parsing the result: 16 services, every one keeping
its short alias, and no dangling depends_on in either app.

Stoat stays non-instanceable, but for one narrower reason now, recorded
in its config: LiveKit advertises its own tcp_port 7881 and UDP range
50000-50100 to clients from livekit.yml, so those cannot be randomised
per instance without generating that file per instance and allocating a
range for each.

Stoat's port descriptors name their parent service, so they follow the
rename too: caddy -> stoat-caddy, livekit -> stoat-livekit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:57:05 +01:00
librelad
3ebd4ee5bb fix(webui): tell a deliberate singleton from an un-reviewed app
The instances line had two states where there are three. An app with a
blocker cannot be instanced; an app whose flag is explicitly false was
reviewed and deliberately kept to one — the resolver clients point at,
the auth provider every router points at, the scanner the updater
resolves by a fixed container name; an app with no flag has simply never
been looked at.

Collapsing the middle case into the last reported a decision as an
oversight, and invited someone to "fix" it by flipping the flag. Those
apps now read "Not offered" and point at the reason recorded above the
flag in their config, rather than "not been opted in".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:50:11 +01:00
librelad
fd65b74d53 config: keep infrastructure apps out of multiple instances
The previous commit enabled everything that passed the two mechanical
checks. Passing them is necessary, not sufficient: several of those apps
are singletons by role, and an instance of them would validate, clone,
start, and then not make sense.

Eight are now off by design, each saying why:

  adguard              a resolver is what clients point at
  authelia             the forward-auth provider every Traefik router points at
  gluetun              a network provider — apps join it by container name
  headscale            the control server a tailnet is defined by
  libreportal_catalog  LibrePortal's own catalog, internal plumbing
  ollama               one endpoint, and gigabytes of models per copy
  trivy                the updater resolves the scanner by a FIXED container
                       name, trivy-service, so a second copy would run and
                       never be the one CVE scanning uses
  wireguard            one stable published UDP endpoint; peers are tied to it

And one that should never have been touched: crowdsec ships no
docker-compose.yml, so the audit — which required a compose to read
service names from — skipped it, while the enabling pass only required a
config and did not. It got an unaudited true. There is nothing for
`instance create` to clone, and one decision engine watching the whole
box is the point of it. Now false, with that stated.

23 apps instanceable, 15 not: 6 that cannot be, 9 that should not be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:47:40 +01:00
librelad
6813621fe9 config: enable multiple instances everywhere it is actually possible
Three apps were instanceable and the rest were silent, so the feature
looked far narrower than it is. Every app has now been checked against
the two rules instance_create.sh enforces, and the answer recorded in
its config rather than left unset.

32 apps are instance-safe and now say so. Six are not, and each says why
in its own words instead of being indistinguishable from an app nobody
had reviewed:

  pihole      a DNS server must own port 53
  unbound     a resolver must own its fixed 5335
  stalwart    a mail server must own 25/465/587/993
  traefik     must own 443, and one Traefik routes every other app
  prometheus  node-exporter and cadvisor carry no "prometheus" prefix
  stoat       pins 7881, and database/redis/rabbit/minio carry no prefix

The first four are genuinely one-per-host: the port is not arbitrary, it
is the protocol. The last two are compose-identity problems and could be
fixed by prefixing those service names, which is a change to make
deliberately rather than in passing.

Recorded as an explicit false with a reason, not left unset, so the next
person reads a decision instead of an absence. The audit was verified not
to pass anything vacuously: every app resolves at least one service name,
so no app reached "eligible" merely because nothing was found to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:38:34 +01:00
librelad
964021ba5e fix(webui): move the "no instances" reason into a tooltip, and say more
The reason spent a full line of every non-instanceable app's page on a
sentence only someone asking "why not" wants. It now shows a short
"Not available" / "Not enabled" with a hover-and-focus target carrying
the technical detail.

It also says more than it could before. The old text knew about one
blocker — a pinned host port — and its own comment conceded the rest was
"left unexplained rather than guessed at, since the remaining blocker
lives in the compose, which the frontend never sees". It does see it:
apps.json carries each app's service list, so the second rule
instance_create.sh enforces (a service or container name with no <app>
prefix, which cannot be made unique per instance) is reproducible here.
Prometheus now says node-exporter and cadvisor rather than nothing.

And it separates two states that read identically before. An app with no
blocker at all is not "unavailable", it is simply not opted in — a
different sentence with a different fix, and the one Matrix was hitting.

Keyboard reachable via tabindex, with aria-label duplicating the tooltip
since screen readers do not reliably surface a title attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:29:29 +01:00
librelad
1b3d68d9f7 Highlight the hovered app row on the Updates tab
The row head is the click target that expands a row and already set
cursor:pointer, but nothing changed under the pointer — so a clickable row
looked identical to a static one right up until it opened, and the only visual
state was post-click.

The tint is painted by the row rather than the head. .ov-row's `padding: 0`
loses to .updater-row's `padding: 12px 15px` on equal specificity, so the head
is only 34px tall inside a 60px row; filling the head left a floating band with
13px of untinted row above and below it. Painting the row covers the full
height and picks up its 11px radius for free.

Keyed on `:has(> .ov-row-head:hover)` rather than `.ov-row:hover` so the
highlight tracks what is actually clickable: on an expanded row, drifting into
the detail body stops indicating a click target, because there isn't one there.
The head-level rule stays as the fallback for engines without :has() — it still
marks the row, just inset — and the pair is mutually exclusive so the two tints
never stack. :has() is already relied on elsewhere in the WebUI CSS.

Verified against the running WebUI by driving Chrome over CDP and dispatching a
real mouseMoved, since lp-shot cannot hover: the hovered row goes from
rgba(255,255,255,.035) to .086, its neighbour stays at .035, and the head fill
reads transparent so nothing double-tints. Confirmed visually in the capture
too. ov-row-head is emitted only by renderUpdates(), so no other tab changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:28:30 +01:00
librelad
b8c23f0dc9 fix(stoat): name compose services, not containers, in the port descriptors
The Ports page showed no service selected for Stoat, and the app's URL
never appeared anywhere, so there was no way to learn which port it is
on.

Field 1 of a PORT descriptor is the parent SERVICE:
db_list_installed_app matches it against each compose service name to
attach the app's access URL. Stoat named containers instead —
"stoat-caddy" and "stoat-livekit", whose services are "caddy" and
"livekit". It is the only app in the catalogue that did; rocketchat says
rocketchat-service, matrix says matrix-synapse, and both are real
service names. Nothing matched, so no URL was ever associated and the
Ports page had nothing to select.

The port itself was always allocated correctly — Caddy publishes 4210 —
which is why the app worked for anyone who already knew the number.

The live config had also lost the 11th field (the Traefik subdomain,
"stoat") that the template carries, so restore that at the same time.

Not a Caddy problem: Stoat's Caddy is its internal path router, doing
prefix stripping and Location rewrites across api, events, autumn,
january, gifbox and web plus file-serving /.well-known/stoat. Traefik is
the edge in front of it — the same split Nextcloud uses with its nginx
sidecar — and Traefik cannot serve that .well-known file at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:14:21 +01:00
librelad
419105c906 fix(webui): spell out the auto-update window as am/pm
"06:00-08:00" is unambiguous on paper and ambiguous at a glance — the
strip said updates install "during 06:00–08:00" and left the reader
working out whether that meant morning or night. The window now renders
as 6am–8am. The config stays 24-hour so there is still one canonical way
to type it; only the display spells it out, and anything that is not a
plain HH:MM-HH:MM passes through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:02:46 +01:00
librelad
64ff5f508b feat(updater): verifiers for Matrix, Mattermost and Rocket.Chat
GATE 1 refuses to ladder an app that cannot prove a rung landed, and
only mastodon, nextcloud and stalwart could. None of those are installed
here, so the stepped upgrade — button or automatic — was unreachable for
every app on the box.

Three fixes.

_updaterPrimaryContainer assumed the container is "<app>-service". It is
a convention, not a rule: matrix names its anchor service matrix-synapse
and stoat names its api (container stoat-api). The verifier therefore
inspected a container that does not exist, saw no state, and could only
time out — on exactly the stateful apps that most need verifying. It now
reads the anchor service's container_name from the compose, buffering
per service block because container_name may sit either side of the
image line.

Added updaterVerifyHttpVersion: poll the app over its PUBLISHED port
from the host, pull the version from a JSON field or a response header,
and require agreement three polls running. Probed from the host rather
than `docker exec … curl` because half these images ship no curl at all
(mattermost is one), so exec-based probing is a coin flip on the
vendor's base image. Version comparison matches only the components both
sides state, since tags and self-reported builds rarely share precision:
v1.158.0 vs 1.158.0, 11.9 vs 11.9.1, 8.7.0 vs 8.7 all agree; 11.9 vs
11.10 does not.

Each app hook is then three facts. Verified live: all three confirm at
the version they are actually on, and all three REFUSE a version they
are not — which is the property that makes stepping them safe.

updaterUpgradeAuto now skips apps with no verifier instead of queueing a
task that GATE 1 will reject, which would otherwise mean a failure
notification every day for an app that was never eligible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:04:46 +01:00
librelad
c3494f7d19 Make CFG_SEARXNG_THEME actually apply
The hook substituted `simple_style: auto`, a line that only exists in SearXNG's
full bundled settings.yml. The file generated here is the minimal
`use_default_settings: true` form with no ui: block at all, so the sed matched
nothing and the theme setting had never taken effect on any install.

It could not have worked even with the right pattern: the entrypoint chowns
settings.yml to searxng:searxng (uid 977) mode 644 on first start, so the
host-side docker user cannot write to it. The edit now runs inside the
container via docker exec, targeting the real key path
ui.theme_args.simple_style.

Three shapes are handled so the hook stays correct on repeat installs and
alongside hand edits: substitute in place when simple_style already exists,
nest theme_args inside an existing ui: block rather than appending a second one
(a duplicate YAML key SearXNG refuses to load), and otherwise append the whole
block. All three were exercised against the running container and produce valid
YAML with exactly one ui: block. awk rather than `sed a\` for the nesting case,
since busybox sed does not expand \n in appended text.

The value is validated against auto|light|dark|black before being written.
SearXNG checks it at startup and exits on anything else, so an unrecognised
CFG_SEARXNG_THEME would have taken the app down instead of merely looking
wrong; it is now reported and the default left alone.

Verified end to end on a base install and a --local instance: both come up,
serve 200, and report Dark as the selected style on /preferences, each with its
own settings.yml and secret_key. The instance's cloned hook correctly reads
CFG_SEARXNG_PROBE_THEME and targets its own container, since the container name
is built from $app_name. Both test installs were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:58:08 +01:00
librelad
1fb8fdee20 Grant searxng DAC_OVERRIDE so it can write its own config
SearXNG never started on rootless Docker — base install or instance, it
restart-looped from the first boot and the web UI was never reachable.

Cause is a specific capability combination, not a general permissions problem.
The image's entrypoint runs as root and, on first start, chowns the mounted
config dir to searxng:searxng (uid 977) using CHOWN, then immediately copies
settings.yml into the directory it has just given away. The compose dropped ALL
capabilities and added back only CHOWN, SETGID and SETUID — so the chown
succeeded and the write did not, because root cannot write to a 0755 directory
owned by another uid without DAC_OVERRIDE:

  cp: can't create '/etc/searxng/settings.yml': Permission denied
  !!! "/etc/searxng/settings.yml" is not a valid file, exiting...

Isolated the cause by running the image against fresh directories: it fails
with the shipped cap set and succeeds with DAC_OVERRIDE added, everything else
identical. Granting CHOWN without DAC_OVERRIDE is the exact breaking pair; all
other capabilities stay dropped.

Verified end to end: base install and a --local instance both come up and serve
200 on their own random ports, each with its own settings.yml and a distinct
secret_key. This also completes the instance verification that was blocked
earlier — searxng could not be runtime-tested while the app could not boot.
Both test installs were removed afterwards.

Separate pre-existing bug, not fixed here: the install hook applies the theme
with sed s/simple_style: auto/.../ but the generated settings.yml contains no
style keys at all, so the substitution matches nothing and CFG_SEARXNG_THEME
has never had any effect. Fixing it means inserting a ui: block rather than
replacing a line, which is a different mechanism and wants its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:25:31 +01:00
librelad
6dbf2e6a55 Point vaultwarden's DOMAIN at APP_URL so it starts without a domain
Found by installing vaultwarden and one instance end to end. The template built
DOMAIN as https://<subdomain>, and blanking host_setup on a box with no
CFG_DOMAIN_n set (earlier in this branch) left it as a bare "https://".
Vaultwarden validates that value and exits:

  Error validating domain: empty host
  DOMAIN variable needs to contain the protocol (http, https)

APP_URL is already the address the app is reached at in both worlds —
https://vault.<domain> behind Traefik, http://<lan-ip>:<port> without it —
which is exactly what vaultwarden means by DOMAIN. Prior to the blanking the
value was "https://<app>." with a trailing dot, which started but pointed at a
host that never resolved, so this was broken before too, just quietly.

Verified: base and instance both come up and serve 200 on their own random
ports, each with DOMAIN set to its own address, and with distinct IPs and admin
tokens. Both were then removed; nothing left behind.

Four other apps interpolate the same legacy DOMAINSUBNAME_DATA into env vars
and get an empty value with no domain configured — gitea (DOMAIN, SSH_DOMAIN,
ROOT_URL), mastodon (LOCAL_DOMAIN), owncloud (OWNCLOUD_DOMAIN), jitsimeet
(PUBLIC_URL). They start rather than exit, so the breakage is quieter, and the
fix is not uniform: ROOT_URL/PUBLIC_URL want a URL like this one, while
DOMAIN/SSH_DOMAIN/LOCAL_DOMAIN want a bare host that APP_URL cannot supply.
Left alone pending that decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:45:21 +01:00
librelad
e9fceeab99 Enable multi-instance on vaultwarden and searxng
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.

Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.

Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.

The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.

Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.

WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.

Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:35:33 +01:00
librelad
39dad00455 Give vaultwarden and searxng random host ports
Both pinned an arbitrary host port — vaultwarden 8201:80, searxng 8083:8080 —
which was the only thing blocking them from being instanced. Neither number is
meaningful the way pihole's 53 or stalwart's 25 are, so both become
random:<internal> and portAllocate assigns each install (and each future
instance) its own. The ports appeared nowhere else: no hook, no compose, no
docs. Neither app is installed on the maintainer's box, so nothing to migrate.

Both now clear every instance guard. Of the eight apps the port guard caught,
that leaves six, all genuinely one-per-host.

Also made compose rewrite rules 2 and 3 skip commented lines, for the same
reason rule 1 already does. Spotted while verifying the above: vaultwarden
parks an optional exporter behind #, and rule 2 rewrote the container_name
inside that dead block while the service key above it kept the old name,
leaving it internally inconsistent. Harmless — rule 2 is anchored on
container_name: so it could never reach the image line — but there is no reason
to touch a commented block at all. Bookstack's rewritten identities remain
byte-identical to the running instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:25:50 +01:00
librelad
e9bbe44601 Rename bookstack_db to bookstack-db for naming consistency
It was the only underscore-separated service name across all 38 apps; every
other helper uses a hyphen (nextcloud-db, matrix-postgres, owncloud-mariadb,
gitea-cache, mastodon-redis).

Beyond consistency this closes a naming collision by construction. Instance
slugs are <type>_<id> and may only contain [a-z0-9_] — the underscore is forced
there, because app configs are SOURCED and the uppercased slug becomes part of
CFG_<SLUG>_* variable names, which a hyphen would make invalid shell
identifiers. So a hyphenated helper name is one no slug can ever produce:
bookstack_home-db is unreachable, where bookstack_home_db was a name an
instance literally called "home_db" could also generate.

The four installed Bookstack apps were removed and rebuilt on the new template
(no data worth keeping, per the maintainer). All four are back up and serving
200 on the LAN, and their port rows join correctly to the renamed services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:12:09 +01:00