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>
"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>
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>
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>
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>
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>