Docker only tracks where an app's data lives (its bind mounts), not how
big a bind-mounted host dir is — so named-volume accounting reads ~0 for
LibrePortal, whose app data lives in bind mounts. Add a generator that
reads each app's mount map from `docker inspect` and `du`s the directories
(via runFileOp, so it runs as the data-owning user and isn't blocked by
rootless UID mapping). `du -x` keeps each measurement on its own
filesystem, so data on a separate disk is reported as a distinct
"external" total. The generator self-throttles to ~10 min since du is
heavier than the per-minute metrics. Surfaced as a "Storage by app"
section on the Storage page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds per-file integrity attestation on top of the existing signed-tarball
release flow. make_release now generates a SHA256SUMS manifest over the shipped
tree and (when a key is configured) signs it, riding both inside the release
tarball so they land in the install tree with no extra download.
lpVerifyInstall (scripts/source/verify.sh) re-hashes the install tree against
that manifest and verifies the manifest's minisign signature against the
root-owned footprint pubkey, yielding states: verified / modified / tampered /
unsigned / unverifiable / development. webuiSystemVerify writes verify_status.json
(throttled daily, force on demand, also after each update apply), surfaced as an
Integrity line + "Verify now" button on the Admin → Overview Updates card and a
row in the update details panel. `libreportal verify` exposes the same check on
the CLI.
Honest framing: this is a self-check (run by the software it verifies), so red
fires only for genuine modified/tampered states; the badge tooltip points to
out-of-band `minisign -Vm` for an independent guarantee.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Drop the OS row and compact Memory/Uptime so the System card matches
the density of its sibling cards. Full detail stays on the deep system
stats page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Three fixes from testing the storage page:
- Placement: the "Reclaim space" button moves into the page header,
top-right (matching the metric page), instead of sitting in the body.
- It now actually reclaims: build cache needs -a to drop (docker reports
0 B "reclaimable" without it, but it's pure cache — safe to clear), so
the CLI uses `docker builder prune -af`. Previously the safe scope
freed ~nothing on a box whose reclaimable was mostly cache.
- Honest "Reclaimable" number: /api/system/storage was counting the
whole build cache AND unused tagged images, overstating what the safe
prune frees (e.g. 340 MB shown, ~96 MB per docker, button cleared 0).
Reclaimable now = dangling images + build cache only; stopped
containers and volumes are never counted (the safe prune never touches
them). Headline now matches the button's effect.
Also simplify the CLI output (drop the jargony scope notice and the
reclaimed-total greps) and re-enable the now-persistent header button
after the post-reclaim refreshes.
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds a `libreportal system reclaim` CLI command and an orange "Reclaim
space" button on /admin/config/system/storage (the v2 prune control the
page always hinted at).
Scope is deliberately SAFE: build cache + dangling (untagged) images
only (docker builder prune -f + docker image prune -f via the
rootless-aware runFileOp). It never touches volumes (app data) or
tagged/in-use images, so nothing an app relies on is removed.
Wiring mirrors system_update: a systemReclaim() action + system_reclaim
route case run the command verbatim through the task processor. The
button confirms via showConfirmation, shows a spinner, and re-reads
storage usage as the prune lands. Button styled with --status-warning to
match the Reclaimable stat it sits under, with a note clarifying scope.
Signed-off-by: librelad <librelad@digitalangels.vip>
The expanded snapshot detail reused the shared .task-meta/.meta-item
layout, which forces each field onto one nowrap line and clips long
values (the full date string, repo paths) mid-string. Give the backup
snapshot its own scoped label-over-value grid plus full-width Tags/Paths
blocks that wrap, surface app=/host=/engine= tags as their own fields,
and show a readable date (full timestamp on hover). Applied to both the
global Snapshots tab and the per-app Backups card so they match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The page has no live feed, so a completed backup/restore wasn't reflected
until a manual Refresh or re-navigation. Subscribe to the TaskEventBus
'taskCompleted' event and repaint on backup/restore completions. Debounced
to coalesce the burst when several per-app tasks finish together; only the
mounted instance reacts and a stale listener removes itself. The Refresh
button stays as a manual pull.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The primary header button defaulted to "Backup all apps" on every tab that
wasn't Locations/Configuration, so it showed on Dashboard and Migrate where
it isn't wanted (Dashboard backs up via the status-grid tiles; Migrate is
about moving hosts, not backing up). Keep it on the Backups tab and hide the
header action entirely on Dashboard and Migrate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Promote a compact Storage summary (breakdown donut + per-category legend
+ reclaimable) onto the System index, replacing the thin Docker strip and
its easily-missed "Open breakdown" link; it links through to the full
breakdown page. Drop the Disk usage trend chart, which duplicated the
Disk gauge's root-mount %.
Extract the donut + segment builders onto SystemStoragePage so the index
summary and the full page share one renderer. This also fixes a donut
stacking bug: the SVG used the final cumulative fraction for every
slice's dashoffset instead of each slice's own running offset, so the
ring only partially filled. It now fills proportionally.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The metric detail page showed the empty overlay ("No samples in this
range yet — check back in a minute") whenever the initial history fetch
returned zero points, but nothing ever hid it again. Live ticks then
pushed samples in, drew the chart and filled the now/peak/avg/min stats
— while the overlay stayed up, contradicting the visible data.
Make _renderChart the single authority for the overlay: hidden whenever
there are points, shown only when there are none. Live data clears it as
soon as the first sample lands; switching to a genuinely empty range
brings it back.
Signed-off-by: librelad <librelad@digitalangels.vip>
The Load ring was driven by load1_percent = min(100, load1/cores*100)
and coloured by the generic gauge thresholds (red at >=90%). On a
low-core box that pinned it red whenever load merely approached the core
count — which is normal "fully used" territory, not a problem.
Drive the ring from raw load1 with max = cores*2 (so load == cores sits
mid-gauge) and colour by load-per-core: green below capacity, orange
around capacity (>=1.0x), red only once load clearly exceeds it (>=1.7x,
tasks genuinely queuing). cpu.cores rides the live SSE payload, so the
colour is correct on live ticks too.
Signed-off-by: librelad <librelad@digitalangels.vip>
The OS/Kernel/Uptime/CPU/Swap strip was the only section on the System
page rendered without a .sys-section-head, so it had no title and butted
directly against the charts above it (no top spacing). Add a "Host" head
— matching the Docker / Per-app pattern — which supplies both the label
and the section's 26px top margin. "Host" rather than "System" since the
page H1 is already "System".
Signed-off-by: librelad <librelad@digitalangels.vip>
The admin overview Manage backups action called window.librePortalSPA,
a global that is never assigned, so the optional-chaining call silently
no-op'd. The router is window.spaClean; point the call at it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
System-config backups (libreportal backup system) carry no app slug, so the
notification descriptor resolved a blank subject + no icon, and a system-only
pick collapsed to `backup all` when no apps were installed. Give them the
LibrePortal icon + a "Configs" subject, add backup-system to the system-task
logo detection, and guard the whole-fleet collapse on having >=1 app. Rename
the visible subject from "System config" to "Configs" throughout the backup UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The shared .task-header has no gap, so a full-width chip row (status /
cpu / mem / ports / ip) left the last live chip butting against the
Restart button. Add a 12px gap on .service-item rows only (matching
.task-info's internal gap) so the resource chip and Restart no longer
touch, without affecting the Tasks page that reuses .task-header.
Signed-off-by: librelad <librelad@digitalangels.vip>
Was centred in the open details panel; move it to the left edge so it
lines up with the rest of the panel content.
Signed-off-by: librelad <librelad@digitalangels.vip>
Bump the dev strip's horizontal padding 14px -> 18px to match
.setup-level-card's content inset, so the strip icon/text sit on the
same left edge as the card titles above it instead of ~4px inboard.
Padding sides (not an icon margin) keeps the whole row aligned and
leaves the top/bottom entrance animation untouched.
Signed-off-by: librelad <librelad@digitalangels.vip>
Swap the strip's shared 🛠️ emoji for the inline "tool" SVG used by the
topbar Developer-mode banner — a real, dedicated icon that ties the two
dev-mode surfaces together and no longer doubles the Advanced card's
glyph.
Enrich the entrance: the box grows in and settles, a one-shot accent
glow pulses for the "unlocked" beat, a subtle shine sweeps across, the
icon pops with a slight overshoot/wiggle, and the text slides in just
behind it. All gated behind prefers-reduced-motion.
Signed-off-by: librelad <librelad@digitalangels.vip>
Tap the Advanced card 10 times and a full-width "Dev mode activated"
strip slides in beneath the two cards — the same 10-tap pattern as the
topbar logo and services-manager unlocks, now at install time. The
choice rides the setup payload (dev_mode) so setup_apply.sh persists
CFG_DEV_MODE=true, and it's mirrored in-process via LpUi.dev so the
next surface already reflects it. 10 more taps toggles it back off.
Counting the Advanced radio's click (not the label's) sidesteps the
label->input double-fire; the radio is pointer-events:none, so each tap
reaches it exactly once. The strip is [hidden] by default (no phantom
gap in the flex column) and replays its entrance keyframes each reveal.
Signed-off-by: librelad <librelad@digitalangels.vip>
The Beginner/Advanced cards on the first setup step had three-sentence
descriptions that read as a wall of operator detail — the opposite of
the friendly first impression the step is meant to give. Trim each to a
single game-intro-style line; the reversibility note and the Advanced
toggle still cover the details for anyone who wants them.
Signed-off-by: librelad <librelad@digitalangels.vip>
The "<X> needs to be installed" feature cards (Enable Whitelist, Authelia
Integration, Headscale Integration, …) were rendering with broken
proportions inside narrow form-grid columns: the body squashed into a
~30-char column and the install button stretched vertically as the only
flex item with room to grow.
Switch to a 2-row CSS grid:
┌────────┬──────────────────────────┐
│ icon │ title │ row 1: who is this for
│ │ reason │
├────────┴──────────────────────────┤
│ [ Install <Service> ] │ row 2: full-width fix-it
└───────────────────────────────────┘
icon grid-row 1, col 1
body grid-row 1, col 2
action grid-row 2, col 1 / -1, width 100%
Reads top-to-bottom regardless of how narrow the host column is, so the
Features tab's 3-column grid stops looking broken. The old @media
(max-width: 560px) responsive override is gone — the grid layout works
at every width, no breakpoint needed.
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>
The Open-backup-center button was rendering as the raw .btn-secondary
fallback (muted grey) because the amber-tinted theme override was
scoped only to .config-actions and .console-actions — .backup-title-
actions wasn't in the selector list. Result: same shape as Back-to-Apps,
totally different colour, looked off.
Add .backup-title-actions .btn-secondary to both override blocks (the
nebula-theme rule and the default themes.css fallback) so Open backup
center now matches Back-to-Apps and the Config Reset button: solid
amber in default themes, translucent amber under nebula.
Comment also reframed — these aren't "Back to Apps"-specific anymore;
they're "step away from this page" secondary actions as a family.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two fixes to the .lp-ui-advanced-toggle on the Services tab header:
1. The thumb flipped from --text-primary (white-ish) to --text-on-accent
(a dark navy on the default theme) when toggled on, which read as a
"black circle" inside the accent track. Other toggles in the project
(.eo-toggle in modal.css, .routing-toggle in routing.css) keep the
thumb white in both states — only the track changes colour. Dropping
the checked-state thumb fill brings this toggle in line.
2. The toggle was floating bare in the header row next to nothing,
which looked out of place compared to the contained button-style
controls in the same slot on Backups (Backup now / Open backup
center). Wrapped it in a chip: neutral rgba(text, 0.06) bg + 0.15
border + 6×12 padding, hover bumps both alphas. Same recipe a
.task-btn uses for its resting state, so the toggle visually reads
as a control sitting in line with the rest of the row's actions.
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>
Fixed-width tracks + cap formula kept the box pinned to "N cards at
328px" outer regardless of viewport size, so zooming out left a
massive empty band between the box's right edge and the layout edge.
The box was no longer "dynamic" in any real sense — it scaled with
the card count, not with the available content.
Switching grid-template-columns to repeat(auto-fit, minmax(--app-min,
1fr)) lets cards stretch to fill the row, and auto-fit collapses
trailing empty tracks so a 2-card row in a 3-track-wide viewport
doesn't leave a 328px hole at the end. Zoom in/out now just widens
or narrows the cards; the box always reaches the layout edge.
This drops the cross-category card-width uniformity that the earlier
fixed-width pass introduced — a 2-card category now lays out as two
wide cards while a 3-card category gets three narrower ones. That's
mutually exclusive with "box always full width" without leaving
holes, and the user has shifted priority to full-width-always.
JS cleanup: dropped updateAppsCount + its window-resize listener +
its callsites in renderApps/filterAppsByQuery — no more --app-count
or column-count measurement needed when the grid handles everything
natively.
Signed-off-by: librelad <librelad@digitalangels.vip>
Each tool row's Run button gains a small play-triangle SVG to the left
of the label, matching the iconography pattern the Services tab uses
for its Restart and Open buttons. Same green colour (currentColor), so
the icon inherits the success/destructive variants without extra CSS.
Button becomes a flex container with a 6px gap so icon + label stay
nicely centred regardless of label width.
Signed-off-by: librelad <librelad@digitalangels.vip>
User's empirical fix: on a 1280-class viewport (sidebar 220, content
~1010), --app-min 300 made the grid pick 3 cols because
floor((1010+20)/(300+20)) = 3, which left a 4-card category landing
as 3+1 with the orphan-row gap that's been the running visual
complaint. Bumping --app-min to 328 changes the floor to
floor((1010+20)/(328+20)) = 2, so the same 4-card category becomes
2+2 with no orphan.
Wider monitors are unaffected — a 1056px content area still fits 3
tracks of 328 (3*328 + 2*20 = 1024 ≤ 1056), and 1700px+ content
still fits 4. The cards-per-row count only drops on the narrow band
where 300 would otherwise have squeezed a third just-too-tight
column in.
Signed-off-by: librelad <librelad@digitalangels.vip>
Apply the same .services-rows / .tasks-container pocket pattern to the
Tools tab so the three app-page tabs (Services, Tools, Tasks/Backups)
share one visual language: rows live inside a sunken dark panel that
reads as a contained area inside the tab-pane's glass surface.
.tools-rows gains
background: rgba(var(--bg-rgb), 0.2)
border-radius: 8px
margin: 16px
padding: 16px (was 1rem 1.25rem 2rem)
For the multi-category tabs case the pocket sits flush below the tab
bar — drop the top margin via the .tools-tab-bar ~ .tools-cat-pane
sibling selector so the tabs and pocket read as one element.
Signed-off-by: librelad <librelad@digitalangels.vip>
The orphan-1 rebalance (4-on-3 → 2x2) cost too much: it dropped a
4-card category from 3 cards per row to 2 across the board, and would
do the same for any N where N % maxCols == 1. User feedback: 3
densely-packed cards with a small orphan-row gap reads better than
2 wider cards in a 2x2 layout — denser rows feel more compact and
let the eye scan more apps at once.
Back to the post-d4b7731 state: fixed-width tracks (auto-fill,
--app-min) so card widths line up across categories, plus the
sentinel that sets --app-count to 99 when visible cards meet the
natural full-width column count so the box reaches the layout edge.
The 4-on-3 case is now 3+1 again — the lone card on row 2 has empty
cells to its right, accepted as the lesser of two evils.
If the orphan ever becomes a real visual issue, the next move would
be JS-rendered last row (own sub-grid sized to its item count) rather
than reducing the column count globally.
Signed-off-by: librelad <librelad@digitalangels.vip>
Screenshot showed a 4-card category laying out as 3+1 (three cards on
row 1, Wireguard Easy alone on row 2 with two card-shaped empty cells
on its right). Fixed-width tracks + auto-fill kept the cards aligned
across categories but couldn't avoid the orphan — pure CSS grid has
no way to collapse partial-row trailing cells when the column above
them is filled.
apps-manager.js now picks --app-cols deliberately: the natural
column count for the viewport, reduced by one when the last row
would otherwise be exactly one orphan card. 4 cards on a 3-col
viewport becomes 2x2; 5 cards stays at 3+2; 6 stays at 3+3+0; 7
drops to 2-col so the last row gets a partner (still has one orphan
at the very end since 7 is prime, but never below 2 cols — a single
column stack reads worse than an orphan).
CSS swap: grid-template-columns now consumes the new --app-cols
custom property and uses minmax(--app-min, 1fr) so cards stretch
within their tracks (the orphan-prevention dance means widths can
vary across categories now — tradeoff for never having internal
gaps). 1-card view still shrinks the box via the existing formula
so a lone card isn't stretched across the full row.
Signed-off-by: librelad <librelad@digitalangels.vip>
The fixed-width tracks change kept card widths uniform across
categories but reintroduced the "large gap on the right" outside the
glass box — with cards locked at 300px and the cap formula tracking
exactly N cards, the box stopped wherever the visible cards ended,
leaving up to 150px+ of empty parent space to its right on a wide
viewport.
Bringing back the natural-columns sentinel from the earlier pass.
updateAppsCount measures the parent's inner width (minus the
section's 90px of margin/padding/border), computes the column count
the auto-fill grid would pick at full width, and passes a huge
sentinel (99) as --app-count whenever visible cards >= that count.
The formula then overshoots the 100%-44px parent cap and the box
runs edge-to-edge. Cards themselves still come out at --app-min
because the grid template is repeat(auto-fill, var(--app-min)) — no
1fr stretching — so the cross-category uniformity from the previous
fix is preserved.
Net effect: 1-2 cards on a 3-col viewport still shrink (no card-shaped
hole), 3+ cards reach the right edge of the layout, every card lines
up across categories regardless of which branch fires.
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>
Two bundled UI fixes.
1. Tools page Run / destructive buttons — the base recipe in tools.css
(rgba green/red 0.12 bg + 0.30 border + full-saturation text) reads
muddy against Nebula's cosmic gradient, same readability problem
.install-btn / .uninstall-btn had before the nebula overrides bumped
them to 0.35/0.65 with --text-primary text. .tool-run-btn and its
.destructive variant now ride those same overrides so Run pops as
green-tint and the dangerous variant pops as red-tint, both with
neutral text against the gradient.
2. Services tab row — the "Logs" button now reads "Details" because
that's what it actually toggles (meta + rich detail + log toggle).
The data-action moves from toggle-logs to toggle-details, and the
expanded panel no longer auto-opens a log stream. A small footer
"Show logs" / "Hide logs" toggle at the bottom of the open panel
explicitly opts in to tailing, kicking off the existing SSE stream
on click (auto-updates while shown). Closing the parent details
panel also resets the log block back to its hidden state so the
next reopen starts clean. app-tabbed-manager's task-running button
disable was taught about the new actions so they stay clickable
while a long task is running.
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 auto-fill minmax(300px, 1fr) template stretched cards to fill the
glass box, so a 2-card category landed at ~301px each (the box
shrunk-and-stretched to a hair over 2*300) while a 3-card category
(box now full-width) landed at ~323px each. Cards visibly didn't
align between categories — the user spotted the 22px difference.
Switching the grid template to fixed-width tracks
(repeat(auto-fill, var(--app-min))) means cards are always exactly
--app-min (300px / 280px at ≤1024) regardless of how many are
visible. Card positions and widths line up across every category.
The natural-columns sentinel from the previous pass is no longer
load-bearing — with fixed-width cards, "full width" at high N gives
no extra card-width benefit, only trailing space inside the box.
updateAppsCount drops the measurement step and just sets the visible
count, letting the formula shrink the box around the cards.
Signed-off-by: librelad <librelad@digitalangels.vip>
Previous cap shrank the box to "exactly N cards at min width", which
made 3 cards sit a few pixels short of the layout edge on a 3-column
viewport while 4 cards (which wraps internally) ran edge-to-edge —
visually inconsistent and the user flagged the gap.
updateAppsCount now measures the parent's available inner width
(minus the section's own 90px overhead: 22 margin + 22 padding + 1
border, doubled) and computes the natural column count the auto-fill
grid would pick at full width. If visible cards >= that count, the
function passes a sentinel (99) as --app-count so the formula
overshoots the 100%-44px parent cap and yields the layout-edge box.
Otherwise the cap still kicks in to hide card-shaped holes for 1-2
cards.
Also wired a window resize listener in the constructor so dragging
the window, snapping it, or opening devtools re-evaluates the
decision — the natural column count is viewport-dependent.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two follow-ups to the dynamic-width change:
1. The box was centred (margin: 22px auto), which moved cards out of
their original left position whenever the cap kicked in. Revert to
margin: 22px so the cards keep their left X — the box just shortens
on the right when there are few visible cards.
2. The formula assumed content-box, but style.css:4 sets
* { box-sizing: border-box } globally. With border-box max-width is
the outer width, so a 2-card cap of 664px gave content = 664 - 44
(padding) - 2 (border) = 618, just under the 620 needed to keep
2 columns at minmax(300px, 1fr) with gap 20 — grid silently dropped
to 1 column and the cards stacked. Formula now adds 46px (padding
+ border) plus 2px of sub-pixel buffer, so 2 cards have 622px of
content and reliably stay on one row.
Signed-off-by: librelad <librelad@digitalangels.vip>
Restores the per-app snapshot list (regressed during the backup-system
revamp) and rebuilds it on the same .task-item visual the Services tab
uses, so the two app-page tabs read as a matched pair. Wires the three-
level navigation the user asked for end-to-end:
/backup global dashboard + snapshot table
└─ click app tile → /app/<name>/backups
└─ click any snapshot row expands to detail in place
└─ click App / ID cell → /app/<name>/backups?snapshot=<id>
(auto-expands + scrolls + flashes)
Per-app Backups tab (BackupAppCard):
- Snapshots render as task-item rows: app icon, "12h ago" title,
location pill, full timestamp chip, short-ID monospace chip,
Restore + Details actions.
- Click the row header (or "Details") to toggle a .task-details panel
showing snapshot ID, location, full timestamp, host, tags, and the
paths the snapshot covers.
- Shows up to the 50 most recent; >50 surfaces a hint to the global
backup center for the full list.
- flattenSnapshots() now carries hostname/tags/paths through so the
detail panel has real content.
Cross-page navigation:
- Dashboard app-tile click navigates to /app/<name>/backups instead of
opening the pick-now modal. The pick-now action is preserved as an
explicit "Back up" pill that appears top-right on hover/focus.
System tile keeps the old modal click (no dedicated page yet).
- Global Snapshots table — the App and ID cells are now SPA-routed
links to /app/<name>/backups?snapshot=<id>. Snapshots without an
app=<slug> tag (system backups) stay plain text. Routed via
navigateToRoute so the SPA mounts in place instead of a full reload.
Deep-link mechanism:
- BackupAppCard._honorSnapshotDeepLink reads ?snapshot=<id> on render,
finds the matching .backup-snapshot-item, opens its details, scrolls
it into view, and applies a brief .backup-snapshot-flash (animated
box-shadow pulse) so the user's eye lands on it after the SPA jump.
CSS:
- backup.css gains .backup-snapshot-rows, the location pill, the
monospace ID chip, the tag chips, the deep-link flash keyframes,
the tile "Back up" pill (.backup-app-tile-action — only visible on
hover/focus to keep the dashboard calm at rest), and the dashed
underline link style for the snapshot-table deep-link cells.
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 the install-time Beginner/Advanced choice the user described, with
the linked dev-mode escape hatch and global body-class machinery that
any surface can hang advanced/dev-only DOM off.
Three-tier mental model, two flags in the data model:
Beginner default. nothing extra shown.
Advanced .lp-advanced DOM revealed; advanced wizard steps shown
Adv+Dev .lp-dev DOM also revealed; dev-only fields visible
Linking rule (enforced inside LpUi):
- enabling dev auto-enables advanced (dev w/o advanced is incoherent)
- disabling advanced auto-disables dev
Wire shape:
CFG_INSTALL_LEVEL beginner | advanced (general_basic)
CFG_DEV_MODE existing, unchanged behaviour
window.LpUi.{advanced,dev} {get(), set(), apply()}
localStorage keys lp.ui.advanced, lp.ui.dev, lp.ui.seeded
body classes lp-ui--advanced, lp-ui--dev
events lp-ui-advanced-changed, lp-ui-dev-changed
global CSS gates body:not(.lp-ui--advanced) .lp-advanced { hide }
body:not(.lp-ui--dev) .lp-dev { hide }
Setup wizard:
- New step 1 "Choose your experience" with Beginner/Advanced cards.
Beginner is preselected so race-through gets the safe default.
- Picking a level updates totalSteps live (4 for beginner, 5 for
advanced) so the progress bar reflects the choice.
- Metrics step (Prometheus + Grafana) is gated to Advanced — beginner
never sees it, never gets asked, never installs them by accident.
- Submit payload now carries install_level; setup-routes.js validates
it against the enum (beginner|advanced).
- scripts/setup/setup_apply.sh writes it to CFG_INSTALL_LEVEL via
updateConfigOption.
- On submit, LpUi.advanced.set is called immediately so the next
surface (running-tasks page) is already in the right mode — no
refresh needed.
WebUI bootstrap:
- js/utils/lp-ui.js loads first thing in index.html (before any other
bootstrap) so body.lp-ui--advanced is applied pre-paint — no FOUC
of advanced content on a fresh tab.
- On first run, seeds lp.ui.advanced from CFG_INSTALL_LEVEL.
Subsequent loads honour the user's per-browser override.
- Mirrors CFG_DEV_MODE → lp.ui.dev on the seed pass.
Dev-mode unlock:
- Existing 10-click LibrePortal-logo easter egg unchanged.
- NEW: same 10-click unlock on the Advanced toggle (in services-manager).
Reuses the countdown-toast pattern; on the 10th click delegates to
the topbar's _setDevMode so there's one canonical setter and the
config_update task path stays singular.
- TopbarComponent now exposes its instance as window.topbar so the
toggle's tap handler can reach _setDevMode.
- topbar._setDevMode also calls LpUi.dev.set(enabled) so the body
class flips immediately (no reload needed to see dev-only DOM).
Convention rolled out:
- Services tab's .service-rich panel was already gated on
body.lp-ui--advanced.
- .lp-advanced / .lp-dev are now first-class hide classes any
component can tag DOM with — see style.css globals.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two reasons the back button was unreliable:
1. The very first history entry (the URL the user landed on) had
state: null because handleInitialRoute() called navigate(path,
false), and the pushState branch only ran when addToHistory=true.
When the user later pushState'd forward and then hit back, the
popstate handler's guard "e.state && e.state.route" was false on
the initial entry, so it silently did nothing — back appeared
broken. Now navigate() replaceState's the current entry whenever
addToHistory=false, so the initial entry (and any back-compat
URL rewrite) always carries its route. The popstate handler also
now falls back to window.location when state.route is missing,
so third-party history manipulation can't break us.
2. Open SSE streams (LiveSystem, taskEventBus, services-manager log
tails) block the browser's back-forward cache. Without BFCache,
back has to fully re-mount the page instead of restoring it
instantly the way Amazon/GitHub feel. Now pagehide closes every
live bus we own, and pageshow(persisted=true) reopens them when
the page is restored from BFCache. Log tails aren't auto-resumed
— Resume overlay handles that if the user comes back to a
services tab.
Public surface added: LiveSystem.pause()/resume() and
ServicesManager.pauseStreams(). TaskEventBus already had stop()/
start(). The legacy-URL rewrite in handleAppDetail also now
replaceState's with { route: canonical } instead of {} so the
stamp is consistent across all internal history updates.
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds a foundational global UI-mode flag — Beginner (default) vs Advanced —
gated by a single toggle on the Services tab title row. First foothold
of a project-wide pattern: any surface that grows extra-technical detail
(mounts, limits, internals, raw IDs, …) will gate it on the same flag,
so a newcomer doesn't see a wall of operator information while a power
user gets everything site-wide with one flip.
How it's wired:
window.LpUi.advanced — { get(), set(on), apply() }
localStorage key — lp.ui.advanced ('0' | '1')
body class — lp-ui--advanced
event — window 'lp-ui-advanced-changed' { advanced }
Surfaces gate their advanced-only DOM via CSS:
body:not(.lp-ui--advanced) .service-rich { display: none; }
So flipping the toggle is instant and DOM-free — no re-render needed.
The Services tab's rich container panel (limits, image, healthcheck,
networks, mounts) is the first thing behind the flag; live CPU%/memory
chips in each row stay visible always because they read just as easily
as a status colour and are useful to everyone.
Title row gets a small slider toggle styled in the project's accent —
unobtrusive, labelled "Advanced". Default OFF (Beginner).
The same _renderRow reorders the log block above the rich-detail block
inside .task-details, so when Advanced is on AND a row is expanded, the
live log appears right where the "Logs" click landed rather than below
a wall of metadata. Helps with the old simple-click feel even when the
extra detail is showing.
Plumbed deliberately to be project-wide so the upcoming first-install
"Beginner vs Advanced" wizard step can seed the flag (planned:
CFG_INSTALL_LEVEL in general config → emit body class server-side at
template render time → no FOUC on a fresh load).
Signed-off-by: librelad <librelad@digitalangels.vip>
The Admin → System area was growing a parallel per-container surface
(/admin/config/system/app/<name>) alongside the existing per-app Services
tab on the app page. Two pages onto the same thing is the kind of
duplication that rots fast — they drift, users have to remember which
one to use, and the next person adding a feature has to decide twice.
This commit consolidates onto the existing Services tab (which already
has compose-service awareness, docker socket access, restart actions via
the task system, and live log streaming) and decommissions the parallel
admin sub-page:
- Delete system-app-page.js and its lazyLoad entry. The dispatch in
admin-system.js for the 'app' view now redirects to the app page's
Services tab so old bookmarks still resolve cleanly.
- System index per-app rows navigate to /app/<name>/services (not
/admin/config/system/app/<name>) and the row hint copy is updated
to match.
- Services tab gains the rich container detail the old admin page
rendered, fed by /api/system/containers + /containers/:id +
/containers/:id/stats:
* Inline live chips in each service header: CPU% and memory
(with limit + percent if a limit is set). Memory chip flips
amber at 80% and red at 95% of the configured limit.
* New "service-rich" panel inside the existing expandable
details section (above the log block, so the existing Logs
toggle reveals both):
- Image + image-id + uptime + restart count
- Memory / CPU / PIDs limits + restart policy
- Healthcheck pill + last 3 probes (collapsible per-probe)
- Networks table (name, IP, gateway, MAC)
- Mounts table with type badges (volume/bind/tmpfs)
* Live stats refresh every 5 s; existing status refresh stays
on 10 s. Both gated on the Services tab being active.
- Backups for the app already live on the existing /app/<name>/backups
tab (loadAppBackups → BackupAppCard.render), so the navigational
promise of "one place per-app" is already met — System index just
needed to route there.
- CSS: services.css picks up .service-live-chip (with warn/danger
colour cues) and the full .service-rich block (grid, tables, mount
badges, healthcheck pills).
Signed-off-by: librelad <librelad@digitalangels.vip>
Promotes the admin → System area from a single index page with a transient
overlay into a real router with four addressable sub-pages, plus a docker-
api-backed read surface to drive them.
URLs:
/admin/config/system index (gauges + trends + per-app table)
/admin/config/system/metric/<key> single-metric deep-dive
/admin/config/system/app/<name> per-container app deep-dive
/admin/config/system/storage docker disk-usage breakdown
The path resolves to category=`system` in adminCategoryFromPath, so the
existing SPA dispatch still drops you into AdminSystem; AdminSystem then
reads the rest of the path and mounts the right sub-renderer into
config-section. Each sub-page owns its own DOM + lifecycle and is disposed
when the orchestrator re-mounts on the next navigation. Browser back, page
reload, and shareable URLs all work — no modal, no overlay state, no
fragile open/close lifecycle. Esc on the metric page navigates back to the
index.
Backend (containers/libreportal/backend):
- utils/docker.js — shared client for the bind-mounted Docker socket
(extracted from service-routes.js' inline copy). dockerRequest,
dockerStream, and a multiplex-log decoder for /containers/:id/logs.
- routes/docker-info-routes.js mounted at /api/system, contributes:
GET /containers full list, plus grouped-by-app shape
GET /containers/:id inspect projection (limits, mounts,
networks, ports, health, restart count)
GET /containers/:id/stats one-shot CPU% / memory / network /
blkio / pids (derived from precpu/cpu
deltas, like `docker stats`)
GET /containers/:id/logs last N lines, multiplex-decoded
GET /storage `docker system df` rolled up per
category, plus top-10 images +
top-10 volumes by size
Frontend (containers/libreportal/frontend/js/components/admin):
- admin-system.js — refactored into orchestrator + index view. _parsePath
drives dispatch; sub-views are window.SystemMetricPage /
SystemAppPage / SystemStoragePage classes mounted into config-section.
The per-app table is now keyboard-focusable rows that navigate to the
per-container page; the Docker strip grows a "Storage" tile that
navigates to the storage page.
- system-metric-page.js (renamed from system-detail.js, rewritten as an
in-flow page renderer). Same chart visuals as the old overlay — grid,
axis, area gradient, peak/min/now markers, hover crosshair + tooltip
scrubbing, per-metric accent theming — but rendered into the page
instead of a fixed-position panel. Range picker reflects to ?range=
so refresh preserves the selection. 1 Hz SSE feed splices into the
chart tail in real time.
- system-app-page.js — for each container in the app stack: status,
image, image-id, uptime; live stats card (cpu / mem with limit-pct /
rx / tx / blkio r-w / pids, polled every 2s with warn+danger colour
cues at 80% and 95% of memory limit); limits panel (memory, cpu,
pids, restart policy, restart count, started-ago); healthcheck
status + last 3 probes; networks table (name, IP, gateway, MAC);
published ports; mounts table with type badges; collapsible log tail
with refresh.
- system-storage-page.js — donut chart (cumulative-arc, hand-rolled
SVG) splits total in-use disk by images / volumes / containers /
build cache; per-category cards with size + reclaimable; top-10
images and top-10 volumes tables with "unused" / "orphan" badges.
CSS (containers/libreportal/frontend/css/admin.css):
Overlay-specific rules (.sys-detail wrapper, backdrop, panel, close
button, body lock) removed. Inner chart rules (stats grid, svg, grid,
axes, peak/min/now, crosshair, tooltip, foot) retained and reused by
the metric page. New blocks for .sys-metric-page, .sys-app-page (with
stat warn/danger colour states, health pills, mount-type badges, log
pre styling), .sys-storage-page (donut + legend + headline + per-
category cards + orphan/unused badges), .sys-app-row (clickable
rows with arrow + accent hover), .sys-stat-link (clickable Docker
strip tile).
Signed-off-by: librelad <librelad@digitalangels.vip>
The task-event bus translates the backend's task.upsert SSE events into
window-level taskCreated / taskUpdated / taskCompleted CustomEvents. It
fired taskCompleted whenever a task's current status was terminal AND
the previously-known status was not — including the case where the bus
had never seen the task before at all (prevStatus undefined → wasTerminal
false → "transition" detected).
Why this misfired: the backend re-broadcasts the full task object on any
inode change to the task file, not just on logical status changes. The
periodic ownership/permission repair sweep (crontab_check_processor.sh)
chowns the entire tasks directory, which bumps ctime on every task file
and trips fs.watch, which broadcasts task.upsert for each one. If the
page was loaded after a task had already finished, the bus saw that
task for the first time as already terminal and fired a "task completed"
toast — for tasks that completed minutes or hours earlier.
Fix: when an upsert is for a task the bus has never seen AND that task
is already terminal, bootstrap silently. We have no evidence the task
transitioned now — it might have transitioned hours ago. The real
running→terminal transition (bus knew about the task while it was
running, then receives a terminal upsert) still notifies, which is what
users actually want to know about.
Signed-off-by: librelad <librelad@digitalangels.vip>