588 Commits

Author SHA1 Message Date
librelad
e793c7a0e4 Merge claude/1 2026-05-27 13:26:49 +01:00
librelad
4d7027258d feat(app): Wave B + C — collapse 28 per-app installers onto generic driver
Finishes the installApp refactor started in d941f59 (Wave A). Every app
whose <app>.sh was either pure boilerplate (Wave B) or boilerplate +
small custom logic (Wave C) now routes through the generic driver in
scripts/app/install/app_install.sh; bespoke logic moved to declarative
hooks in containers/<app>/scripts/<app>_install_hooks.sh.

Net: ~4,000 lines of duplicated 10-step sequence gone. From 31 per-app
.sh files (pre-Wave-A) down to 2 intentional keepers.

DELETED outright (pure boilerplate — driver replaces them identically):
  jellyfin, mastodon, focalboard, ipinfo, speedtest, dashy, invidious,
  nextcloud, ollama, vaultwarden, pihole

DELETED + hook-extracted (small bespoke step preserved in a hook):
  bookstack, moneyapp, owncloud, trilium, searxng, gitea, headscale,
  unbound, prometheus, grafana, gluetun, wireguard, jitsimeet, authelia,
  traefik, adguard, onlyoffice

KEPT (intentional special cases):
  crowdsec      — host-app pattern (no docker compose, runs as apt+
                   systemd via installCrowdsecHost; uninstall/stop/
                   restart hooks already live in this file and are
                   invoked by dockerUninstall/Stop/RestartApp directly).
  libreportal   — WebUI bootstrap. Pre-compose image build + post-install
                   webuiLibrePortalUpdate + bootstrap-time suppression of
                   menuShowFinalMessages don't fit the generic flow.

Driver change — scripts/app/install/app_install.sh:
  Moved monitoringToggleAppConfig "$app_name" "docker-compose.yml" from
  the post-start integrations block into the install body at post-compose
  (right after dockerComposeSetupFile, before docker-compose up). The
  toggle edits the compose file on disk — running it after start meant
  the container had already been brought up with the unmodified compose,
  so the metrics endpoint wouldn't reflect CFG_<APP>_MONITORING until
  the next restart. Matches the original ordering in every per-app .sh
  that used to call it inline.

Hook surface (declare-f-gated, silent no-op when absent):
  <slug>_install_pre              before any install work
  <slug>_install_post_setup       after dockerConfigSetupToContainer
  <slug>_install_post_compose     after dockerComposeSetupFile (+ the
                                  shared monitoring toggle on the compose)
  <slug>_install_post_start       after dockerComposeUpdateAndStartApp
  <slug>_install_message_data     echoes extra argv for menuShowFinalMessages
  <slug>_install_post             very last thing, after the final message
  + the existing _uninstall_pre/_post, _stop_post, _restart_post

Notable extractions:
  bookstack  — _install_post_start: probe :PORT_1/login until 200/302,
               then `bookstack:create-admin` inside the container with
               CFG_BOOKSTACK_ADMIN_{EMAIL,PASSWORD}; falls back to the
               seeded admin@admin.com on timeout.
  adguard    — _install_post_start drives the wizard's HTTP API
               (POST /control/install/configure) so the admin doesn't
               click through five pages, then pins the admin bind back
               to 0.0.0.0:3000 (matches the compose mapping) and health
               checks. _install_message_data echoes user/password to
               menuShowFinalMessages.
  authelia   — _install_pre requirements; _install_post_compose copies
               configuration.yml + users_database.yml, substitutes
               theme/domain/host, generates JWT/session/storage secrets,
               toggles monitoring on configuration.yml; _install_post_start
               argon2-hashes the admin password via the container, writes
               users_database.yml, restarts; _install_post echoes creds.
  traefik    — _install_pre prompts for the LE email if CFG_TRAEFIK_EMAIL
               is unset; _install_post_compose copies static + dynamic
               configs, wires CFG_TRAEFIK_DASHBOARD_ACCESS (local-only /
               domain-only / public), toggles monitoring on traefik.yml,
               then traefikUpdateWhitelist + traefikSetupLoginCredentials.
  wireguard  — _install_pre host-conflict guard (/etc/wireguard/params);
               _install_post_compose persists CFG_WIREGUARD_SUBNET,
               resolves WG_HOST (domain+traefik → host_setup, else IP),
               runs runAppCfg wireguard-ip-forward; _install_post_start
               restarts after wg-easy installs its iptables rules.
  jitsimeet  — _install_post_setup downloads the tagged release zip from
               GitHub; _install_post_compose mass-edits the .env and runs
               gen-passwords.sh; _install_post_start rewrites nginx
               default site to usedport1/2 + restart.
  prometheus — _install_post_compose seeds prometheus.yml under
               $containers_dir/prometheus/prometheus/; _install_post_start
               sets 0777 on storage dirs so the container TSDB can write
               regardless of host UID mapping.
  grafana    — _install_pre requirements; _install_post_start 0777 on
               grafana_storage.
  gluetun    — _install_post_start refreshes the provider snapshot,
               reattaches every routed app (the netns container ID is
               stale after gluetun gets recreated), then prompts to
               onboard any existing apps.
  + the smaller bookstack-shape extractions for owncloud (version scrape),
    trilium / searxng (wait-for-first-boot-config), gitea (Prometheus
    bearer token sync), headscale / unbound (config copy), moneyapp
    (Auth.js AUTH_URL), onlyoffice (compose-resolved user/pass into the
    final message).

Manifest + arrays regenerated. Verified end-to-end:
  - bash -n on every hook file + the driver: clean
  - Each hook file sources cleanly in a subshell, exposes only the
    intended functions, flagged lazy-loadable (not eager)
  - Smoke-stubbed install run for jellyfin (pure), nextcloud (pure),
    bookstack (hooked), crowdsec (kept): correct dispatch in all cases —
    deleted apps route to installApp, kept apps still hit their real
    function

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 13:26:49 +01:00
librelad
d1d64d12a9 Merge branch 'claude/2' 2026-05-27 13:24:31 +01:00
librelad
9d5d0103b6 fix(routing): _previewUrl uses port.subdomain, not the retired HOST_NAME
routing-manager.js read CFG_<APP>_HOST_NAME for its preview URL, but that
key was retired by the per-port subdomain refactor (2e4f420, 2026-05-22)
and no .config defines it anymore. The lookup always returned undefined,
so even with a configured domain the preview fell through to the
`<your-domain>` placeholder instead of showing the real host.

Now derives the preview from the port's own subdomain (parts[10] of
the 12-col PORT row), matching the canonical host_setup rule in
scripts/network/variables/variables_init_app.sh:
  @ / root      -> apex (`https://<domain>`)
  set           -> `https://<sub>.<domain>`
  empty         -> `https://<app>.<domain>`

Also adds `subdomain` to the port object emitted by _collectPorts so
this and any future per-row consumer can read it.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 13:23:47 +01:00
librelad
e46c619d82 Merge claude/2 2026-05-27 01:43:08 +01:00
librelad
d941f59388 feat(app): generic installApp driver + dispatcher fallback (Wave A)
The 31 containers/<app>/<app>.sh files each defined install<App>() with
the SAME 10-step sequence — ~4,000 lines of duplicated boilerplate.
Replaces all that with one generic driver + hook surface.

scripts/app/install/app_install.sh:
  installApp <slug> [config_variables]
    — Dispatches on $<slug> (c/u/s/r/i) the same way the per-app .sh
      files did. Same convention; dockerInstallApp's existing
      `declare $app=i` callsite needs no change.
    — Runs the standard sequence: dockerConfigSetupToContainer →
      dockerComposeSetupFile → optional .env copy → fixPermissions →
      dockerComposeUpdateAndStartApp → standard post-install steps
      (appUpdateSpecifics, setupHeadscale, databaseInstallApp,
      webuiContainerSetup, monitoring registration) → final message.
    — Hooks (all declare-f-gated, silent no-op when absent):
        <slug>_install_pre / _post_setup / _post_compose / _post_start
        <slug>_install_message_data   (echoes extra args for menu)
        <slug>_install_post
        <slug>_uninstall_pre / _post
        <slug>_stop_post
        <slug>_restart_post
      Hooks live in containers/<app>/tools/<app>_tools.sh (auto-sourced
      per the modular-per-app-tools convention).

function_install_app.sh:
  When no install<App>() function exists, fall through to
  `installApp <app_name>` instead of erroring. So an app with no .sh
  at all becomes a zero-byte addition — drop in <app>.config +
  docker-compose.yml + <app>.svg, done.

containers/linkding/linkding.sh:
  Deleted (canary). Linkding's body was 100% standard sequence;
  fallback handles it identically. Smoke-tested with stubbed helpers
  — dispatcher fires, generic runs full flow, monitoring integration
  + final-message hook plumbing all intact.

Wave B (next): delete the .sh for every other 'pure-boilerplate' app
(~15 candidates per the survey). Wave C: extract custom logic from
the 7 fat apps into hooks before deleting their .sh.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:43:08 +01:00
librelad
6f2d7ca0dc Merge claude/1 2026-05-27 01:40:06 +01:00
librelad
e57d42ddf6 refactor(webui): path-based URLs for app tabs + config sub-tabs
The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:

  - `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
    `/admin/config/network`) and be `/app/adguard/tasks`.
  - The config sub-tab (general / advanced / features / network / …)
    had no URL representation at all — `showTab` was a pure visual
    swap with no history push, so refreshing a deep config sub-tab
    sent the user back to the default first category.

New URL shape:

  /app/<name>                          → config tab, default sub-tab
  /app/<name>/<tab>                    → non-config main tab (tasks, backups, …)
  /app/<name>/config/<category>        → config tab + specific sub-tab
  …?task=<id>                          → optional deep-link to a single task

Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:

  window.appPath(name, tab, sub, taskId) → URL
  window.appPartsFromPath(pathname)      → { app, tab, sub }

Every URL constructor in the WebUI was replaced with `window.appPath`:

  spa.js                               — handleAppDetail back-compat redirect
  app-tabbed-manager.js                — getTabFromURL + new getConfigSubFromURL
                                          (path first, ?tab= fallback for legacy)
                                          updateURL + updateApp use appPath
                                          the inline task-deep-link constructor
  apps-manager.js                      — showAppDetail + showAppDetailWithConfig
                                          showTab now pushes /app/<n>/config/<sub>
                                          renderAppDetail picks the sub-tab out of
                                          the URL on first load
                                          4 fallback task-URL constructors
  tasks-manager.js                     — completion-notification URL
  task-actions.js                      — start-notification URL
  notifications.js                     — 2 task deep-link URLs

Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.

Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:40:05 +01:00
librelad
92e585deba Merge claude/1 2026-05-27 01:26:27 +01:00
librelad
e72d3d278d style(backup): shorten the BACKUP_STRATEGY tooltip to one practical sentence
The advanced field was carrying a four-clause explanation walking through
"quiesce", "live dump", "stop fallback", and the live-option eligibility
rules — useful background for engineers, overwhelming as a hover tooltip
for the typical user picking from the dropdown.

New tooltip:
  "How the app is prepared before each backup. Automatic is recommended;
   pick Live only if you need zero downtime."

Two lines, plain language, points at the right default. The dropdown's
own labels (Automatic (recommended) / Stop→snapshot→start / Pause→snapshot
→unpause / Live — no downtime) carry the technical specifics; the
tooltip no longer has to.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:26:27 +01:00
librelad
c615820c35 Merge claude/2 2026-05-27 01:23:55 +01:00
librelad
1e9e65225b fix(gluetun): wrap rm -f on dockerinstall-owned tempfiles in runFileOp
gluetun_providers.sh writes its working files ($raw, $headers) next to
$output_file, which lives at
  containers_dir/libreportal/frontend/data/apps/generated/gluetun-providers.json
— dockerinstall-owned in rootless. The five rm -f calls on those paths
were unwrapped, so the manager running the script (e.g. from the
task processor) would get Permission denied — same class as the
updateConfigOption sed -i bug that was just fixed.

$tmp comes from mktemp (/tmp), so the rm -f for it stays unwrapped.

Audit context: this was the only remaining raw filesystem op against
container-tree paths in any containers/*/*.sh. The rest of the
container .sh files are clean — every sed -i / chmod / chown / cp / mv
is already routed through runFileOp or runFileWrite, and the
per-app install bodies delegate fs work to high-level helpers
(dockerConfigSetupToContainer, copyResource, dockerComposeSetupFile)
which themselves use the wrappers.

Hooks (<app>_migrate_pre/_post, restoreAppRunHook pre/post) are
present in the framework but unused by any app today — that's by
design (opt-in per-app). If a future app needs federation-key rotation
post-migrate, or a hostname rewrite that the generic URL-rewrite
layer doesn't cover, those slots are ready.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:23:55 +01:00
librelad
cd30d90a81 Merge claude/1 2026-05-27 01:16:43 +01:00
librelad
01a125db55 style(notif): unify task notifications + drop the App:/System: prefix
Started, completed, and failed task toasts were rendered by three
different code paths producing three different layouts:

  • task-actions.js executeTask              "App: AdGuard\n…task started!"   (with type emoji)
  • task-actions.js executeTaskMonitoring    "App: AdGuard\n…task started!"   (without type emoji) — dead code
  • tasks-manager.js createAndExecuteTask    "Task created: install adguard"  (raw shape) — dead code
  • tasks-manager.js complete/fail notif     "App: AdGuard\n…task completed!" (with type emoji)

…plus the system-task path was reading the literal `'system'` slug into
the toast: "App: System / Config_update task started!" with a 404'd
/icons/apps/system.svg (the same bug renderTaskIcons had on the row
itself, fixed in 59ee92b).

Three changes:

1. Drop the "App: " / "System: " label prefix on every toast. The bold
   line is now just the subject name (the row's title still carries the
   semantic with its leading App-or-LibrePortal icon). Three tasks of
   the same app no longer read like a column heading repeated.

2. Treat `appName === 'system'` as the LibrePortal sentinel everywhere
   the toast renders — displayName resolves to "LibrePortal" and the
   app-icon slot loads /icons/libreportal.svg. Mirrors the row-icon
   fix in 59ee92b. The completion-path `isSystemTask` check now also
   accepts `appName === 'system'` in addition to `setup-*` types.

3. Delete the dead code that produced the inconsistent shapes:
   - executeTaskMonitoring in task-actions.js (no callers anywhere)
   - window.createAndExecuteTask in tasks-manager.js (no callers; only
     surviving reference was a stale comment in app-tabbed-manager.js,
     updated to point at executeTask instead)

Net: every task toast in the WebUI now follows the same three-slot
layout — [type emoji] [app/LibrePortal logo] <strong>Name</strong> +
"Action task started/completed/failed/cancelled!".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:16:43 +01:00
librelad
3e63dfbdbb Merge claude/2 2026-05-27 01:16:02 +01:00
librelad
56d2f8105c fix(config): updateConfigOption uses the right de-sudo helper for the file's tree
Symptom (reported on adguard install):
  sed: couldn't open temporary file /libreportal-containers/adguard/sedZaGX2H:
       Permission denied
  ✗ Error Updated CFG_ADGUARD_BACKUP to true
  ! Notice Non-interactive mode: aborting on error.

Root cause: updateConfigOption ran a raw `sed -i` regardless of which tree
the config file sits in. Works fine for /libreportal-system/configs/*
(manager-owned) but breaks on /libreportal-containers/<app>/<app>.config
(dockerinstall-owned in rootless mode) — sed -i writes its temp file next
to the target, inheriting the directory's perms, and the manager can't
write inside dockerinstall dirs. EVERY app installer that mutates a
CFG_<APP>_* value (the autogenerated random password, BACKUP toggle,
PORT override, etc.) goes through this function, so this was a latent
ticking bomb across all containers.

Fix: pick the helper based on path —
  -  under $containers_dir  → runFileOp  (escalates to
    dockerinstall in rootless, runs as manager in rooted)
  - otherwise                            → runInstallOp (always manager)
Read paths (grep / source) stay unwrapped — both dirs are world-readable;
only the write needs the privilege swap.

Net: no more 'Permission denied' on app installs; the de-sudo pattern is
now respected end-to-end for CFG writes.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:16:02 +01:00
librelad
3d7f5fbdeb Merge claude/2 2026-05-27 01:05:44 +01:00
librelad
14bc0c3386 ui(backup): tile-click → Back-up checklist modal; LibrePortal icon on System tile; 2-up grid
Reshape the dashboard's Backup status grid into a click-to-pick UI:

- Removed the inline Back-up / Restore buttons from the System config
  tile. Same shape as an app tile now; LibrePortal app icon instead of
  the server-stack glyph.
- Grid is 2 columns (was auto-fill min 220px). Tiles are wider, read
  better, and the System tile no longer needs to span a full row to fit
  inline buttons.
- Click any tile (System or app) → opens a new "Back up" modal:
    * System config first (key=__system__, LibrePortal icon)
    * Every installed app, alphabetical
    * Checkbox per row + 'Select all' / 'Clear' shortcuts
    * The tile clicked is pre-ticked
- Confirm queues backup tasks:
    * Everything ticked  → single `libreportal backup all` (which also
      runs `backup system`) — one task instead of N
    * Subset            → one task per ticked item (`backup system`
      and/or `backup app create <slug>`)

Restore for System config used to live on the dashboard's inline
'Restore' button. It's now reachable via the Backups tab — system
snapshots appear in the snapshot list with the standard per-row
Restore action — same path apps already use. No new UI required;
just one fewer dashboard button.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:05:44 +01:00
librelad
c49007d6e0 Merge claude/1 2026-05-27 01:04:03 +01:00
librelad
ae790853bf chore(dashboard): drop the redundant "Admin overview" link
The user Dashboard carried a small chevron link "Admin overview →" just
above the installed-apps grid. The topbar already has a top-level "Admin"
nav-item (topbar.html:34) that goes to the same /admin route. The
dashboard link was a redundant second entry point with no extra value;
removing it tightens the dashboard layout without losing navigation.

Drops:
  - dashboard-content.html: the <a class="dashboard-admin-link"> block
  - admin.css: the .dashboard-admin-link rule + :hover (now orphaned)

The /admin route, the topbar Admin nav-item, and the AdminOverview JS
component all stay as-is — only the dashboard-side entry point goes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:04:03 +01:00
librelad
a265b86cc9 Merge claude/2 2026-05-27 00:50:53 +01:00
librelad
57d6fdaa7c ui(backup): tighten Backup-status tooltip — short and sweet
Was: 'What's saved. Save System config first — if anything breaks, you
     need it to get everything else back.' — read a bit kindergarten.

Now: 'Latest backup per app + System config. Back up System first —
     it's needed to restore the rest.' — same info, tighter, still
     reads at a glance.
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:50:53 +01:00
librelad
a9baf1d0fa Merge claude/2 2026-05-27 00:49:19 +01:00
librelad
a9af8d93c7 ui(backup): drop Backup-status hint text; move it to a plain-language tooltip
The two-line hint under 'Backup status' was redundant — the System
config tile speaks for itself once it's there. Replaced with an ℹ️
tooltip on the heading (same pattern as 'Cross-host migrate' on the
Migrate tab).

Tooltip text deliberately plain: 'What's saved. Save System config
first — if anything breaks, you need it to get everything else back.'
No 'bare-metal restore' jargon, no 'snapshot' — the kind of sentence
that lands for someone who's never heard of either.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:49:19 +01:00
librelad
420964c6e8 Merge claude/2 2026-05-27 00:42:58 +01:00
librelad
102fc38da0 ui(backup): merge System config into the Backup status grid
The dashboard had two parallel sections — 'Per-app status' (every app's
latest backup) and a standalone 'System config' card below it. Folded
them into one grid: a single 'Backup status' card with the System config
tile rendered FIRST, then every app tile.

Why first: a bare-metal restore needs the system config (CFG_* +
backup-location credentials) — without it the backups exist but the
keys to reach them don't. Putting it at eye-level above the app tiles
makes the dependency visible.

System tile reuses the .backup-app-tile shape: server-stack icon,
'System config' as the name, status dot + 'Last backed up X ago' /
'No backup yet'. Plus two compact inline action buttons (Back up /
Restore) on the right that wire into the same data-action handlers
the old standalone card used — no behaviour change, just the visual
container.

grid-column: 1 / -1 on the system tile makes it span the row so the
two action buttons fit alongside the meta text without crushing the
app-tile grid template.

Section header: 'Per-app status' → 'Backup status' + hint 'System
config and every installed app's latest backup. System config always
first — a bare-metal restore needs it.' Dashboard subtitle updated
to match.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:42:58 +01:00
librelad
6fb595e481 Merge claude/1 2026-05-27 00:37:32 +01:00
librelad
59ee92bd87 fix(tasks): treat app:'system' as a sentinel so the LibrePortal logo renders
`libreportal config update` and `libreportal system update` tasks are
submitted with `task.app === 'system'` (see task-actions.js' configUpdate
+ systemUpdate). renderTaskIcons hit its first branch on any truthy
task.app and built an <img src="/icons/apps/system.svg"…> which 404s
(there is no per-app icon called "system"). The onerror handler then
hid the broken image, so those task rows showed only the 🛠️ type emoji
and no LibrePortal logo — visually inconsistent with sibling system-level
rows like "LibrePortal - Finalize Setup" (which happens to carry
`app: 'libreportal'`, matching a real icon, and renders correctly via
the same branch).

Treat `app: 'system'` as a category sentinel rather than a real slug:
skip the per-app icon path, fall through to the system-task branch that
loads /icons/libreportal.svg directly. That icon is already shipped + the
data shape stays intact ('system' is the meaningful category, not a lie
about the app identity).

Net: "LibrePortal - Apply Configuration" and "LibrePortal - System Update"
now show the LibrePortal logo alongside their type emoji, matching the
Setup / Update / Backup-All rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:37:32 +01:00
librelad
6ca6320c88 Merge claude/1 2026-05-27 00:32:43 +01:00
librelad
aede5d44be refactor(tasks): friendly-title pattern table + cover the gaps
formatCommandForUser was a 90-line if/else chain that grew one branch at
a time as new task shapes appeared. Three sites had escaped coverage and
fell through to the raw-command fallback:

  libreportal config update '<changes>'   → shown as the raw 50-char clip
  libreportal peer add <name> <kind> …    → same
  libreportal regen webui --force         → same

Restructure as a declarative `PATTERNS` array of `{match, title}` rows.
Each row is one regex + one title (string OR function for per-app rows
that extract the app slug). The matcher iterates once; first match wins.
Adding a new task shape is now a one-line append — no new code branch,
no copy-paste of the `if/match/return` boilerplate.

Behaviour-equivalent for every previously-formatted command (verified
by running 15 sample command strings through the new function against
the old expected titles); the three previously-broken ones now resolve:

  libreportal config update CFG_DEV_MODE=true     → "LibrePortal - Apply Configuration"
  libreportal peer add Alice host …                → "LibrePortal - Add Peer"
  libreportal regen webui --force                 → "LibrePortal - Regenerate WebUI Data"

Plus a couple I noticed while in there:
  libreportal backup system                       → "LibrePortal - Backup System Config"
  libreportal peer remove / peer pair             → friendly equivalents

The two non-table fall-throughs (the toolsCatalog-aware `app tool` lookup,
and the generic `libreportal app <action> <app>` map) stay inline since
they need richer logic than the table can carry — but everything else
lives in the one scannable list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:32:43 +01:00
librelad
9d1a3836b2 Merge claude/2 2026-05-27 00:28:07 +01:00
librelad
4cda8490ce ui(migrate): wrap empty-state in a bordered callout panel
The 'No backups from other hosts visible…' empty state was rendering as
centred text inside the outer card, which read as floating prose rather
than a defined block. Wrapped it in a bordered callout (matches the
visual weight of the per-app task cards): rounded border, surface-2
background, padding, plus a centred location-pin glyph above the
message and the existing 'Open Locations' button as the CTA.

Inline styles so it works against the existing theme vars without
needing a new CSS rule.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:28:07 +01:00
librelad
ab43b97da2 Merge claude/1 2026-05-27 00:10:35 +01:00
librelad
061fa5e391 fix(lazy-load): count both braces per line so embedded awk doesn't strand the depth tracker
`webuiSystemUpdate` was calling `webuiSystemMetrics` and getting
"command not found": the lazy-load manifest was missing it (and
`webuiSystemApps`), even though both are defined in
webui_system_metrics.sh.

Cause: the manifest scanner's awk-based depth tracker is line-based.
The first function in that file, `_metricsReadCpu`, contains an
embedded awk script:

    _metricsReadCpu() {
        awk '/^cpu /{        <-- the open brace runs to end-of-line, depth += 1
            ...
        }' /proc/stat        <-- the close brace is mid-line, NOT counted
    }                        <-- depth-- but we started one too high

The increment rule fired for the `/^cpu /{` line (open brace at EOL),
the matching close on the `}' /proc/stat` line was not counted because
the existing depth>0 branch only decremented when stripped was EXACTLY
`}`. Result: depth ended at 1 instead of 0 after _metricsReadCpu, and
every subsequent funcname() header was treated as "still inside a
function" and silently dropped. Same pattern across the ~6 files the
lazy-loader memory called out as "false-positive eager".

Fix: in the depth>0 branch, count open AND close braces per line
symmetrically and apply the net delta. Drops the redundant "exactly
`}`" special-case rule, clamps depth at 0 as a sanity backstop. False
positives from braces inside string literals could under/over-count
locally, but the clamp + the next legitimate `funcname()` header at
depth-0 re-anchors the tracker.

Result on a full regen:
  - 858 → 876 functions indexed (+18 previously-stranded)
  - webuiSystemMetrics + webuiSystemApps now correctly autoloaded
  - eager-file count: 9 → 11 (two files that genuinely have both
    function defs and top-level side effects are now correctly seen
    both as eager AND get their functions indexed — net win on
    every axis)

Verified live: the WebUI updater that was failing with "command not
found" now prints "Updated system information..." cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:10:35 +01:00
librelad
623b86b96e Merge claude/2 2026-05-27 00:09:36 +01:00
librelad
c69449bec8 fix(deploy): rsync --delete was wiping .auth.json; preserve it (+ siblings)
Symptom: after any commit / deploy on this box, the WebUI would log
users out ~60 seconds after they logged back in. Looked like a
short session timeout; was actually the auth file being deleted.

Cause: my recent update.sh change added --delete to the frontend
rsync so source-tree file removals propagate to the live install.
Excludes only protected data/. .auth.json sits at the top of
frontend/ (never in the source repo — it's the persisted credentials
+ JWT secret), so --delete nuked it on every deploy. The next
container start regenerated it with a fresh secret; all existing
cookies (signed with the old secret) became invalid. The dashboard's
60-second auto-refresh hits /data/system/*.json which is auth-gated,
gets 401, and the global 401 interceptor in auth-manager.js shows
the re-login overlay. Hence 'logged out after 60 seconds'.

Fix: extend the rsync exclude list with:
  --exclude '.*'       (any top-level dotfile — covers .auth.json
                        and future runtime state of the same shape)
  --exclude '*.lock'   (lockfiles like setup.lock if any ever land
                        outside data/)
  --exclude '*.bak'    (backup files from manual edits)

data/ exclude kept. JWT lifetime stays at 30 days as designed.

Also: feat(webui): icon on the 'Open Locations' button in the
backup → Migrate tab's empty state. Matches the location-pin icon
used by the sidebar's Locations entry so the visual carries over
when the user clicks through.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 00:09:35 +01:00
librelad
7f29d33c45 Merge claude/1 2026-05-26 23:58:52 +01:00
librelad
88b431ee86 style(migrate): tighten card header + give the empty state a real CTA
The Migrate tab carried two walls of explanation text — a 3-line hint
under the h2 ("Pulls a snapshot taken on another host…") and an even
longer empty-state paragraph ("Either no other LibrePortal has backed
up to a location this host can see, or this is the only host using its
locations…"). Both spelled out diagnosis the user can infer from the
empty list itself, and the tone didn't match the rest of the backup
page (cards elsewhere have a short title + a 4-6 word hint, with any
long explanation as a hover title attribute).

Three changes:

1. h2 down to "Cross-host migrate" with a small ℹ️ carrying the full
   explanation as a title= tooltip — matches the existing tooltip
   pattern in the Locations form (BACKUP_RETENTION_PRESET_META).
   The short subtitle "Restore an app from another LibrePortal" stays
   as backup-card-hint, mirroring "Per-app status / Latest backup per
   app on this host" elsewhere on the page.

2. The empty state is now the standard `<div class="backup-empty-state">`
   container (same shape Locations + Snapshots use), one trimmed line
   ("No backups from other hosts visible in any enabled location.
   Add a shared backup location on both hosts to enable cross-host
   migrate.") instead of two paragraphs.

3. Added an "Open Locations" CTA button inside the empty state — the
   #1 next-step for a user staring at this empty list is to add a
   shared location, which lives one tab over. New data-action
   "go-to-locations" wired through the existing event-delegation
   handler in backup-page.js calling switchTab('locations').

The renderMigrate JS still toggles #backup-migrate-empty.hidden — the
wrapper id is unchanged, only its inner markup tightened. No
behavioural change beyond the CTA + tab switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:58:52 +01:00
librelad
8fa13ae52b Merge claude/2 2026-05-26 23:53:22 +01:00
librelad
ea59d5b268 feat(config): reconcileConfigFile now syncs comments from template (preserves user values)
Existing installs were locked out of template-side description /
options / marker changes because reconciliation kept the user's whole
`CFG_KEY=value     # comment` line verbatim. So new metadata like the
**DEV** marker I'm adding for the developer-mode feature wouldn't take
effect on already-deployed boxes — only fresh installs.

Updated reconcileConfigFile to split each line into value-part and
comment-part. User value is still sacred; the comment (title,
description, [options], **ADVANCED**/**DEV** markers) now comes from
the template. Field renames, label tweaks, marker additions/removals
shipped in a release reach existing installs on the next CLI
invocation (which runs the reconciler).

Specifically unblocks: the developer-mode WebUI feature (CFG_DEV_MODE
field gets added by the existing add-only path; CFG_INSTALL_MODE and
CFG_RELEASE_CHANNEL now pick up their new **DEV** markers and the
'Release - Stable' / 'Bleeding Edge' labels).

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:53:21 +01:00
librelad
9d8b1c5948 Merge claude/2 2026-05-26 23:49:09 +01:00
librelad
8a9ae28b6f feat(webui): developer mode + Android-style 10-click easter egg
What this delivers (Stage 1+2 of the dev-mode feature):

1. New `**DEV**` marker for config fields. Mirrors the existing
   `**ADVANCED**` pattern: stays in the description string, frontend
   strips it for display, presence flips a 'hide unless dev mode is on'
   behaviour. Implemented in ConfigUtils.cleanDescription /
   isDevField / isDevModeOn and in ConfigShared._filterDevKeys, which
   the two generateFieldsForCategory* helpers now call before rendering.

2. New CFG_DEV_MODE field in configs/general/general_install. Visible
   under Advanced; defaults to false. The canonical place to toggle
   dev mode (the WebUI easter egg writes to it, the auto-detector
   writes to it, and users can flip it directly here too).

3. Marked CFG_INSTALL_MODE and CFG_RELEASE_CHANNEL with `**DEV**`.
   Normal users no longer see either field — they install Release-
   Stable and that's the whole story. Devs see both with the
   user-facing labels you asked for:
     CFG_INSTALL_MODE        Release - Stable | Git clone | Local folder
     CFG_RELEASE_CHANNEL     Release - Stable | Release - Bleeding Edge
   (CFG_INSTALL_MODE label for the release option also renamed to match.)

4. 10-click LibrePortal-logo easter egg in topbar.js:
   - Counter on any .libreportal-logo click; idle-reset after 3 s
   - Toast countdown from click 6 ('4 clicks away from being a developer…')
   - At 10: toggles CFG_DEV_MODE via the standard config_update task
     (same path the Config form uses); shows '🛠️ Developer mode
     unlocked. Reload to see the extra options.'
   - Re-using the same logo when dev mode is on toggles it back off
     ('… away from disabling developer mode') — symmetric, no separate UI

5. Auto-detect: on every WebUI load, if CFG_INSTALL_MODE is git or
   local AND CFG_DEV_MODE is off, auto-flip to on with a one-time
   toast 'Developer mode auto-enabled — you're on a git install.
   Click the LibrePortal logo 10× to disable.' Stops dev-install
   users getting locked out of the very options they need to manage
   their install. Idempotent — runs once per page load; no-op if
   already on or on release.

Disable surfaces: (a) CFG_DEV_MODE in Advanced on the Config form is
the canonical toggle; (b) 10 more logo clicks. A 3rd surface (a System
page banner) is deferred — those two cover the practical cases.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:49:09 +01:00
librelad
48085e1d4d Merge claude/1 2026-05-26 23:48:35 +01:00
librelad
52e0227bb6 chore(cleanup): retire appGenerate — dead-on-arrival app-skeleton wizard
`libreportal app generate <name>` (and the menu's "g. Generate App" entry)
was broken three independent ways and incompatible with the per-app
architecture the project actually uses now:

  1. Copies from $install_containers_dir/template/ which doesn't exist —
     the only template/ in the tree was in scripts/unused/OLD_CONTAINERS/
     and was never installed into the live tree. cp -r would just fail.

  2. Every sed call used BSD/macOS syntax `sed -i '' -e …`. On Linux
     (every distro this targets) the empty '' becomes a positional file
     argument, so the substitutions never ran. 8 calls, all broken.

  3. Even if it had run, the produced skeleton would have been a
     pre-modular-tools / pre-per-port-subdomain app shape: no tools/,
     no scripts/ subdir, HOST_NAME=test in the .config. Every active
     containers/<app>/ today carries the modular layout the rest of the
     framework expects.

Plus the recent cleanups (the prompt loop fix in 9ffc8e4, the per-port
subdomain refactor in 2e4f420) had been peeling pieces off it without
the root question — does the function still belong? — getting asked.

Delete the whole surface:
  - scripts/app/app_generate.sh (157 lines, the function body)
  - scripts/unused/OLD_CONTAINERS/template/ (the never-installed source
    files appGenerate would have copied — stale enough to still carry
    HOST_NAME=test, CFG_<X>_HOST_NAME, and 248 lines of compose template)
  - menu entry "g. Generate App" + its dispatch in menu_main.sh
  - "generate" case branch in cli_app_commands.sh
  - `libreportal app generate` line in cli_app_header.sh
  - The corresponding entries auto-drop from files_app.sh +
    function_manifest.sh via regen.

New apps are added the way the catalog already grew — by hand-crafting
containers/<app>/{<app>.sh, <app>.config, docker-compose.yml,
tools/<app>.tools.json, scripts/<app>_*.sh}. Copying an existing app's
folder + renaming is the closest thing to a "generator" and it's a one-
command operation.

Net: -556 lines, no behaviour lost (the function never worked).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:48:35 +01:00
librelad
b63e60c85f Merge claude/1 2026-05-26 23:42:12 +01:00
librelad
9ffc8e4924 chore(cleanup): retire two stale leftovers from the lazy-load + subdomain work
initilize_files.sh's load-comment said "Phase 6 will tackle this with a
precompiled cache file" — but Phase 6 deliberately shipped a different
(simpler) win, and Phase 7 was reconsidered and rejected (the ~30 ms
saving doesn't justify the invalidation complexity across every CFG
write site). Rewrite the comment to describe current behaviour: the two
config scans run because bash can't lazy-load variables, and the
precompile was looked at and dropped.

app_generate.sh had a `read -p "" host_name` inside a `while true` with
no break — anyone who actually ran `libreportal app generate` would
have hung at the hostname prompt forever. The value was then fed into
a `sed 's/HOST_NAME=test/HOST_NAME='"$host_name"'/g'` against the new
app's .config file, but the post-2e4f420 template no longer carries
HOST_NAME=test (per-port subdomains in the PORT row drive routing now).
So the prompt was infinite-looping to gather a value that fed a no-op
sed. Drop both — the function loses no real capability since the
subdomain field is set per-port via the standard PORT row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:42:12 +01:00
librelad
44486f6de3 Merge claude/2 2026-05-26 23:28:06 +01:00
librelad
ef47155cdf feat(rootless): make pasta the actual default in network_rootless
The installer (rootless_docker.sh:123) already defaulted CFG_ROOTLESS_NET
to pasta when unset — but the bundled configs/network/network_rootless
shipped CFG_ROOTLESS_NET=slirp4netns with a description warning about
the AppArmor caveat. That made the WebUI Config page surface slirp4netns
as the selected option even though the install script preferred pasta if
unset, and the warning told users they'd have to hand-relax the AppArmor
profile if they switched.

Both are now obsolete:
  - CFG_ROOTLESS_NET=pasta is now the explicit default in the bundled
    config (matches the installer's implicit default).
  - Description drops the AppArmor manual-fix warning since the
    installer applies the local override automatically
    (installRootlessApparmorForPasta, shipped in the previous commit).

Dropdown order swapped too — pasta now top of the list as the
recommended option, slirp4netns kept as 'legacy fallback'.

The live install on this box already runs pasta (manually flipped
during debugging); the CFG file was synced to match so a future
rootless reinstall doesn't revert.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:28:05 +01:00
librelad
11213af3e8 Merge claude/2 2026-05-26 23:13:04 +01:00
librelad
4063283db1 feat(rootless): proper AppArmor profile for pasta network driver
The Debian-shipped passt AppArmor profile (/etc/apparmor.d/usr.bin.passt)
denies the accesses pasta needs to plumb rootlesskit's netns:
  - ptrace_read on the rootlesskit child to enter its user namespace
  - read /run/user/<uid>/dockerd-rootless/netns (the netns file)
  - read /proc/<pid>/net/{tcp,tcp6,udp,udp6} for implicit port forwarding

Without these the rootless docker daemon fails with:
  pasta failed with exit code 1:
  Couldn't open user namespace /proc/<pid>/ns/user: Permission denied

scripts/docker/install/rootless/rootless_apparmor.sh:
  New installRootlessApparmorForPasta() — idempotent fixup.
  1. Adds `include if exists <local/usr.bin.passt>` to the main profile
     (one line; re-adding is a no-op via grep).
  2. Writes /etc/apparmor.d/local/usr.bin.passt with the four rules
     pasta needs. The /local/ pattern is the standard Debian AppArmor
     hook for site-managed overrides — survives `apt upgrade passt`
     because it's outside the package's managed paths.
  3. Reloads via apparmor_parser -r.

Called from installDockerRootless after the override.conf write, gated
on $rootless_net == pasta. slirp4netns installs skip it.

This box was already manually patched while debugging the pasta swap —
the installer-side change makes it idempotent across reinstalls and
applies the same fix on any other host that installs rootless docker
with pasta as the net driver.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:13:04 +01:00