405 Commits

Author SHA1 Message Date
librelad
56102ac5c0 feat(updater): P1b — CFG_<APP>_VERSION drives the anchor image tag
Add a `#LIBREPORTAL|<APP>_VERSION_TAG|<current-tag>` sentinel to each app's
anchor (`<slug>-service`) image line, so a `CFG_<APP>_VERSION` config drives
that tag through the existing tagging system — the config becomes the source,
the compose tag is derived (like ports/IPs/domains already are). This is the
"install/pin a specific version" knob.

- Placeholder is the literal current tag (e.g. `31-fpm-alpine`), NOT a `*_DATA`
  token: an unset var leaves the line untouched (inert comment, keeps the real
  tag) and never trips the up_app stale-tag scanner.
- Only the anchor line is tagged (sidecars mariadb/redis/nginx stay
  tracked-by-digest, not user-version-picked). ollama correctly targets
  `ollama/ollama`, not the companion open-webui.
- Untagged anchors normalized to `:latest` (semantic no-op) so they're
  templatable too. The manager (libreportal) is skipped — it updates via its
  release channel, not a docker tag.

32 apps wired. Verified: CFG_NEXTCLOUD_VERSION=32 → `nextcloud:32` (prefix
preserved, idempotent, sidecars untouched); all compose files still valid YAML.
Inert until a version config is set, so no behaviour change on existing installs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 23:13:57 +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
2da888f92d fix(tasks): show LibrePortal logo on system health/network heal task rows
The task-list row only paints the LibrePortal app-logo for commands matched by
isLibrePortalSystemTask's whitelist (appless tasks otherwise get just the type
icon, so arbitrary custom commands don't get a spurious logo). `libreportal
system health/network heal|check` weren't in it — only `system reclaim|image` —
so the heal rows showed a bare function icon instead of the logo like "Check for
Updates". Add health|network to the whitelist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 23:04:12 +01:00
librelad
24fbc36518 fix(health): robust crash-loop detection (RestartCount delta) + task display
Crash-loop detection keyed on `docker ps --filter status=restarting`, but a
backed-off loop sits "exited" between restarts once docker's backoff grows to
tens of seconds — so a slowed loop is missed. Detect via RestartCount CLIMBING
between scans (what a crash loop actually is), unioned with the instantaneous
restarting signal for fast loops. Baseline counts persist in
.health_restart_counts, written only by the throttled check so the heal's
re-scans don't disturb the delta.

Also give the system_health_heal / system_network_heal tasks proper display
(they fell through to the raw command + generic ⚙️): friendly titles in
formatCommandForUser, type icons (🩺 / 🌐) in getTaskTypeIcon, and action
labels in formatActionTitle — so they read as "LibrePortal - Repair Control
Plane" etc. with an icon, like install tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 22:53:54 +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
71a02374d1 perf(backup): raw-data stats, dedupe repo stats, SSH connection reuse
Follow-up to the backup-refresh throttle/dedupe, cutting the cost of the
remote pulls that do still happen.

* restic stats now runs in --mode raw-data (restic_check.sh). The default
  restore-size mode walks every snapshot's tree to sum logical file sizes —
  the slowest restic op — just to fill a size readout. raw-data reads the
  index only and reports the repository's actual deduplicated on-disk size,
  which is exactly what the dashboard already labels "deduplicated,
  encrypted". raw-data omits total_file_count, so the per-location card now
  shows that location's snapshot count (already loaded client-side, and more
  useful for a backup repo) instead of a file count.

* engineLocationStats now shares the same per-refresh memoiser as
  engineSnapshotsJson (engine_dispatch.sh). Both the locations and dashboard
  generators call it per location, so repo stats went from two restic calls
  per location per refresh to one. Factored the cache into _engineCachedPull.

* SSH connection reuse for SFTP locations (backup_ssh.sh): ControlMaster=auto
  with a self-reaping ControlPersist master, so the several restic
  subprocesses a refresh/backup spawns against one location share a single
  authenticated connection instead of a fresh handshake each — the dominant
  per-call cost on a high-latency link. Toggle via CFG_BACKUP_SSH_MULTIPLEX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 21:56:55 +01:00
librelad
d794d4031a fix(overview): raise specificity so full-width detail actually applies
The prior .ov-row override tied .updater-row on specificity, and updater.css
loads later, so `display:grid` won and the detail stayed boxed. Use the
two-class selector .updater-row.ov-row to win regardless of load order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:39:40 +01:00
librelad
97af9474cc fix(overview): expanded app detail spans full row width
The fleet Updates expander row inherited .updater-row's 3-column grid, so an
open row's detail body (CVEs, recovery, history) landed in a narrow right-hand
`auto` column with dead space to its left. Make .ov-row a block: the head stacks
on top and the detail body uses the full width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:35:28 +01:00
librelad
7ab60a801d feat(updater): scrollable, worst-first CVE lists so long lists stay compact
An app with many CVEs (e.g. Trivy's 28) rendered every row full-height, pushing
the page down. Extract one renderCveList() shared by the standalone Security tab
and the per-app expander: sort worst-severity-first, and once past ~6 rows cap
the height (260px) with an internal scroll + themed scrollbar and a bottom fade
hint. Add a count pill to the expander's "Security" heading. Per-app sections
stay stacked and independently collapsible as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:28:07 +01:00
librelad
a079050108 fix(trivy): scan local images via the mounted socket, not a registry pull
The client scan resolved images by pulling from Docker Hub, which fails on an
offline/privacy box ("index.docker.io ... network is unreachable") — yet every
installed app's image is already present locally. Point Trivy at the docker
socket that's already bind-mounted into the container (--image-src docker, plus
DOCKER_HOST=unix:///run/user/<uid>/docker.sock for rootless, derived from the
install user — rooted's default path is found automatically). Scans local
images with zero network. Verified: aquasec/trivy:latest -> 28 CVEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:59:59 +01:00
librelad
8aa4c175ae fix(trivy): scan in client mode against the running server
Standalone `docker exec trivy-service trivy image ...` opens the vuln-DB cache
the long-lived server already holds, failing with "cache may be in use by
another process: timeout" — so every scan silently returned [] (apps: 0 even on
vulnerable images). Point the exec'd client at the server (--server
http://localhost:4954, the container's fixed --listen port); the server owns the
DB, the client just submits the image. Verified against alpine:3.10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:51:49 +01:00
librelad
abd8e0b68b feat(updater): surface Trivy CVE scanner state (installed / DB updating / ready)
Trivy runs as a server whose vulnerability DB downloads on first boot; until it
lands no scan can produce results. Previously the updater generator wrote an
empty-but-valid cves.json the moment the file was missing, so installing Trivy
painted a green "no known vulnerabilities" all-clear that was actually a lie —
the DB hadn't even downloaded, and the Updates/Security view gave no signal.

Add an honest scanner state the WebUI branches on:
- containers/trivy/scripts/trivy_scan.sh — trivyScannerState (absent |
  db_updating | ready) via `trivy version -f json`, trivyDbUpdatedAt, and
  trivyScanImageCves (per-image scan normalized to {id,severity,package,
  installed,fixed_in,url}, deduped). All degrade safely on error.
- webui_updater_scan.sh — stamp cves.json with scanner.state; only run real
  per-image scans once the DB is ready. Always rewritten so state tracks live.
- updater-page.js — Security tab shows a loading box while the DB updates, an
  install nudge when absent, and the genuine 🎉 only when ready+empty; Overview
  CVE card sub + hint reflect the state.
- overview-manager.js — fleet Security row surfaces the "building CVE database"
  pending state instead of silently omitting.
- function_manifest.sh — regenerated for the new trivy_scan.sh functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:47:07 +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
0651dee155 fix(webui/overview): drop zero-count stats from health hero when nothing is tracked
With no apps tracked the sub line read '0 apps tracked · 0/0 backed up ·
last scan …' — pure noise. Show only the scan time in that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-12 22:28:18 +01:00
librelad
41786edd36 fix(backup/webui): show the retention "Backup style" divider only for Custom
.backup-retention-preset-block carried a permanent border-bottom (+ padding),
but the Keep-* fields it separates the dropdown from are hidden for every
preset except Custom — so on Self-hosting/etc. it was an orphaned line with
empty space beneath it. Moved the divider + spacing onto an .is-custom state
that applyVisibility() toggles alongside the fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:25:03 +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
6bed124984 fix(backup/webui): hold Refresh spin ~450ms so instant refetches still pulse
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:17:08 +01:00
librelad
6912c36aaf fix(backup/webui): render the Backups Configuration form (dup-id collision) + refresh feedback
The Backups tab's Configuration sub-tab showed only the warning banner and
an orphaned divider -- the actual config form (retention presets, engine,
keep-policies, Save/Reset) never appeared.

Root cause: a duplicate id. The Fleet Overview, the app grid and per-app
detail all render the shared apps-unified-layout, which keeps a hidden
#config-tab > #config-section in the DOM. The Backups Configuration sub-tab
created its OWN #config-section, and configManager.renderConfig() resolved
the target with getElementById('config-section') -- which returns the FIRST
(hidden) node in document order. So the 21KB form painted into the invisible
per-app container while the backup tab's container stayed empty. Because the
/backup routes now redirect into /apps/overview/backups, this fired every
time -- the form had effectively never been visible.

Fix:
- renderConfig(category, target) now accepts an explicit container (element
  or id) and falls back to #config-section; the error-retry button re-targets
  via configSection.id.
- The Backups Configuration sub-tab uses a unique id (#backup-config-section)
  and passes it to renderConfig; retention-preset and engine-details
  selectors are scoped to it.

Also: the Refresh button now spins + locks while refreshAll()+render() run,
so the click has visible feedback instead of appearing to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:13:06 +01:00
librelad
b33764f259 Merge claude/2 2026-07-07 20:47:21 +01:00
librelad
6148a6597e fix(backup/webui): wrap the global Backups tab list in a themed box
The global Backups tab rendered its snapshot list (and the "No backups
yet." empty state) as a bare flex column directly on the tab panel, so
the list section had no visual container -- unlike the Dashboard tab's
cards and the per-app Backups tab's recessed panel. Wrap it in a
.backup-snapshot-panel box (translucent fill + hairline border, matching
.backup-card) so the section reads as a bounded surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:47:03 +01:00
librelad
73a93cf145 fix(webui/backups): sync fleet Backups sub-tab URLs (/apps/overview/backups/<sub>)
The embedded backup center deep-links *in* fine (mountBackupCenter parses the
sub-segment), but clicking a sub-tab never wrote the URL: BackupPage disables
pushTabToUrl() when embedded and nothing on the fleet side stepped in, so the
address bar stayed stale and the sub-tabs weren't shareable/deep-linkable —
unlike the sibling Migrate sub-tabs.

Give BackupPage an onTabChange hook fired on every switch; the OverviewManager
supplies it to replaceState /apps/overview/backups/<sub> (replaceState, matching
Migrate, so the on-entry deep-link re-sync can't stack a duplicate history
entry). Standalone /backup keeps using pushTabToUrl() unchanged.

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

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-07 20:46:45 +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
e0e13f7566 fix(webui/config): proper acronym/brand casing in config section names
formatSubcategoryName title-cased naively, so section headers read "Ssh",
"Dns", "Libreportal". Add a whole-word acronym/brand map so they render
professionally as "SSH", "DNS", "LibrePortal" (also covers VPN/API/IP/UI/URL/
HTTP(S)/TLS/SSL/WebUI for future files). Composes with stripCategoryPrefix,
which runs after and is case-insensitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 22:47:10 +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
8e8856f5d0 Merge claude/1 2026-07-06 22:33:08 +01:00
librelad
9b32444475 feat(config): per-file icons before the config-section names
Config sections (Basic, Mail, DNS, Firewall, …) rendered as a bare name. Add a
small data-driven icon before each: config files now carry a `# @icon <emoji>`
directive in their header, the config generator surfaces it as the subcategory's
`icon`, and the config editor prepends it to the section <h3> (covers every
render path — regular/master/toggle/domains/catalogs/whitelist — since they all
drop displaySubcategory straight into the heading).

- webui_generate_configs.sh: parse `# @icon` (first 10 lines) → emit "icon".
- config-manager.js: prepend subcategoryData.icon to the section title.
- configs/**: added an @icon to all 23 base config files (backup/general/network/
  security/webui). The live install's config files get the same directive + a
  configs.json regen so existing installs show the icons too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 22:33:08 +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
c302ee2318 feat(containers/navidrome): use the real vinyl-record logo icon
Replace the placeholder equalizer with a faithful SVG recreation of Navidrome's
blue vinyl-record mark (the project ships only PNG logos, so this is a clean,
scalable redraw): grooved disc, white centre label + spindle hole, and the two
detached groove ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 20:06:13 +01:00
librelad
9f6ebfa37a feat(containers): add Navidrome music streaming server
New self-hosted app following the standard container layout (config +
docker-compose template + icon), modelled on the Jellyfin media app:

- navidrome.config — media-category metadata, single webui port (random:4533),
  served on the `music` subdomain via Traefik.
- docker-compose.yml — deluan/navidrome:latest with a ./data volume (DB/config/
  cache) and a read-only ./music library mount; declares its SQLite DB
  (libreportal.backup.db) + data dir for live, zero-downtime backups; gluetun
  markers included so it can optionally be VPN-routed.
- navidrome.svg — brand-blue equalizer icon.

Auto-discovered by the container scan / apps.json generator, so it appears in
the App Center as an installable Media app. Like Jellyfin, the first admin
account is created in Navidrome's own web UI on first run, so no auth tools are
needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 19:38:02 +01:00
librelad
fb8ebd7924 fix(webui/overview): paint Backups header immediately, confine spinner to content
Switching to the fleet Backups tab blanked the whole pane to a bare, headerless
lpLoadingBox() while the (script-heavy) backup center loaded, so it read as the
entire section reloading with no indication of where you'd landed — then the
header + content appeared at once when the fetch resolved.

Render the shared .ov-tab-header (💾 Backups + description + divider) up front and
drop the spinner into an .ov-tab-body beneath it, matching the per-app detail
tabs' header-first idiom (and the recent Admin board fix). The header stays put
and only the content area swaps in once BackupPage mounts.

The Migrate tab already does this — mountMigrate() injects its header and the
sub-tab strip is static, so its spinner is already confined to the Restore/Peers
sub-panel — so no change was needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 19:15:27 +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
e168496824 perf(gluetun): throttle the provider-list fetch off the WebUI-update hot path
webuiLibrePortalUpdate runs the per-app refresh hooks on every update (the
task processor fires it repeatedly), and gluetun's hook silently pulls a
~7MB servers.json from GitHub each time. The hook's stdout is captured by
`result=$($_hook)`, so on a slow link the update just sits with no output
right after "Generated apps-tools.json..." — it reads as a freeze.

- Throttle the fetch to once per CFG_GLUETUN_PROVIDERS_REFRESH_HOURS (24h
  default; 0 = manual-only). Direct callers (Tools refresh, install hook)
  pass GLUETUN_PROVIDERS_FORCE=1 to still get a guaranteed pull.
- curl --compressed (this JSON gzips ~7x) + --connect-timeout 15 so a slow
  or dead link shrinks/fails fast instead of stalling every update.
- Print "Refreshing <app> WebUI data..." from the updater loop (outside the
  output capture) so the step is visible instead of looking hung.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:58:38 +01:00
librelad
6dd729d0ac fix(webui/admin): paint page header immediately, confine spinner to content
The Admin Dashboard (OverviewPage) and Admin System (SystemPage) boards each
blanked the whole content pane to a bare headerless lpLoadingBox() on init(),
then rendered the .page-header + panel only once data arrived. So clicking
either tab flashed a full-pane spinner with no indication of which page you'd
landed on, then everything appeared at once.

Extract the page header into a _headerHtml() helper on each page and render it
up front in both the loading state and render(). Now navigating to a board
paints its header (breadcrumb + title + description) instantly and the spinner
is confined to the .admin-panel beneath it — the header stays put and only the
content swaps in when the fetch resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-05 20:33:21 +01:00
librelad
f4913422fb refactor(catalog): rename the 'marketplace' app to 'libreportal_catalog'
'Marketplace' read as a place to buy things; this is a free, self-hosted app
catalog, so rename the app to LibrePortal Catalog. Slug is libreportal_catalog
(underscore — the slug becomes a CFG_<SLUG>_ prefix and a bash identifier via
declare "${app_name}=i"; a dash would break install). Docker-facing names use
dashes (libreportal-catalog-service / hostname libreportal-catalog), declared
explicitly in the PORT config + compose, not derived from the slug.

- containers/marketplace/ -> containers/libreportal_catalog/ (+ .config, .svg, hook file)
- CFG_MARKETPLACE_* -> CFG_LIBREPORTAL_CATALOG_*, APP_NAME + TITLE + PORT_1 updated
- install hook fn marketplace_install_post_setup -> libreportal_catalog_install_post_setup
- served browse site reworded Marketplace -> Catalog, icon refs updated
- regenerated function_manifest.sh (autoload stub now points at the new file)

Not installed yet, so there is no live config/container/volume to migrate — the
cheapest moment to rename. The client-side registry ('View full page on the
marketplace') wording is a separate subsystem and left unchanged for now.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:25:21 +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
ae39bba93f feat(marketplace/site): hash-route the views (#view=submit) — shareable + bookmarkable
The pins now update the hash so How it works / Submit / Submissions are
deep-linkable; the #<slug> app-focus link still works. One parseHash()
distinguishes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 23:14:35 +01:00
librelad
01cd6c3f9d feat(marketplace/site): How it works, Submit an app + Submissions views
Turns the site into a small multi-view app. Three pinned nav buttons above
the search (App Center pin style) switch the main pane:
- How it works: the former footer text, expanded into cards (add an app,
  point your box here, how trust works, run your own).
- Submit an app: the PR-based flow (fork → drop-in app folder → PR → reviewed
  + signed → published with a community badge + your name). No uploads/accounts.
- Submissions: renders a static submissions.json (operator-published, like the
  catalog) — author names, status badges (pending/review/merged), PR links,
  a pending-count badge on the pin, graceful empty/loading states.

Category/search return to browse; #<slug> deep-link still focuses one app.
REPO_URL is overridable via <meta name=lp-repo>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 23:10:21 +01:00
librelad
855d698486 feat(marketplace/site): Featured sidebar row + match App Center font/size/icon/bg
- Add a Featured sidebar row (gold star, publisher-curated meta.featured),
  filters to featured apps; only shown when the catalog has any.
- Exact App Center font stack + 16px category text (was system-ui/14px).
- 'All' (and Featured) render as inline currentColor glyphs so the All icon
  is white like the WebUI — all.svg uses currentColor and rendered dark as an
  <img>; the fixed-blue category icons stay <img>.
- Apply --sidebar-bg to the sidebar itself too (double layer, matching the App
  Center) so the sidebar tint matches instead of showing more aurora through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:50:05 +01:00
librelad
26e099c25f fix(marketplace/site): define the sidebar tokens so hover + separators match
--surface-hover and --sidebar-border weren't defined in the site's :root (and
--sidebar-bg had a made-up value), so the category hover/active background and
the row separators resolved to nothing — the sidebar looked flat vs the App
Center. Set them to the exact nebula values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:40:07 +01:00
librelad
3be0e340ea fix(marketplace/site): keep the empty focus bar hidden (class display vs [hidden])
.focusbar's display:flex was overriding the hidden attribute, leaving a thin
empty bar under the status strip when no app was focused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:27:57 +01:00
librelad
c0133cd437 feat(marketplace): match the App Center sidebar + simplify the app name/blurb
Website sidebar now mirrors the App Center (sidebar.css): a 220px full-height
column, full-width category rows with bottom-border separators and the real
per-category icons (bundled into the site by the install hook from the live
frontend), and the same search box — instead of the rounded pills + generic
circles + count badges it had. Marketplace app copy shortened to peer style:
description 'App Catalog & Registry', one-sentence long description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:23:28 +01:00
librelad
cc36d86e57 feat(webui/apps): registry Add opens a detail modal with a marketplace link
Replaces the extra on-card marketplace tags (the separate Official trust
badge) with a richer, more elegant flow: a registry card keeps a single
'Available' pill, and clicking Add (or the card) opens a modal outlining the
app — full description, publisher + trust, version, the add command, and a
'View full page on the marketplace ↗' link to the app's page on the source
it came from — with the actual add as the confirm. Gives a beat to review a
community app before pulling it in.

- webui_registry_scan.sh re-emits source{base,channel}; loadApps stashes it
  (window.registryCatalogSource) + carries per-app version.
- openRegistryDetails() builds the eo-modal; addRegistryApp() is now just the
  task dispatch (the modal's confirm / a fallback).
- The marketplace website supports a #<slug> deep-link (focus one app, with a
  'Show all' bar), so the modal's link lands on that app's page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-04 22:04:46 +01:00