79 Commits

Author SHA1 Message Date
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
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
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
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
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
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
e25c69e2a1 Make multi-instance work without a domain
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>
2026-08-19 02:53:43 +01:00
librelad
c26b7190c5 feat(tools): one small spinner toast per tool run
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.
2026-08-19 02:27:41 +01:00
librelad
046d69bad7 feat(updater): Upgrade button for cross-version moves
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>
2026-08-13 00:08:16 +01:00
librelad
5e06e77d7f fix(forms): one bad select no longer kills every dropdown after it
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>
2026-08-12 22:40:26 +01:00
librelad
0b0fe3de40 fix(backup): pair tags and paths on one row in snapshot details
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>
2026-08-01 13:43:27 +01:00
librelad
eea9cb4006 feat(icons): add official Trivy app icon
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>
2026-07-17 23:49:40 +01:00
librelad
4f06674b61 ux(tasks): frame the task-type icon in a tile to match the app icon
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>
2026-07-17 23:35:43 +01:00
librelad
2005f2c742 feat(tasks): retry keeps the failed task (marked "Retried") + jumps to new run
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>
2026-07-17 23:23:48 +01:00
librelad
25b496df06 ux(health): rename "Repair Control Plane" → "Fix System Issues" (plainer copy)
"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>
2026-07-17 23:11:34 +01:00
librelad
2d1e4aa98f feat(health): self-healing control-plane watchdog + crash-loop failure cap
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>
2026-07-17 22:03:02 +01:00
librelad
4b85c24e94 feat(trivy): add Trivy as a recommended security app
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>
2026-07-14 21:46:37 +01:00
librelad
d41073fad7 fix(webui/tasks): quiet the noisy task toasts for background (self-surfacing) tasks
A background task like the updater check surfaces its result in the page it was
launched from (the Updates/Overview tab repaints itself), yet it still fired the
full "View Task" pair — "Check task started!" then "Check task completed!" —
on top of the launcher's own small "Checking apps for updates…" line. Three
notifications for something you have no reason to open the Tasks page for.

Add a small, generic classifier (LP_BACKGROUND_TASKS) keyed by task action/type
(with a command regex that also catches the backend `updater check auto` run).
For a classified task:
  - executeTask() skips the standard "task started!" toast and, when the run is
    hand-launched, records its id as pending.
  - the taskCompleted handler skips the standard "task completed!" toast; if the
    id was pending (user launched it) it shows one small plain result line
    ("Apps checked for updates & vulnerabilities."), otherwise stays silent so
    the periodic auto-scan makes no noise at all.

Non-background tasks are unaffected — the standard task-notification style still
applies. Extend byAction to quiet another self-surfacing action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-07 22:05:56 +01:00
librelad
143c1ca23d fix(config/webui): align multi-column config toggles inline with inputs
In the config grid (e.g. Backups > Configuration > Engine) toggle pills sat
~6px below and 2px taller than the text/select controls beside them, so they
read as misaligned. Two causes, both scoped to .config-fields:

- .field-group carried a vestigial margin-bottom:6px (the grid's own 16px gap
  already spaces rows). It inflated the grid track, so the align-self:end
  toggle landed 6px below the input controls. Zeroed inside the grid.
- The .checkbox-label pill's 24px switch + 10px vertical padding rendered 46px
  vs the inputs' 44px. Trimmed to 9px padding so the pill is 44px.

Toggles now share the exact vertical band (top + height) as the inputs in
their row. Applies to every config-grid toggle, not just the backup engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:55:12 +01:00
librelad
01e8743a8e Merge claude/1 2026-07-07 21:40:57 +01:00
librelad
ada5a4c177 fix(webui/config): box the master-toggle control row like the rest of a section
On /admin/config/general the Basic section's fields sit in the darker inset
".config-fields" box, but the Mail Enabled toggle and Installation Mode select
did not — their control sits in its own .mail-master-toggle / .git-master-toggle
wrapper, a sibling of .config-fields, which the boxing rule never covered. So a
gated section's enable row read as loose, unboxed content above the (hidden)
options.

Extend the boxed-panel rule to the master-toggle wrappers (mail / install-mode /
generic / backup-remote). Element-qualified to the wrapper <div> because
renderToggleSection stamps the same class on the inner .checkbox-label pill too,
which must not get its own nested box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-07 21:40:57 +01:00
librelad
64d298c6db fix(catalog/webui): make the official-catalog "Re-enable" button inline, full-height & clickable
When the official catalog is hidden, its "Re-enable" action rendered as a
flat, greyed-out ~27px pill pinned to the top-left of the row — shorter than
the 44px URL field and not obviously clickable (the generic .btn-secondary
loses its nebula-theme polish outside .config-actions, so it fell back to an
unstyled base button).

- The action column now align-self:stretch, so its control matches the URL
  field's height and sits inline (also vertically centres the compact × in the
  enabled state).
- The Re-enable button gets a dedicated .catalog-reenable-btn treatment: accent
  fill + accent border + hover state + an eye icon, filling the row height so it
  lines up with the input and clearly reads as a button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:40:23 +01:00
librelad
8aae1bde4f fix(webui/config): nudge the "Official" catalog badge inline with the URL text
The badge centres on the input box (top:50%), but the single-line input renders
its text a hair low, so the pill read as slightly above the URL. +2px lands it
optically inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 23:25:46 +01:00
librelad
d571018b9f fix(catalog/webui): replace the official-hide warning wall-of-text with a ✓/✗ checklist
Feedback: the removal warning was an overwhelming paragraph and made it look like
updates might break. Keep the official catalog removable, but present the effects
as a clear two-part list instead of prose:
  ✓ Still works: system updates & hotfixes, installed apps, re-enabling any time
  ✕ What changes: official apps hidden from the App Center "Available" list
Backed by a new optional messageHtml arg on the shared confirmation dialog (dev-
controlled HTML body) + styles for the effect groups. Required tickbox kept.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:13:55 +01:00
librelad
190c06ebce fix(catalog/webui): inline info note + official row shows its URL, height matches
- Info banner: drop the borrowed traefik warning banner (icon sat above the
  text, amber-warning styling) for a .catalog-note — icon inline with the text,
  neutral boxed look matching the content sections.
- Official row (#1): render the release-host URL in a read-only input (same
  element/height as the third-party rows) with the "✓ Official" badge overlaid,
  instead of short text — so it shows the URL and its box height matches the
  others. URL is read from the loaded config (CFG_RELEASE_BASE_URL).

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:44:55 +01:00
librelad
3766f73654 feat(catalog/webui): pin official source #1 (removable via warning+tickbox) + box config sections
- Catalog block now shows the official catalog as a pinned row #1 (verified
  badge + its URL from the generated data). Removing it opens the shared
  confirmation dialog with a REQUIRED tickbox (generalized to accept custom
  checkbox text) — it only hides official apps from browsing; system updates are
  unaffected. Backed by a new CFG_CATALOG_OFFICIAL_ENABLED flag the scan honors.
- Trimmed the info-banner wording per feedback.
- Config option sections: the body (after the divider, before the buttons) now
  sits in a darker boxed panel (bg + border), matching the content-box look used
  elsewhere. Applies to regular grids + the block editors; scoped to the main
  config editor.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:29:46 +01:00
librelad
506a3cf7ca feat(catalog/webui): domains-style block editor for CFG_CATALOG_* sources
Adds the Catalog Sources block to the config editor (General → App Catalogs),
mirroring the domains block UI: add/remove up to 9 catalog URL slots, saved by
the generic config-form (any CFG_* input) → configUpdateBatch, which rebuilds
registry_catalog.json. Official catalog stays source #1 (not editable here);
third-party rows carry an "unverified" note.

- new catalog-manager.js (lean CatalogManager; reuses the domains block CSS,
  URL inputs, no DNS checks) + window.addCatalog/deleteCatalog globals.
- config-manager.js routes the general_catalogs subcategory to it.
- system-loader.js loads catalog-manager.js before config-manager.js.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:08:29 +01:00
librelad
c7d5de7a4f fix(webui/forms): stop the custom-select enhancer double-wrapping a dropdown
On the app-details Config tab, switching main tabs re-renders the config form
(showAppDetail → displayConfigForm runs on every tab switch). A re-render race
let the enhancer wrap a <select> that was already enhanced, nesting a second
.custom-select inside the first — so the dropdown showed twice, one on top of
the other (reproduced: CFG_<APP>_BACKUP_STRATEGY / _COMPOSE_FILE ended up with
two nested wrappers). The per-element ENHANCED symbol didn't catch it.

Harden the guard with two DOM-based checks (which survive any symbol loss/race):
skip a <select> that already carries the .custom-select-native class or already
sits inside a .custom-select wrapper — in both shouldEnhance() and the
constructor. Port-manager selects were unaffected (they regenerate their
container each render); this fixes the plain form-select fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 22:05:00 +01:00
librelad
0e33efb9e1 revert(setup): drop the "no apps selected" confirm popup
Removes the zero-app confirmation dialog added earlier today — it wasn't
needed. Zero-app installs proceed straight through again. The other setup
fixes (finalize rolls up per-app results, watcher gates the hand-off on
whole-group success, dev-mode auto-enable skipped during setup handoff)
are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 18:54:05 +01:00
librelad
e3ec256265 fix(setup): stop "setup complete" from lying about a half-done install
Three coupled defects in the first-run wizard flow, all surfacing as
"it said complete but nothing was set up":

1. Zero-app installs sailed through. With no apps ticked, setup was just
   config+finalize, finished in seconds having installed nothing, and
   fast-forwarded to an empty App Center. Add a self-contained in-wizard
   confirm ("Install with no apps?") before submitting. Can't reuse the
   shared confirmation-dialog component — it isn't loaded this early in
   boot — so the dialog is rendered by the wizard itself and mounted on
   <body> to escape the aurora surface's FX-stacking rule.

2. finalize declared success unconditionally. It never inspected the
   per-app install tasks, so a failed app still yielded "your install is
   ready" + a redirect. Pass the setup group id to `setup finalize`; it
   now rolls up the group's app-install task results and logs a clear
   partial/failed verdict. The WebUI completion watcher gates the welcome
   toast + App Center hand-off on the whole group succeeding, not just on
   finalize completing — a failed app now keeps the user on the tasks page
   with an error toast instead of a false all-clear.

3. Setup task count was confusing (banner "of 2" while the page listed 3).
   On dev/git installs the topbar's dev-mode auto-enable raced the wizard's
   own CFG_DEV_MODE write and spawned a second, un-grouped config-update
   task mid-setup. Skip that auto-enable while a setup handoff is active
   (the wizard already persists CFG_DEV_MODE); it runs on the next load if
   still needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 18:31:15 +01:00
librelad
868b0d3c24 fix(webui/setup): darken the setup-progress banner so it reads as a solid card
The setup-in-progress banner floats over arbitrary page content (the aurora
gradient, apps grid, task logs) but filled at rgba(--bg-rgb, 0.45) with no
backdrop blur, so the page bled through and it looked washed-out. Mirror the
.notification treatment already used for floating toasts: a near-solid panel
(0.85) over a frosted backdrop, plus a lift shadow. Stays theme-aware via
--bg-rgb (near-black on dark themes, near-white on light).

Signed-off-by: librelad <librelad@digitalangels.vip>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:43:12 +01:00
librelad
baa1ed4d8d fix(webui/overview): uniform tab rhythm + rounded sub-tab strip corners
- .tabs-list now rounds its own top corners (12px, matching .tab-navigation);
  the first/last .tab-button radii were invisible against the bar's square
  background on the Backups/Migrate/Config sub-tab strips.
- All five fleet tabs share one rhythm under the header divider: a single
  16px gap, no extra inset — .ov-tab-body drops its recessed margin/padding
  box, and the Backups/Migrate sub-tab strips gain the matching gap.
- The Peers list keeps its recessed panel via .peers-body (in-card recipe),
  no longer riding the flattened .ov-tab-body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 21:24:42 +01:00
librelad
1165ec1799 feat(webui/apps): marketplace cards — registry apps merge into the App Center grid
The grid now also reads apps/generated/registry_catalog.json (optional
data: any failure = no registry cards, never a broken grid) and renders
signed-catalog apps that have no local definition as "Available — Add"
cards: indigo Available pill, teal Official trust badge, and an Add button
that dispatches the app_add task (task-actions/router/format wired). Local
definitions always win on a slug collision; registry cards sort last and
are excluded from the Installed view. No detail page until Add lands the
definition — then the card becomes a normal Install card, repainted by the
app_add branch in the task-completion handler without a manual refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-03 21:23:14 +01:00
librelad
ab0822c46b feat(webui/loading): shared boxed spinner loader for page/panel loading states
Pages and panels showed inconsistent loading states: the Backup center
and several admin pages (System, SSH, admin Overview), the Overview
Migrate/Peers panels, the per-app updater section and the backup engine
details modal rendered a bare 'Loading…' text line (updater-empty /
backup-empty-state) with no spinner, while Services/Config/Tasks used a
boxed card + spinner.

Add one shared loader — window.lpLoadingBox(message) + .lp-loading CSS in
the core/loading subsystem (the boxed card + accent spinner the good tabs
already use) — and route those bare-text loaders through it. The system
metric graph keeps its absolute overlay but gains the same spinner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-25 22:06:03 +01:00
librelad
dac824a161 fix(webui/forms): enhance per-app config & port dropdowns with themed select
The custom-select enhancer only matched select.form-control /
select.theme-selector, so dropdowns rendered by the per-app config form
(config-form.js uses form-select / form-input / config-input), the app
tools form, the port-manager grid (port-* classes) and the instance
domain picker stayed as plain native OS dropdowns. The LibrePortal app's
Theme option is one of these.

Add those classes to ENHANCE_CLASSES, give the classless instance domain
select a form-control class, and add a compact button override so the
themed dropdown matches the dense port-manager input metrics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-25 21:18:15 +01:00
librelad
f4c24340b7 fix(webui): lighten faint empty-state messages on dark panels
Empty-state 'no data yet' messages rendered at rgba(text, 0.55) (and one at
text-muted), which is hard to read on the dark recessed panels. Switch them to
the theme's --text-secondary muted token so they stay de-emphasized but legible.

Covers .updater-empty, .updater-detail-empty, .sys-detail-empty, .eo-modal-empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-25 21:07:07 +01:00
librelad
0fbbde1000 style(app-config): wrap each config field in a translucent card
App-config fields rendered as a loose vertical stack with no grouping. Give
each .app-config .form-field the .admin-card glass recipe (var(--card-bg) fill,
faint rgba(text,0.10) hairline border, 12px radius, 16px padding) so each
option reads as a discrete container. Spacing now comes from the .panel-fields
flex gap, so the per-field margin-bottom is dropped to avoid doubling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-18 15:45:57 +01:00
librelad
0bcde854e6 refactor(webui): move fleet Overview under /apps/overview; retire standalone /backup
The fleet Overview area (Overview/Updates/Improvements/Backups/Migrate) now
lives at /apps/overview* instead of /overview*, reflecting that it belongs to
App Center. The Backups tab is therefore /apps/overview/backups, and the old
standalone Backup Center page is removed entirely:

- apps feature owns /apps/overview* (covered by the existing /apps* route); its
  mount() dispatches /apps/overview -> fleet Overview before the grid check.
- _legacyRedirect() rewrites old short URLs so bookmarks/links keep working and
  the address bar shows the canonical path:
    /overview[/tab] -> /apps/overview[/tab]
    /backup[/sub]   -> /apps/overview/backups[/sub]
    /updater, /peers redirects retargeted to /apps/overview*
- Removed the standalone backup feature: components/backup/{index.js,feature.json},
  its manifest entry, the /backup route registrations and the dead handleBackup().
  The BackupPage classes stay — the Overview Backups tab embeds them.
- Repointed every backups/overview link: the admin dashboard's 'Open backup
  center', the app-card 'Open backup center' button + snapshot-overflow link,
  the sidebar Overview entry, the improvements deep-link, and the Migrate
  'go to locations' deep-link.

Also drop the redundant inline Check button from the Security empty state
(same rationale as Improvements: the host auto-scan repopulates it and the
header carries a manual Check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-17 18:40:40 +01:00
librelad
168924757e fix(tasks): reap orphaned running tasks immediately at processor startup
Holding the singleton flock at startup proves no other processor is alive
to heartbeat or complete anything, so every task still marked running is
a corpse from a killed predecessor. Recover them all before the first
dispatch (recoverOrphans now takes an 'all' mode) instead of waiting out
the 60s heartbeat-staleness window — which used to leave a dead task
showing 'running' alongside the genuinely-running next task for a minute
whenever the service was restarted mid-task (e.g. by the deploy chain
during initial setup). The idle-loop pass keeps the stale-only gate.

refactor(dashboard): slim the storage card back to chart + percentage

The disk card was only ever meant to be the donut and the % figure; drop
the Apps/Docker/Other/Free legend rows and signal the deeper view with a
corner expand glyph instead (the System page's chart-expand icon) — the
card already opens /admin/system/storage on click.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 23:39:28 +01:00
librelad
a28eed0729 fix(services): route per-service restart through the task system + CLI
The Services tab restart button POSTed to a backend endpoint that (a)
checked the app's compose path from INSIDE the webui container, where
the host's containers root isn't mounted — so every restart failed with
'Compose file not found' — and (b) queued a raw 'docker compose restart'
that the host task processor would run as the manager user, which can't
talk to the rootless daemon anyway. Errors surfaced via a bare alert().

Per-service restart now follows the exact shape of the whole-app verbs:

- CLI: 'libreportal app restart <app> [service]' — the optional service
  arg makes dockerRestartApp restart just that compose service, via
  dockerCommandRun (right user in rootless mode) from the app dir on the
  host, where the compose file actually lives. Service names validated
  against compose-legal characters before touching a shell line.
- WebUI: the button dispatches a 'service_restart' task action through
  the task router (mutations-via-tasks), runs in the background with the
  standard task toast + link — no page switch — and failures use the
  notification system instead of alert(). Because the task runs host-
  side, restarting the WebUI's own libreportal-service now works too.
- Backend: the mutating restart endpoint and its now-unused helpers are
  removed; service-routes.js is read-only surface (status + log tails).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 23:26:40 +01:00
librelad
c05e4af6f0 refactor(webui): move update status from dashboard banner to a topbar pill
The dashboard carried a persistent, full-width "LibrePortal vX.Y.Z" /
up-to-date strip — passive info occupying prime real estate above the
user's actual content, and out of place next to genuinely actionable
cards. Version/update state is chrome, so it now lives in one persistent
pill in the global topbar (every page), with detail + actions behind the
existing modal.

The pill is calm by default and escalates only when warranted:
  * up to date     -> subtle "✓ Up to date"
  * local/dev      -> neutral version chip ("v0.2.0")
  * checking        -> spinning "Checking…"
  * update waiting  -> accented, pulsing-dot "⟳ Update"
Clicking opens the details modal (unchanged) for the full readout and the
Update now / Check for updates actions.

The dashboard update banner is removed. The network-notifier's banner
stays — it's attention-only (shown solely on a real, actionable network
conflict), which is exactly when a dashboard banner earns its place; its
topbar badge now anchors after the pill, and the dashboard data-loader
re-asserts that banner on mount.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 19:47:41 +01:00
librelad
9582671072 feat(tasks): path-based single-task permalink (/tasks/<cat>/<id>)
The global tasks page still deep-linked a single task with a ?task=<id>
query while the rest of the SPA moved to path-based permalinks
(/app/<name>, /admin/<…>). Bring it in line: the task is now a path
segment, /tasks/<category>/<id>.

Task ids are guaranteed `task_<digits>_<base36>` (isValidTaskId), so the
redundant `task_` prefix is dropped in the URL and restored on read via a
new window.taskPath / window.taskPartsFromPath helper pair (mirrors
appPath/appPartsFromPath). The parser still accepts the legacy ?task=
query and the full-prefixed id, so old links, bookmarks and notifications
keep resolving.

Updated every builder (tasks-manager updateURL + notification url,
task-id link, task-actions, admin config-form, setup-wizard handoff with
its &from=setup flag) and the notification navigation handler / button
text to recognise the path form.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 18:31:18 +01:00
librelad
ab1d335d35 refactor(apps): manage instances on the type's page, not the grid
Replaces the per-instance grid cards + per-card "New instance" action with a
single card per app type and an in-page family switcher — the UX you asked
for (swap between instances via LibrePortal navigation; manage on the page).

- Grid: hide any app declaring INSTANCE_OF (loadApps filter), so there's one
  card per type. A subtle "N instances" chip replaces the old card button.
- App detail: a "family switcher" bar under the title for multi-instance
  types and their instances — a pill per member (base + each instance) that
  path-navigates to that slug's detail (current tab kept), plus "+ Add"
  (existing create modal). When viewing an instance, a "Remove instance"
  action sits in the bar.
- Remove flow: instance_remove task verb (-> existing `libreportal instance
  remove`) with an on-brand confirm modal; lands back on the base type page.

Frontend-only; the instance create/remove backend and CLI are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-05 00:17:30 +01:00
librelad
376610cd11 feat(apps): scoped multi-instance support (run two of an app)
Lets a *multi-instance-capable* app run as several fully isolated instances
on one box (e.g. two Bookstack/WordPress sites, or a "family" + "work"
Nextcloud) — distinct data, DB, subdomain, backups and update cadence.

Design: an instance is just another app. It gets its own slug (<type>_<id>),
its own CFG_<SLUG>_* namespace, deployed dir, DB row, IP/port allocation and
host, so the entire existing pipeline (scan, install, services, routing,
updater, backups) treats it like any app with zero changes. All
instance-specific rewriting is confined to a clone of the type's template;
the shipped template and the core engine are untouched.

Gating: opt-in per app via CFG_<TYPE>_MULTI_INSTANCE=true. Only Bookstack
carries it for now (the validated reference). The other 31 apps are
unaffected — the feature is invisible unless the flag is present.

- scripts/instance/instance_create.sh — clone + re-namespace config, rewrite
  compose identity (container_name / Traefik routers / backup labels) and
  per-app tools, set a hostname-safe subdomain (PORT field 10), then hand off
  to dockerInstallApp. Plus instanceList / instanceRemove.
- libreportal instance create|remove|list — new CLI category; mutations route
  through the task system (no new mutating API endpoint).
- WebUI: "instance of <type>" badge + a "New instance" card action on capable
  apps, and a create modal (name + domain# + subdomain, live host preview)
  that dispatches the standard task. Capability/instance-of read straight off
  the already-exposed app config.

Known follow-ups (documented): flip the flag on more apps after a compose
identity check (Nextcloud next); per-app tools are best-effort isolated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-04 23:34:52 +01:00
librelad
f6f29bf68b feat(webui): match Migrate tab to app-detail tab design
Give the fleet Overview › Migrate tab the same in-content header + sub-tab
styling the per-app detail tabs use:

- Add a .config-title header (icon + title + description) inside the Migrate
  pane, above the Restore/Peers sub-tabs, and hide the generic floating fleet
  header for Migrate (mirrors how Backups already supplies its own heading).
- Make the .tab-button icon↔label gap explicit (flex + 6px gap) so sub-tabs
  render identically whether or not the markup has whitespace between the
  emoji and name spans; align the Backups sub-tab strip gap to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:04:44 +01:00
librelad
9986a8a814 fix(webui): stop rapid clicks selecting text on setup level cards
The Beginner/Advanced experience cards are tap targets, but had no
user-select guard. Rapid clicking — notably the Advanced card's 10-tap
dev-mode unlock — selected the card title/description text as a side
effect. Add user-select: none to .setup-level-card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-02 16:21:39 +01:00
librelad
f1e1330cd8 feat(webui): network-drift notifier (topbar badge + dashboard banner)
Surfaces network_status.json in the WebUI, attention-only: a rose badge
+ dashboard banner appear ONLY when conflicts_found, with a details panel
listing the stranded apps and a 'Heal now' button that runs the heal
through the task pipeline (libreportal system network heal). Re-reads on
task completion via taskRefresh.

Cloned from update-notifier; the badge anchors just after the update
badge so the two coexist in a stable order. New --page-network hue.
Wired into system-loader scripts[], topbar onTopbarReady, and index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:07:54 +01:00
librelad
69bb5532b7 refactor(webui): move Peers out of Admin; harmonize Backups sub-tab strip
- Remove the Admin sidebar Peers entry; /peers and /admin/tools/peers now
  redirect to /overview/migrate/peers (its new home next to cross-host Restore).
- Re-skin the embedded Backups center's sub-tab strip from pills to the per-app
  Config .tabs-list/.tab-button segmented look (full-width bar, accent underline)
  so every nested sub-tab row is consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:46:30 +01:00
librelad
efdbed8e0c fix(webui): embedded backup-center review fixes
- BackupPage task-refresh run-guard is instance-relative now (window.backupPage
  OR window.overviewBackupPage), so the embedded center's own auto-refresh works
  instead of relying on OverviewManager's overlapping coverage.
- _ensureBackupAssets no longer memoizes a rejected promise — a transient
  script-load failure no longer bricks the backup center until reload; the next
  open retries.
- spaClean.loadScript removes the failed <script> element on error so the
  getElementById dedupe can't make a retry resolve without re-fetching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:55:09 +01:00
librelad
dfd4ffa268 feat(webui): drop Updates & Backups top-nav items; fold into App Center
The fleet Overview area (Overview · Updates · Improvements · Backups) and the
backup center now live under App Center, so the standalone top-nav items are
redundant. Top nav is now Dashboard · App Center · Admin · Tasks.

- Remove the Backups and Updates anchors from topbar.html.
- Remove the nav{} blocks from updater/backup feature.json + manifest (so they
  don't resurface when the nav kernel lands).
- Highlight App Center for /overview and /backup; drop the dead /updater branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:10:30 +01:00