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>
The existing --skip-docker-images flag keeps a lot more than just images:
the docker-install user, the rootless dockerd, the rootless sysctl
drop-ins, AND the image/build cache. So a reinstall after using it
already skips the slow `dockerd-rootless-setuptool.sh install` step —
which is the meat of why anyone reaches for this flag on a local dev
loop. The name "--skip-docker-images" undersells what it actually does
and "skip the rootless install" is the user-facing intent.
Adds --skip-rootless as an alias of --skip-docker-images (same flag
variable, no behaviour change). Both spellings continue to work — anything
scripting the old name keeps working — but the help text, examples, and
the uninstall printf now use the clearer --skip-rootless. Same name
shift in scripts/update.sh: SKIP_ROOTLESS=1 is the new env-var spelling,
SKIP_DOCKER_IMAGES=1 is the back-compat alias.
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>
Replaces the JSON history file behind /api/system/history with a fixed-size
binary ring buffer on disk and adds a second, downsampled tier so the chart
can now span seven days, not just twenty-four hours.
Two on-disk rings under frontend/data/system/:
metrics_ring_1m.bin 1440 pts @ 1 min ( 24 h)
metrics_ring_5m.bin 2016 pts @ 5 min ( 7 d)
Each point is 32 bytes (uint32 timestamp + 7 float32 metrics — cpu / mem /
swap / disk / load1 / net_rx / net_tx); files carry a 32-byte header with
magic, version, capacity, head, count, bucket seconds, and last bucket time
so they're self-describing and torn-write recoverable.
A persistent 1-minute ticker inside the backend (independent of whether
anyone's subscribed to /api/system/stream) composes points from /proc plus
the bash generator's latest snapshots and appends to the 1m ring; every
five minutes it averages the last five 1m points into the 5m ring. On
first run, the writer backfills the 1m ring from the legacy
metrics_history.json so first paint already has 24 h.
/api/system/history?range=N auto-selects the tier (≤1440 → 1m, else 5m),
keeps the existing { points, updated } shape, and additionally returns
`tier` for clients that care. Falls back to the legacy JSON on cold start.
Admin → System: 7d added to the range picker (now 1h / 6h / 24h / 7d),
swap + load1 promoted to their own trend cards, and every gauge / chart
card grows an Expand affordance that opens a fullscreen single-metric
deep-dive overlay:
- Big themed chart with grid, gradient area, peak/min/now markers, and
a live-pulsing "now" dot
- Hover crosshair + tooltip scrubs the series with formatted time +
value
- now / peak / avg / min stat strip with deltas
- Range picker (1h / 6h / 24h / 7d) re-fetches and re-themes per metric
- 1 Hz live SSE feed updates the overlay's now-stat in real time
- Escape / backdrop / close button all dismiss
- Per-metric accent colour (cpu=accent, mem=info, disk/swap=warning,
net_rx=success, net_tx=accent, load=accent) flows through gradient,
border, dot, and stats card
Zero new dependencies — hand-rolled SVG and pointer events throughout.
Signed-off-by: librelad <librelad@digitalangels.vip>
The glass box was a CSS Grid with auto-fill columns of minmax(300px,
1fr), so it always painted across the full content area. With only 2
apps on a wide row the third/fourth column slots remained inside the
border as empty space — visually a card-shaped hole.
Drive the box's max-width off a --app-count CSS variable, capped at
(100% - 44px) so it can't escape the layout's symmetric 22px gutter.
margin: 22px auto keeps the horizontal padding symmetric in both the
capped (auto-centers the smaller box) and full-width (auto collapses
to 22+22) cases. --app-min (300/280 at the ≤1024 breakpoint) feeds
both the grid template and the cap formula so the responsive column
width stays a single source of truth.
apps-manager.js sets --app-count to the count of visible .app-card
elements after every render and after the sidebar search filter, so
filtering down to 2 hits also collapses the box. Floor of 1 keeps the
empty state usable.
Mobile (≤768) overrides max-width to none — single column already
fills, and the 10px gutter shouldn't be auto-centered.
Signed-off-by: librelad <librelad@digitalangels.vip>
The previous commit added body.has-dev-banner shifts for .sidebar and
.apps-layout assuming they were position:fixed top:60 like the topbar.
They aren't — on desktop both sit in flex flow (.sidebar is
position:relative, .apps-layout is just a flex container), so
top:96px pushed the sidebar 96px down from its natural slot, leaving
a big visible gap above the category list.
Scope the sidebar nudge to the mobile media query where it actually
becomes fixed (also covers .sidebar-container, the unified apps
layout's mobile drawer). Replace the wrong .apps-layout top rule with
a height tweak — it sizes itself off (100vh - 60px) and was overflowing
the viewport by 36px when the banner was on; calc(100vh - 96px)
accounts for the banner.
Topbar shift (top:0 → 36) stays unchanged; that one was correct.
Signed-off-by: librelad <librelad@digitalangels.vip>
Banner was fixed at top: 60px (just below the 60px-tall topbar) at
z-index 999 — same vertical band as the sidebar (top: 60px, z-index
100) and the apps-layout subnav, so it covered the top 36px of both
when dev mode was on.
Moved to top: 0, z-index 1001 (above the topbar). When the banner is
visible, body.has-dev-banner now also shifts every other fixed-
positioned chrome element down by the banner's 36px:
.topbar 0 → 36
.sidebar 60 → 96
.apps-layout 60 → 96
.mobile-drawer 60 → 96 (already had this override)
Body padding-top stays at 96px (banner + topbar) — content offset is
unchanged. Standard environment-banner placement (Stripe test-mode,
GitHub staff-mode) and makes "you're in dev mode" actually visible
above your nav.
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds /api/system/stream — a Server-Sent Events feed driven by a single
per-process ticker that reads /proc directly and splices in the latest
host-side metrics.json each second. Subscribers share the connection so
N open tabs cost one ticker, and the ticker pauses entirely when nobody
is listening.
Frontend gets a singleton LiveSystem EventSource manager with auto-
reconnect, Page-Visibility integration (closes on tab hide), and last-
sample replay for late subscribers. Admin -> System gauges and the
dashboard memory + disk tile now tick at 1 Hz; trend charts and the
per-app table keep their 30 s poll because the underlying files only
regenerate once a minute.
Also adds /api/system/history as a thin range-query wrapper over the
existing 24 h JSON ring buffer — the binary ring backend will slot in
behind it in the next phase without changing the response shape.
Signed-off-by: librelad <librelad@digitalangels.vip>