The admin ops/health board now lives at /admin/dashboard (adminPath
('overview') emits it; the topbar Admin link points there). Bare /admin
still resolves to the same board — no redirect, both paths render it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the frontpage liquid-fill disk circle with a real donut split into
Apps · Docker · Other · Free, keeping disk % used in the centre. Apps come
from the per-app generator (root-device bytes), Docker from system df; both
are clamped within "used" so skew can't overflow. Live disk ticks redraw it,
and the card now clicks through to the full Storage breakdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The /backup → Backups tab was the last surface still rendering snapshots
as a plain HTML table — every other backup-related list had moved to the
.task-item card pattern shared with Services. Cohesion-only refactor:
both surfaces now look identical, with the global view adding the
fields the per-app view doesn't need.
HTML: drops <table class="backup-snapshot-table"> + its <tbody>,
replaces with a single <div id="backup-snapshot-list"
class="backup-snapshot-rows"> that the same .backup-snapshot-flash
deep-link highlight already targets.
renderSnapshots() now emits .task-item cards via the new
_renderSnapshotRow() helper. Each card carries:
app icon · "12h ago" title · app-name chip (linked) · location pill
· timestamp chip · short-ID chip Restore · Delete · Details
Extras vs the per-app card:
- App-name chip — global list isn't scoped to one app, so each row
needs to name the app it belongs to. The chip is the deep-link to
/app/<name>/backups?snapshot=<id> (replaces the dashed-underline
"link" treatment on the old App / ID table cells).
- Delete button alongside Restore — destructive cleanup lives on the
global view, not on the per-app card.
- "System config" rows (snapshots without an app=<slug> tag) get the
LibrePortal icon and no app-link (no per-app page to open).
Detail panel (expanded via header / Details button) shows App, Backup
ID, Location, full timestamp, Host, Tags, Paths — the same shape as
the per-app version, plus Host (relevant on the global multi-host view).
Click delegation:
- [data-action="toggle-snapshot-row"] on the header + Details button
toggles .task-details-open
- Restore / Delete buttons now stopPropagation so clicking them
doesn't also toggle the panel
- Existing [data-deep-link] handler is reused by the app-name chip
Signed-off-by: librelad <librelad@digitalangels.vip>
The Backup status card sat with just a heading + tooltip on the right;
the Locations card on the same row already had a hint pill ("Active
destinations"). Mirror that pattern: show the next scheduled backup
time pushed to the right of the heading, so the user can see at a
glance when the daily run will fire without digging into Configuration.
Derived purely client-side from CFG_BACKUP_CRONTAB_APP (read off the
already-loaded window.systemConfigs map) — no backend surface needed:
- nextCronFireTime(expr) parses a 5-field crontab (minute hour dom
month dow) supporting *, N, lists (N,M,O), ranges (N-M), and
steps (* /N, N-M/S). Walks one minute at a time from now+1, honours
the POSIX OR rule for DOM+DOW, caps at 366 days so an unmatchable
expression doesn't loop forever, returns null on bad syntax so the
UI falls back gracefully.
- formatRelativeFuture(when) — formatRelative's future-tense sibling:
"in 6h", "tomorrow", "in 3d".
- formatScheduleClock(when) — "at 05:00" today, "Mon 05:00" otherwise.
Hint slot rendered in #backup-next-run. Three states:
- parseable + computable "Next backup tomorrow · at 05:00"
+ title with absolute time + schedule
- unparseable schedule "Schedule: <raw>" with title hint
- empty CFG_BACKUP_CRONTAB_APP "No schedule set" with title hint
Smoke-tested the cron parser against "0 5 * * *", "*/15 * * * *",
"30 23 * * 0", "0 0 1 * *", "", "garbage", and "0 5 * *" (4 fields).
Signed-off-by: librelad <librelad@digitalangels.vip>
"Backup now" and "Open backup center" looked off compared to the rest of
the app page — the secondary link sat underlined with a trailing arrow
glyph instead of a real button, and neither carried an icon. Re-skins
both to use the .btn .btn-primary / .btn .btn-secondary pattern the
config Save / Reset buttons use, so the three action surfaces on an
app page read as one family.
Backup now .btn .btn-primary + upload-cloud SVG (16x16)
Open backup center .btn .btn-secondary + external-link SVG (16x16)
The "Open backup center" link is now SPA-routed (preventDefault + call
navigateToRoute) so clicking it doesn't trigger a full page reload —
same behaviour as the deep-link cells in the global Snapshots table.
href is still /backup so cmd/ctrl-click and right-click → open-in-new-tab
still work the natural way.
Applied to both apps-unified-layout.html and the legacy app-content.html
since the existing app-page surface lives in both templates.
Signed-off-by: librelad <librelad@digitalangels.vip>
The per-app Backups tab was the odd one out: snapshots and the
"Backup now" / "Open backup center" buttons all sat inside a single
flat .backup-app-card with no styling parity to Services or Tasks.
The Services tab uses .services-title (20px header + bottom border)
on top of a recessed .services-rows panel; Tasks uses the same recipe
with .tasks-title + .tasks-container. Backups now matches.
.backup-title is the header row — h3 + subtitle on the left,
Backup-now (primary) and Open-backup-center (secondary) buttons
pinned to the right so they stay reachable regardless of how long
the snapshot list grows. No pagination needed: the renderer already
soft-caps the displayed list at 50 with an "Open backup center"
overflow link, and per-app snapshot counts almost never exceed that.
.backup-snapshots-container is the dark panel (rgba bg 0.2, radius 8,
padding/margin 16) wrapping the existing status line + snapshot rows.
JS untouched — it still writes to #backup-app-card-status and
#backup-app-card-snapshots; only the outer shell changed.
Signed-off-by: librelad <librelad@digitalangels.vip>
"Snapshot" is restic's term and leaks the tool's vocabulary into the
WebUI. Users think in "backups" — the on-page label even says "Backups"
already; only the secondary copy still said "snapshot". Renames the
remaining user-visible mentions while leaving code identifiers, API
keys, data attributes, CSS class names, and the ?snapshot= deep-link
param untouched (those are internal contracts and changing them would
churn for no user-visible win).
Renamed surfaces:
- Per-app Backups tab header:
"Snapshots for <app>" → "Backups for <app>"
"across all configured repositories" → "across all configured locations"
- BackupAppCard:
"No snapshots yet" → "No backups yet"
"No snapshots found" → "No backups found"
"Showing the most recent 50 of N snapshots" → "...of N backups"
ID-chip tooltip "Snapshot ID" → "Backup ID"
Detail panel "Snapshot ID:" → "Backup ID:"
- Backup retention preset descriptions (KEEP_LAST/DAILY/WEEKLY/MONTHLY/
YEARLY) — "snapshot per day/week/..." → "backup per day/week/..."
- Personal preset hint: "6 monthly snapshots" → "6 monthly backups"
- Restore confirmation modal hint: "snapshot restored in place" →
"backup restored in place"
- Config-warning banner copy adjusted so it doesn't introduce
"snapshots" as a noun
- Retention "Keep last" input suffix: "snapshots" → "backups"
- Cross-host migrate tooltip: "snapshot" → "backup"
Signed-off-by: librelad <librelad@digitalangels.vip>
The inline "Delete location" action was the last spot on the Backup
page still using the native browser confirm() — the snapshot delete
already uses the styled backup-modal, so the location delete sat out
as the odd one. Adds a new #backup-delete-location-modal matching the
existing modal shell (header / body / backup-danger-btn footer),
swaps deleteInlineLocation() to open it instead of confirm(), and
wires the confirm button to a new confirmDeleteLocation() that does
the actual `libreportal backup location remove <idx>` task.
Behaviour is the same — confirm body text moves into the modal as a
muted hint paragraph using backup-card-hint, location name bolded
for scannability. expandedLocs cleanup also moves into the confirm
handler so the row collapses only when the user actually deletes.
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds per-row checkboxes (right of the Delete button, per request), a
master "Select all" toggle in the action bar, and morphs Clear All into
"Delete Selected (N)" the moment 1+ rows are ticked. Both paths go
through the same _showClearAllModal redesigned in 1ccc4bb — same UX,
same "Cancel running too" toggle, same logic; only the title + eyebrow
shift to reflect which mode the user came in through:
all → "Delete all N tasks?" eyebrow "Delete Tasks"
selected → "Delete N selected tasks?" eyebrow "Delete Selected"
State lives in this.selectedTaskIds (Set<string>). The row checkboxes
fire toggleTaskSelection(id, checked); the master fires toggleSelectAll
which ticks/unticks every visible row's checkbox in one pass (visible,
not all-of-this.tasks — so category filters DTRT).
_updateSelectionUI() reconciles three things on every change:
- the Clear All button label + title attr
- the master checkbox's checked/indeterminate state (some-but-not-all
visible → indeterminate dash, all → checked, none → unchecked)
- hooked into renderTasks() so category-switches don't leave stale
UI
performClearAll(opts) now accepts opts.targets — the subset to operate
on. clearAllTasks() passes either the selection or this.tasks depending
on mode. The active-task cancel-or-skip logic (cancelRunning toggle) is
unchanged — runs identically over the smaller set.
CSS:
.task-select — 22×22 framed checkbox matching the .task-btn
buttons it sits next to (border, hover green,
focus outline)
.task-select-box — custom box with check + indeterminate dash
drawn via ::after, no SVG dependency
.task-select-all — text-style toggle in the action bar with the
same custom box
No new globals. Hooked up via the existing window.tasksManager.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two small dev-mode UX changes.
1. Banner. When CFG_DEV_MODE is on, a 36px amber-tinted strip sits flush
under the topbar with "You are currently running in Developer mode"
and a dismiss X. Dismissal is remembered in localStorage and cleared
whenever dev mode is toggled back on, so re-enabling the mode brings
the banner back. Body picks up `.has-dev-banner` while visible to
bump padding-top by the strip's height (also adjusts the mobile
drawer's top/height).
2. Toast. The auto-enable message dropped the trailing
"Click the LibrePortal logo 10× to disable." — too noisy on every
git/local page load; the easter egg is still discoverable. New
message is just "Developer mode auto-enabled — you're on a <mode>
install."
Signed-off-by: librelad <librelad@digitalangels.vip>
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>
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>
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>
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>
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>
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>
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>
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>
Two related UI tidies — both removing surface area from the topbar / Tools
group rather than adding new pages.
Peers → /admin/tools/peers
Was a top-level /peers route with its own topbar nav item, which doubled
the navigation surface for what's really an admin tool (same shape as
SSH Access). Now lives under the Admin sidebar's Tools group alongside
SSH Access. /peers is kept as a legacy redirect → /admin/tools/peers.
Plumbing:
- config-sidebar.js gains a Peers entry under the Tools label.
- config-manager.js gains a 'peers' branch that fetches
peers-content.html into config-section, then inits PeersPage.
- window.adminPath() learns 'peers' → /admin/tools/peers.
- spa.js handlePeers() is now a redirect (mirrors handleSsh).
- topbar.html drops the Peers nav item.
- peers-content.html slimmed to a config-section template (no
standalone page wrapper) so it embeds cleanly under the admin shell.
- PeersPage gains a rootId constructor arg for symmetry with SshPage
(queries still work globally — IDs are unique).
System lifted out of the Tools group
User feedback: 'overview/system are kinda like, the same thing'. Moved
System to sit right under Overview at the top of the sidebar, before
the 'Config' label. Both surfaces are admin-landing pages (Overview =
ops/health summary, System = live host + per-app stats) — distinct from
config form pages or the Tools utilities.
config-sidebar.js: System block moved to the top section (right after
Overview's click handler). Original Tools-group instance removed.
Signed-off-by: librelad <librelad@digitalangels.vip>
End-to-end direct-ssh-direct: two LibrePortal instances exchange pairing
tokens, each authorizes the other to call a locked-down peer-shell dispatcher
via SSH forced-command, then either side can pull live app data from the
other without needing a shared backup repo.
Push and Connect-via-relay are deferred — push is symmetric to pull (same
forced-command, opposite verb), and the relay variant waits for Connect to
actually exist (config_json + kind enum already future-proofed in Phase 2).
Key generation (peer_key.sh):
One ed25519 keypair per install at ~<manager>/.ssh/libreportal-peer{,.pub}.
Generated lazily on the first peer-related call. Used as our outbound
SSH identity AND as the pubkey other instances authorize.
Forced-command dispatcher (peer_shell.sh):
Standalone script, deployed by peerInstallShell() to
~<manager>/.local/bin/peer-shell. authorized_keys entries look like:
command="~/.local/bin/peer-shell <peer-name>",no-pty,no-port-forwarding,
no-X11-forwarding,no-agent-forwarding,no-user-rc ssh-ed25519 AAAA… peer:<name>
sshd hands us $SSH_ORIGINAL_COMMAND; we parse, whitelist the verb, and
refuse anything else. Verbs:
ping Liveness probe (JSON ok:true).
list-apps JSON {peer, apps:[{slug, size_kb}]}.
stream-app tar of containers_dir/<slug> to stdout (slug strictly
validated — lowercase alnum+dash; rejects path traversal).
Audit log appended to ~/.local/state/libreportal/peer-shell.log. Excluded
from the generated source arrays (would crash any sourcing shell on empty
SSH_ORIGINAL_COMMAND); generate_arrays.sh skip-list extended.
Pairing token (peer_pairing.sh):
Format: lp-peer|v1|<name>|<user>|<host>|<port>|<base64-pubkey>|<fingerprint>
Pipe-delimited because the SHA256 fingerprint and base64 pubkey both
contain ':'. peerPairingParse decodes + re-derives the fingerprint from
the actual key, refusing tokens with mismatched fingerprints (catches
truncation / tampering). peerPairingAccept:
1. Installs peer-shell (peerInstallShell).
2. Appends to authorized_keys with the lockdown options above.
3. Inserts a peers row (kind=direct-ssh-direct, config carries host,
port, user, fingerprint).
Symmetric — user runs accept on BOTH sides with the other's token to
enable bidirectional calls.
Outbound SSH (peer_remote.sh):
peerExec <name> <verb> [args] — looks up the peer's connection config and
ssh's in with the right key, BatchMode + ConnectTimeout + accept-new for
the host key. peerPing wraps it and updates peers.status + last_seen.
Pull-an-app (peer_pull.sh):
peerPullApp <peer> <app> [--no-pre-backup] [--keep-urls]
1. peerPing (refuse if unreachable).
2. migratePreBackupDestination (reuses the Phase 0 safety wrapper —
same restic-tagged pre-migrate snapshot as the backup-channel flow).
3. Stop + wipe destination's app folder.
4. peerExec stream-app | tar -x (pipefail; bails on partial transfers).
5. migrateApplyUrlRewrite + dockerComposeUpdateAndStartApp install
(URL repointing, idempotent install path).
6. dockerComposeUp + post-restore hooks.
Identical Stage-2..6 to migrateApplyApp; only the data source differs
(tar-over-SSH instead of restic-restore).
CLI (cli_peer_commands.sh + header):
libreportal peer token — emit this host's pairing token
libreportal peer pair <token> [name] — accept a token (override name)
libreportal peer apps <peer> — live peer-shell list-apps
libreportal peer pull <peer> <app> [--no-pre-backup] [--keep-urls]
WebUI (/peers):
Header gains 'Show my token' and 'Pair with token' buttons (both open
modals around the matching CLI verbs). Token modal warns the user that
the token is credentials. Pair modal accepts a free-form override name.
Direct-SSH peer cards gain a 'List apps' button that opens an inline
drawer showing the peer's live app inventory (via peer apps) with per-
app 'Pull' buttons. Pull modal has the same two safety toggles as the
Migrate tab (pre-backup ON, URL rewrite ON by default).
Backup-channel manual-add modal kept; direct-SSH must use the token flow.
Smoke-tested:
- All 16 peer-subsystem functions register without crashing the shell.
- peer-shell ping ⇒ {ok:true}; unknown-verb refused; path-traversal slug
refused; valid-slug streams.
- Token emit→parse round-trip preserves every field; garbage rejected
with not-a-token; v99 rejected with unsupported-version.
Signed-off-by: librelad <librelad@digitalangels.vip>
A peer is a named reference to another LibrePortal instance. Phase 2 only
implements kind=backup-channel (friendly label over a hostname that shows
up in a shared backup repo); direct-ssh-direct and direct-ssh-via-relay
(Connect's blind-relay) are reserved enum values for Phase 3.
DB schema (db_create_tables.sh):
CREATE TABLE peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
kind TEXT NOT NULL DEFAULT 'backup-channel',
config_json TEXT NOT NULL DEFAULT '{}',
status TEXT DEFAULT 'unknown',
last_seen TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
+ indexes on name and kind.
config_json is kind-specific so new transports don't need a schema
migration. For backup-channel it carries {"hostname":"","loc_idx":N}.
Bash module (scripts/peer/):
peer_helpers.sh _peerDb, peerSqlEscape, peerValidateName/Kind.
peer_add.sh peerAdd <name> <kind> [k=v ...] → INSERT, refresh
generator. Rejects unimplemented kinds early so users
don't create dead-end peer records.
peer_remove.sh peerRemove <name> → DELETE.
peer_list.sh peerList → JSON array; peerGet, peerNameForHostname
(reverse-lookup for the migrate-tab overlay).
peer_check.sh peerCheckReachable, peerCheckAll. For backup-channel
'reachable' = at least one snapshot from that hostname
visible in (preferred|any enabled) location. Updates
status + last_seen so UI dots render without re-probing.
CLI (scripts/cli/commands/peer/):
libreportal peer list
libreportal peer get <name>
libreportal peer add <name> backup-channel hostname=<host> [loc_idx=<n>]
libreportal peer remove <name>
libreportal peer check [name]
Auto-routed by cli_initialize.sh's category-discovery.
WebUI data generator (scripts/webui/data/generators/peers/webui_peers.sh):
Emits data/peers/generated/peers.json with the peerList output and a
generated_at envelope. Hooked into webuiLibrePortalUpdate alongside the
backup generators.
Frontend:
- New top-level /peers route in spa.js (PeersPage class, peers-content.html).
- 'Peers' nav item in the topbar between Backups and the right-side controls.
- Add-peer modal with friendly-name + kind + hostname + preferred-location
selector (populated from the existing backup-locations data).
- Per-peer card with status dot, last-checked time, Check + Remove buttons.
- Phase 3 kinds appear in the kind dropdown as disabled options so users
can see what's coming.
Source-array wiring:
- generate_arrays.sh auto-created files_peer.sh from the new peer/ dir.
- cli_files.sh + app_files.sh include ${peer_scripts[@]} alphabetically.
- files_webui.sh auto-picked-up the new peers/ generator subfolder.
The migrate-tab friendly-name overlay (use peer names in /backup/migrate
when a peer record exists for a hostname) is intentionally deferred — it's
a 5-line frontend lookup once peers.json is loaded; cleaner to add after
Phase 3 ships its peer-detail view.
Signed-off-by: librelad <librelad@digitalangels.vip>
Phase 1 of the migration-system refresh. Surfaces Phase 0's kernel
(libreportal restore migrate ...) as a WebUI flow so users don't have
to drop to the CLI to pull an app from a peer's backups.
backend / data generator:
scripts/webui/data/generators/backup/webui_backup_migrate.sh
Walks every enabled backup location, lists every (other_host, app)
pair with snapshot count + latest id/date, and emits a single
destination summary block (installed apps, running apps, disk free)
so the frontend can compute collisions and warnings without per-row
API round-trips. Filters out our own hostname — we don't migrate to
ourselves. Output: data/backup/generated/migrate.json.
Hooked into the standard webuiLibrePortalUpdate refresh pipeline,
so 'libreportal regen webui' (and the periodic task-processor poll)
keep it fresh on their own.
frontend:
- New 'Migrate' sidebar tab on /backup, sits between Locations and
Configuration. Path-based URL: /backup/migrate.
- Per-source-host cards listing every available app, with snapshot
count + relative-time hint, collision dot when the app is already
installed here, and per-app + per-host migrate buttons.
- Confirm modal with two checkboxes matching the kernel's defaults:
[✓] Back up the destination's existing copy first (pre-migrate
backup; auto-disabled when there's nothing to back up)
[✓] Rewrite host-bound URLs to this host (URL rewrite
— uncheck only to keep source hostnames)
On confirm, runs 'libreportal restore migrate app/system …' via the
task system; opt-out checkboxes append --no-pre-backup / --keep-urls
only when the user un-ticks, matching the kernel's default-on flags.
- Empty state when no other hosts have visible backups, explaining
the shared-backup-location prerequisite.
The CLI dispatcher hooks (Phase 0) wire restore migrate app/system to
migrateApplyApp/migrateApplySystem, so the WebUI gets pre-backup safety,
URL rewrite, and structured progress (when --json-progress is set; not
needed here yet — the task system's log tail is enough for v1).
Signed-off-by: librelad <librelad@digitalangels.vip>
- Add .backup-system-card { margin-top: 20px } — the card stands alone below the
two-column cards row (which has no bottom margin), so it was butting against it.
- Add a server-stack icon to the card header (matches the nebula stroke-icon style).
- DEVELOPMENT.md: document the dashboard "System config" card + its last-backup
status (tag system=config → `system` in the dashboard JSON), the CLI/auto paths,
and that the libreportal app is excluded from the per-app grid.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Make the system config a tracked backup, not just action buttons:
- engine: resticSystemSnapshotsJson (tag system=config) + engineSystemSnapshotsJson
dispatcher — query the system snapshots the way per-app status is queried.
- webui_backup_dashboard.sh: emit a "system": { latest_snapshot, latest_time }
object (latest system snapshot on the primary location), and exclude the
libreportal WebUI app from the per-app grid (it's intentionally not backed up, so
it no longer shows a perpetual "No backup yet" tile).
- backup dashboard card: a status line (dot + "Last backed up <relative>" / "No
backup yet"), populated in renderDashboard from d.system — mirrors the app tiles.
Verified: shell + JS parse; dashboard content assembles to valid JSON with the
system key; engine query defined + dispatched; frontend reads d.system into the
#backup-system-status element.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add a "System config" card to the backup dashboard with two actions wired through
the task processor (same path as "Backup all apps"):
- "Back up now" -> libreportal backup system
- "Restore…" -> libreportal restore system (confirm dialog explains it lands
in a staging folder and never overwrites live config)
Card copy explains why it matters (the backup-location creds otherwise live only on
the box). Click handlers + runBackupSystem/confirmRestoreSystem added; JS parses,
data-actions match handlers, commands match the CLI subcommands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Same class of bug as the topbar partial: icon and data-file references were
relative (icons/apps/x.svg, data/apps/...), so on deep path routes (/app/<name>,
/admin/config/x) the browser resolved them against the route dir and the SPA
catch-all served index.html with HTTP 200 instead of 404 — broken images and
silently-wrong JSON.
Make every reference absolute (anchored on the quote/backtick so already-absolute
/icons paths are untouched):
- JS: all icons/ and data/ literals + templates across components/utils/system
- html/topbar.html: logo <img>
- generators: webui_config.sh and webui_create_app_categories.sh now emit
/icons/... into apps.json / apps-categories.json (regenerated on install)
- updated the two icon-path comments to match
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Path-based routes (e.g. /app/<name>) made the relative fetch('html/topbar.html')
resolve to /app/html/topbar.html. The SPA catch-all returns index.html with HTTP
200 instead of 404, so response.ok passed and index.html got injected as the
topbar, leaving #nav-app-center absent -> 'Nav element not found' in setActiveNav.
Make the topbar fetch and the loadConfig fetch absolute, and switch the remaining
relative topbar nav hrefs (index/dashboard/tasks .html) to absolute paths so the
SPA click interceptor routes them instead of doing a real browser navigation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the Admin area's ?= query URLs with clean, hierarchical paths that
mirror the breadcrumb:
/admin -> Overview
/admin/config/<category>-> Config / <category>
/admin/tools/ssh-access -> Tools / SSH Access
New /admin (+ /admin*) SPA route -> handleAdmin, which parses the path via the
shared window.adminPath / window.adminCategoryFromPath helpers and renders
through the existing ConfigManager. Legacy /config, /config?=<x> and /ssh now
redirect into the matching /admin path, so old links/bookmarks keep working
(server already serves index.html for any depth). Sidebar, Admin Overview,
dashboard link and top-nav now build /admin paths; active-nav + config data
loading recognise /admin across spa.js, topbar.js, router.js, data-loader.js.
Scope: Admin area only — /app, /apps, /tasks, /backup keep their existing ?=
URLs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add an Admin Overview as the Admin landing (default when you open Admin): an
ops/health board distinct from the user Dashboard. Four cards built from data
we already generate — Updates (update_status.json, with one-click update),
Backups (backup dashboard.json), SSH & Security (access.json), System
(disk/memory/system_info) — each with a Manage link into the right section.
Styled like the backup dashboard (tiles/status dots).
Wire-up: 'Overview' is the top sidebar item and the default category
(handleConfig + sidebar), rendered by AdminOverview into #config-section via a
renderConfig('overview') special case. Every Admin page now shows the same
'Admin' breadcrumb header (Overview, SSH Access, and the config categories) for
a consistent Admin → Section feel. User Dashboard gets an 'Admin overview →'
link.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Rename the Config top-nav to 'Admin' and move SSH Access into its sidebar
under a 'Tools' group, instead of a separate top-level nav item. SSH Access is
rendered by SshPage into the config main pane via a renderConfig('ssh-access')
special case; the sidebar item (config-sidebar.js) routes there. SshPage now
mounts into any container (defaults to #config-section). /ssh redirects to
/config?=ssh-access for old links; the standalone ssh-content.html is removed.
Declutters the top bar and gives system/admin features one home that scales
(updates, users, Connect settings can become sidebar entries later).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
New /ssh page (topbar nav + SPA route + SshPage controller + ssh-content.html
+ ssh.css). Reads data/ssh/access.json and lets the admin: paste a public key
to authorize a machine, remove keys, and toggle key-only login — all via
'libreportal ssh ...' tasks through the backend's lockout guards. Reuses the
backup key-card styles for a consistent look. This is the inbound counterpart
to the backup location key card (outbound): same paste-a-key model, opposite
direction.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Drop the Export button from the config-backup warning banner — it's now just the alert + dismiss (x). On the Configuration tab the top-right primary action becomes an 'Export' dropdown (first item: Repository Passwords, reusing the existing export-passwords action) so more export types can be added later. Other tabs keep Backup all apps / Add location. Menu opens from the trigger and closes on outside click, item click, or tab switch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.
Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>