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>
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>
Old: "Backup status — system config + every app — at a glance."
New: "Check what's protected — and when it last ran."
The em-dash chain was filler and "at a glance" was redundant on the
dashboard tab (which is the at-a-glance view). New copy leads with
what the admin is here to do.
Signed-off-by: librelad <librelad@digitalangels.vip>
The custom-drawn green box + white tick was reading too utilitarian
against the row's other buttons (and the tick itself had defaulted
black against the dim green fill, hard to spot). Switches both
.task-select-box (per-row) and the master Select-all to the same
chrome the setup wizard uses for its app-pick cards:
- accent gradient fill on :checked (was status-success)
- 12px white SVG checkmark (inline data: URL, same one as
.setup-app input[type=checkbox]:checked::after)
- subtle inset border at rest, accent glow on hover/focus
- 0.22s setupCheckPop / taskCheckPop pop-in on tick
- indeterminate state on the master shows a horizontal dash,
drawn from a second inline SVG (still white on accent)
Sized to 18px so the row checkbox sits clean alongside the 22px-tall
.task-btn buttons. The master in the action bar reuses the same box
spec (no separate variant), matching the wizard's "one checkbox style,
many places" pattern.
Signed-off-by: librelad <librelad@digitalangels.vip>
Clicking the LibrePortal logo 6→9 times spawned four separate
"X clicks away from being a developer" notifications stacked on top of
each other — visual noise for a delightful-bonus interaction.
Now the easter egg keeps a single reference to its current toast and
mutates the `.notification-message` text in place on each subsequent
click. When the toast's 10s auto-remove timer expires mid-sequence
(slow clicker) the next click opens a fresh one — same fallback for
the idle-reset path that clears the count after 3s.
`_devToast` now returns the notification element so the easter-egg
handler can grab it; previously it returned undefined, fine for the
one-shot toasts but no longer enough for the rolling-update pattern.
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds per-row checkboxes (right of the Delete button, per request), a
master "Select all" toggle in the action bar, and morphs Clear All into
"Delete Selected (N)" the moment 1+ rows are ticked. Both paths go
through the same _showClearAllModal redesigned in 1ccc4bb — same UX,
same "Cancel running too" toggle, same logic; only the title + eyebrow
shift to reflect which mode the user came in through:
all → "Delete all N tasks?" eyebrow "Delete Tasks"
selected → "Delete N selected tasks?" eyebrow "Delete Selected"
State lives in this.selectedTaskIds (Set<string>). The row checkboxes
fire toggleTaskSelection(id, checked); the master fires toggleSelectAll
which ticks/unticks every visible row's checkbox in one pass (visible,
not all-of-this.tasks — so category filters DTRT).
_updateSelectionUI() reconciles three things on every change:
- the Clear All button label + title attr
- the master checkbox's checked/indeterminate state (some-but-not-all
visible → indeterminate dash, all → checked, none → unchecked)
- hooked into renderTasks() so category-switches don't leave stale
UI
performClearAll(opts) now accepts opts.targets — the subset to operate
on. clearAllTasks() passes either the selection or this.tasks depending
on mode. The active-task cancel-or-skip logic (cancelRunning toggle) is
unchanged — runs identically over the smaller set.
CSS:
.task-select — 22×22 framed checkbox matching the .task-btn
buttons it sits next to (border, hover green,
focus outline)
.task-select-box — custom box with check + indeterminate dash
drawn via ::after, no SVG dependency
.task-select-all — text-style toggle in the action bar with the
same custom box
No new globals. Hooked up via the existing window.tasksManager.
Signed-off-by: librelad <librelad@digitalangels.vip>
.task-command was still using var(--status-success) (#28a745) which reads
muddy olive against the nebula gradient — the same dimming the status
pills and apps-installed pill already work around with #86efac. The
empty-state row ("$ No tasks found …") was the most visible offender.
Switches .task-command to the same bright mint already used elsewhere.
Same edit, while I was there: the empty-state copy interpolated
categoryName.toLowerCase() as `No ${cat} tasks found`, so the "All Tasks"
category produced "No all tasks tasks found". Special-cases the all
bucket and strips the trailing word when the category name already
includes it ("Running Tasks" → "No running tasks found", not "running
tasks tasks").
Signed-off-by: librelad <librelad@digitalangels.vip>
The Clear All confirmation was the last destructive task action still
running through window.showConfirmation (the legacy dialog system) —
visually inconsistent with the rest of the tasks page (single-row delete,
Uninstall, etc., which all use openEoModal). Switches it to the same
eo-modal shape used by _showDeleteTaskModal so the destructive-confirm
family looks unified.
While here, adds a "Cancel running tasks too" toggle inside the modal,
off by default. Backed by the existing .eo-toggle.eo-toggle-card style
(modal.css). Drives a new opts.cancelRunning in performClearAll:
Off → skip any running/queued/pending tasks; only terminal rows
are deleted. The success toast reports the split.
On → cancel each active task first (POST /cancel), wait for the
terminal status via SSE, then delete (with the 409→force
fallback the single-row deleteTask already uses).
Body composition mirrors the per-task delete modal:
- danger empty-state ("This cannot be undone")
- badge row with Total / Running / Terminal counts
- the toggle (only shown when runningCount > 0 — no need otherwise)
The action button's label live-updates as the toggle changes:
toggle off + running rows → "Delete N (skip M running)"
toggle on / no runners → "Delete N Tasks"
So the user sees exactly what they're about to do before clicking.
Cancel / backdrop / X all resolve to no-op (same contract as
_showDeleteTaskModal). Modal returns {confirmed, cancelRunning} so the
caller knows which path to take.
Sets up the multi-select work next: the modal already accepts an
arbitrary tasks array; the upcoming "Delete selected" is a one-line
call into the same _showClearAllModal with a filtered list.
Signed-off-by: librelad <librelad@digitalangels.vip>
"Task deleted successfully" was a plain single-line toast while every
other task notification (started, completed, failed, cancelled) renders
as <App>-in-bold on the first line + "<Action> task <verb>" on the
second, with the app icon on the left and the per-action emoji as
custom-icon. Inconsistent.
Now reads e.g.
[🗑️] Ipinfo
Install task deleted.
with the ipinfo logo as the row icon, matching the install/completion
toast format.
Also factored the three duplicate "task → identity (display name + app
icon + friendly action title + emoji)" blocks (taskCompleted listener,
delete-modal title, delete notification) into one helper —
_taskNotificationDescriptor(task) — so the four surfaces (started,
completed/failed/cancelled, delete modal, delete notification) always
agree on what to call a task. Net -20 lines.
Signed-off-by: librelad <librelad@digitalangels.vip>
The Delete Task confirmation modal was rendering the raw command
("libreportal app install ipinfo") as its title with no app icon, while
the rest of the WebUI (task notifications, task rows) shows
"Install Ipinfo" and the ipinfo logo. Inconsistent and slightly
intimidating for a confirmation step.
Now mirrors the completion-notification flow:
- Title: `${formatActionTitle(task.type)} ${getAppDisplayName(task.app)}`
→ "Install Ipinfo", "Backup Nextcloud", etc.
- Icon: /icons/apps/<slug>.svg (or libreportal.svg for system tasks)
- Tool tasks borrow the same tool-catalog-lookup the completion toast
uses so a tool deletion reads as "Manage Shortcuts" rather than the
raw tool id.
Reuses the existing TasksManager.formatActionTitle() helper so any
future task type added to that map flows through here automatically.
Signed-off-by: librelad <librelad@digitalangels.vip>
The Features section was a grab-bag of ~27 toggles, most of which are
either category-specific (firewall, SSL, Docker network, SSH hardening)
or install-time choices that brick the box if flipped on a live
install (the WebUI / config / CLI / Docker requirements). One page
made auditing easier but flattened the risk hierarchy.
Reorganised so each toggle lives where it conceptually belongs, and
the dangerous install-time set is double-gated:
network_docker (Advanced) DOCKER_NETWORK, DOCKER_NETWORK_PRUNE,
DOCKER_SWITCHER
network_firewall (Advanced) UFW, UFWD, WHITELIST_PORT_UPDATER [new]
network_domains (field-Adv) SSLCERTS
security_ssh (Advanced) SSHKEY_DOWNLOADER, SSH_DISABLE_PASSWORDS,
BCRYPT_SAVE, GLUETUN_FOR_ALL [new]
general_terminal (Advanced) CRONTAB, CONFIGS_CHECK,
CONFIGS_AUTO_UPDATE, CONFIGS_AUTO_DELETE,
MISSING_IPS, CONTINUE_PROMPT,
SUGGEST_INSTALLS, SUGGEST_METRICS
general_install (Adv+DEV) CONFIG, COMMAND, WEBUI, WEBUI_SERVICE,
DATABASE, PASSWORDS, DOCKER_CE,
DOCKER_COMPOSE
The install-time eight are marked **ADVANCED** **DEV** — invisible
unless Developer Mode is on AND "Show Advanced Options" is expanded.
Each field's description was updated to note "Disabling on an existing
install will brick the system" / "install-time choice only" so a user
who does get to the toggle understands the gun before pulling the
trigger.
Other cleanup that fell out:
- Removed `configs/features/` directory entirely.
- Added the two new subcategories to SUBCATEGORY_ORDER in
network/.category and security/.category.
- Dropped the `category === 'features'` Danger Zone header special-case
in config-manager.js and its .danger-zone-section--header-only CSS
variant (sole user).
- Trimmed an obsolete "Edit the features config" notice in
check_requirements.sh.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two unrelated UI polish items.
1. Task notification titles. The "Config_update task completed!" toast
was leaking the literal task-type id because the friendly-name map
in the taskCompleted listener didn't list `config_update`,
`update_config`, or `system_update`, and the fallback only
capitalized the first letter. Same fallback was duplicated in
task-actions.js's started-toast path.
Extracted into `TasksManager.formatActionTitle(action)`:
- Adds the missing entries (`config_update`/`update_config` → "Update
Config", `system_update` → "Update System").
- Smarter fallback: snake/kebab → Title Case, so an unmapped future
type renders as e.g. "Foo Bar" instead of "Foo_bar".
- Both the started (task-actions.js) and completed/failed/cancelled
(tasks-manager.js) notification paths now route through it, so the
started/done pair always reads the same.
2. Dev-mode strip styling. Earlier amber-on-amber recipe read as a
warning state; the strip is just informational. Switched to a
neutral glass surface (rgba(text,0.04) + rgba(text,0.12) border),
text-primary copy, and the icon alone in var(--accent). Label is
now centered with the close button absolute-positioned on the
right (24px / 56px right padding so the centered text never sits
under the X).
Signed-off-by: librelad <librelad@digitalangels.vip>
Two small dev-mode UX changes.
1. Banner. When CFG_DEV_MODE is on, a 36px amber-tinted strip sits flush
under the topbar with "You are currently running in Developer mode"
and a dismiss X. Dismissal is remembered in localStorage and cleared
whenever dev mode is toggled back on, so re-enabling the mode brings
the banner back. Body picks up `.has-dev-banner` while visible to
bump padding-top by the strip's height (also adjusts the mobile
drawer's top/height).
2. Toast. The auto-enable message dropped the trailing
"Click the LibrePortal logo 10× to disable." — too noisy on every
git/local page load; the easter egg is still discoverable. New
message is just "Developer mode auto-enabled — you're on a <mode>
install."
Signed-off-by: librelad <librelad@digitalangels.vip>
Finishes the installApp refactor started in d941f59 (Wave A). Every app
whose <app>.sh was either pure boilerplate (Wave B) or boilerplate +
small custom logic (Wave C) now routes through the generic driver in
scripts/app/install/app_install.sh; bespoke logic moved to declarative
hooks in containers/<app>/scripts/<app>_install_hooks.sh.
Net: ~4,000 lines of duplicated 10-step sequence gone. From 31 per-app
.sh files (pre-Wave-A) down to 2 intentional keepers.
DELETED outright (pure boilerplate — driver replaces them identically):
jellyfin, mastodon, focalboard, ipinfo, speedtest, dashy, invidious,
nextcloud, ollama, vaultwarden, pihole
DELETED + hook-extracted (small bespoke step preserved in a hook):
bookstack, moneyapp, owncloud, trilium, searxng, gitea, headscale,
unbound, prometheus, grafana, gluetun, wireguard, jitsimeet, authelia,
traefik, adguard, onlyoffice
KEPT (intentional special cases):
crowdsec — host-app pattern (no docker compose, runs as apt+
systemd via installCrowdsecHost; uninstall/stop/
restart hooks already live in this file and are
invoked by dockerUninstall/Stop/RestartApp directly).
libreportal — WebUI bootstrap. Pre-compose image build + post-install
webuiLibrePortalUpdate + bootstrap-time suppression of
menuShowFinalMessages don't fit the generic flow.
Driver change — scripts/app/install/app_install.sh:
Moved monitoringToggleAppConfig "$app_name" "docker-compose.yml" from
the post-start integrations block into the install body at post-compose
(right after dockerComposeSetupFile, before docker-compose up). The
toggle edits the compose file on disk — running it after start meant
the container had already been brought up with the unmodified compose,
so the metrics endpoint wouldn't reflect CFG_<APP>_MONITORING until
the next restart. Matches the original ordering in every per-app .sh
that used to call it inline.
Hook surface (declare-f-gated, silent no-op when absent):
<slug>_install_pre before any install work
<slug>_install_post_setup after dockerConfigSetupToContainer
<slug>_install_post_compose after dockerComposeSetupFile (+ the
shared monitoring toggle on the compose)
<slug>_install_post_start after dockerComposeUpdateAndStartApp
<slug>_install_message_data echoes extra argv for menuShowFinalMessages
<slug>_install_post very last thing, after the final message
+ the existing _uninstall_pre/_post, _stop_post, _restart_post
Notable extractions:
bookstack — _install_post_start: probe :PORT_1/login until 200/302,
then `bookstack:create-admin` inside the container with
CFG_BOOKSTACK_ADMIN_{EMAIL,PASSWORD}; falls back to the
seeded admin@admin.com on timeout.
adguard — _install_post_start drives the wizard's HTTP API
(POST /control/install/configure) so the admin doesn't
click through five pages, then pins the admin bind back
to 0.0.0.0:3000 (matches the compose mapping) and health
checks. _install_message_data echoes user/password to
menuShowFinalMessages.
authelia — _install_pre requirements; _install_post_compose copies
configuration.yml + users_database.yml, substitutes
theme/domain/host, generates JWT/session/storage secrets,
toggles monitoring on configuration.yml; _install_post_start
argon2-hashes the admin password via the container, writes
users_database.yml, restarts; _install_post echoes creds.
traefik — _install_pre prompts for the LE email if CFG_TRAEFIK_EMAIL
is unset; _install_post_compose copies static + dynamic
configs, wires CFG_TRAEFIK_DASHBOARD_ACCESS (local-only /
domain-only / public), toggles monitoring on traefik.yml,
then traefikUpdateWhitelist + traefikSetupLoginCredentials.
wireguard — _install_pre host-conflict guard (/etc/wireguard/params);
_install_post_compose persists CFG_WIREGUARD_SUBNET,
resolves WG_HOST (domain+traefik → host_setup, else IP),
runs runAppCfg wireguard-ip-forward; _install_post_start
restarts after wg-easy installs its iptables rules.
jitsimeet — _install_post_setup downloads the tagged release zip from
GitHub; _install_post_compose mass-edits the .env and runs
gen-passwords.sh; _install_post_start rewrites nginx
default site to usedport1/2 + restart.
prometheus — _install_post_compose seeds prometheus.yml under
$containers_dir/prometheus/prometheus/; _install_post_start
sets 0777 on storage dirs so the container TSDB can write
regardless of host UID mapping.
grafana — _install_pre requirements; _install_post_start 0777 on
grafana_storage.
gluetun — _install_post_start refreshes the provider snapshot,
reattaches every routed app (the netns container ID is
stale after gluetun gets recreated), then prompts to
onboard any existing apps.
+ the smaller bookstack-shape extractions for owncloud (version scrape),
trilium / searxng (wait-for-first-boot-config), gitea (Prometheus
bearer token sync), headscale / unbound (config copy), moneyapp
(Auth.js AUTH_URL), onlyoffice (compose-resolved user/pass into the
final message).
Manifest + arrays regenerated. Verified end-to-end:
- bash -n on every hook file + the driver: clean
- Each hook file sources cleanly in a subshell, exposes only the
intended functions, flagged lazy-loadable (not eager)
- Smoke-stubbed install run for jellyfin (pure), nextcloud (pure),
bookstack (hooked), crowdsec (kept): correct dispatch in all cases —
deleted apps route to installApp, kept apps still hit their real
function
Signed-off-by: librelad <librelad@digitalangels.vip>
routing-manager.js read CFG_<APP>_HOST_NAME for its preview URL, but that
key was retired by the per-port subdomain refactor (2e4f420, 2026-05-22)
and no .config defines it anymore. The lookup always returned undefined,
so even with a configured domain the preview fell through to the
`<your-domain>` placeholder instead of showing the real host.
Now derives the preview from the port's own subdomain (parts[10] of
the 12-col PORT row), matching the canonical host_setup rule in
scripts/network/variables/variables_init_app.sh:
@ / root -> apex (`https://<domain>`)
set -> `https://<sub>.<domain>`
empty -> `https://<app>.<domain>`
Also adds `subdomain` to the port object emitted by _collectPorts so
this and any future per-row consumer can read it.
Signed-off-by: librelad <librelad@digitalangels.vip>
The 31 containers/<app>/<app>.sh files each defined install<App>() with
the SAME 10-step sequence — ~4,000 lines of duplicated boilerplate.
Replaces all that with one generic driver + hook surface.
scripts/app/install/app_install.sh:
installApp <slug> [config_variables]
— Dispatches on $<slug> (c/u/s/r/i) the same way the per-app .sh
files did. Same convention; dockerInstallApp's existing
`declare $app=i` callsite needs no change.
— Runs the standard sequence: dockerConfigSetupToContainer →
dockerComposeSetupFile → optional .env copy → fixPermissions →
dockerComposeUpdateAndStartApp → standard post-install steps
(appUpdateSpecifics, setupHeadscale, databaseInstallApp,
webuiContainerSetup, monitoring registration) → final message.
— Hooks (all declare-f-gated, silent no-op when absent):
<slug>_install_pre / _post_setup / _post_compose / _post_start
<slug>_install_message_data (echoes extra args for menu)
<slug>_install_post
<slug>_uninstall_pre / _post
<slug>_stop_post
<slug>_restart_post
Hooks live in containers/<app>/tools/<app>_tools.sh (auto-sourced
per the modular-per-app-tools convention).
function_install_app.sh:
When no install<App>() function exists, fall through to
`installApp <app_name>` instead of erroring. So an app with no .sh
at all becomes a zero-byte addition — drop in <app>.config +
docker-compose.yml + <app>.svg, done.
containers/linkding/linkding.sh:
Deleted (canary). Linkding's body was 100% standard sequence;
fallback handles it identically. Smoke-tested with stubbed helpers
— dispatcher fires, generic runs full flow, monitoring integration
+ final-message hook plumbing all intact.
Wave B (next): delete the .sh for every other 'pure-boilerplate' app
(~15 candidates per the survey). Wave C: extract custom logic from
the 7 fat apps into hooks before deleting their .sh.
Signed-off-by: librelad <librelad@digitalangels.vip>
The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:
- `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
`/admin/config/network`) and be `/app/adguard/tasks`.
- The config sub-tab (general / advanced / features / network / …)
had no URL representation at all — `showTab` was a pure visual
swap with no history push, so refreshing a deep config sub-tab
sent the user back to the default first category.
New URL shape:
/app/<name> → config tab, default sub-tab
/app/<name>/<tab> → non-config main tab (tasks, backups, …)
/app/<name>/config/<category> → config tab + specific sub-tab
…?task=<id> → optional deep-link to a single task
Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:
window.appPath(name, tab, sub, taskId) → URL
window.appPartsFromPath(pathname) → { app, tab, sub }
Every URL constructor in the WebUI was replaced with `window.appPath`:
spa.js — handleAppDetail back-compat redirect
app-tabbed-manager.js — getTabFromURL + new getConfigSubFromURL
(path first, ?tab= fallback for legacy)
updateURL + updateApp use appPath
the inline task-deep-link constructor
apps-manager.js — showAppDetail + showAppDetailWithConfig
showTab now pushes /app/<n>/config/<sub>
renderAppDetail picks the sub-tab out of
the URL on first load
4 fallback task-URL constructors
tasks-manager.js — completion-notification URL
task-actions.js — start-notification URL
notifications.js — 2 task deep-link URLs
Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.
Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
gluetun_providers.sh writes its working files ($raw, $headers) next to
$output_file, which lives at
containers_dir/libreportal/frontend/data/apps/generated/gluetun-providers.json
— dockerinstall-owned in rootless. The five rm -f calls on those paths
were unwrapped, so the manager running the script (e.g. from the
task processor) would get Permission denied — same class as the
updateConfigOption sed -i bug that was just fixed.
$tmp comes from mktemp (/tmp), so the rm -f for it stays unwrapped.
Audit context: this was the only remaining raw filesystem op against
container-tree paths in any containers/*/*.sh. The rest of the
container .sh files are clean — every sed -i / chmod / chown / cp / mv
is already routed through runFileOp or runFileWrite, and the
per-app install bodies delegate fs work to high-level helpers
(dockerConfigSetupToContainer, copyResource, dockerComposeSetupFile)
which themselves use the wrappers.
Hooks (<app>_migrate_pre/_post, restoreAppRunHook pre/post) are
present in the framework but unused by any app today — that's by
design (opt-in per-app). If a future app needs federation-key rotation
post-migrate, or a hostname rewrite that the generic URL-rewrite
layer doesn't cover, those slots are ready.
Signed-off-by: librelad <librelad@digitalangels.vip>
Started, completed, and failed task toasts were rendered by three
different code paths producing three different layouts:
• task-actions.js executeTask "App: AdGuard\n…task started!" (with type emoji)
• task-actions.js executeTaskMonitoring "App: AdGuard\n…task started!" (without type emoji) — dead code
• tasks-manager.js createAndExecuteTask "Task created: install adguard" (raw shape) — dead code
• tasks-manager.js complete/fail notif "App: AdGuard\n…task completed!" (with type emoji)
…plus the system-task path was reading the literal `'system'` slug into
the toast: "App: System / Config_update task started!" with a 404'd
/icons/apps/system.svg (the same bug renderTaskIcons had on the row
itself, fixed in 59ee92b).
Three changes:
1. Drop the "App: " / "System: " label prefix on every toast. The bold
line is now just the subject name (the row's title still carries the
semantic with its leading App-or-LibrePortal icon). Three tasks of
the same app no longer read like a column heading repeated.
2. Treat `appName === 'system'` as the LibrePortal sentinel everywhere
the toast renders — displayName resolves to "LibrePortal" and the
app-icon slot loads /icons/libreportal.svg. Mirrors the row-icon
fix in 59ee92b. The completion-path `isSystemTask` check now also
accepts `appName === 'system'` in addition to `setup-*` types.
3. Delete the dead code that produced the inconsistent shapes:
- executeTaskMonitoring in task-actions.js (no callers anywhere)
- window.createAndExecuteTask in tasks-manager.js (no callers; only
surviving reference was a stale comment in app-tabbed-manager.js,
updated to point at executeTask instead)
Net: every task toast in the WebUI now follows the same three-slot
layout — [type emoji] [app/LibrePortal logo] <strong>Name</strong> +
"Action task started/completed/failed/cancelled!".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Reshape the dashboard's Backup status grid into a click-to-pick UI:
- Removed the inline Back-up / Restore buttons from the System config
tile. Same shape as an app tile now; LibrePortal app icon instead of
the server-stack glyph.
- Grid is 2 columns (was auto-fill min 220px). Tiles are wider, read
better, and the System tile no longer needs to span a full row to fit
inline buttons.
- Click any tile (System or app) → opens a new "Back up" modal:
* System config first (key=__system__, LibrePortal icon)
* Every installed app, alphabetical
* Checkbox per row + 'Select all' / 'Clear' shortcuts
* The tile clicked is pre-ticked
- Confirm queues backup tasks:
* Everything ticked → single `libreportal backup all` (which also
runs `backup system`) — one task instead of N
* Subset → one task per ticked item (`backup system`
and/or `backup app create <slug>`)
Restore for System config used to live on the dashboard's inline
'Restore' button. It's now reachable via the Backups tab — system
snapshots appear in the snapshot list with the standard per-row
Restore action — same path apps already use. No new UI required;
just one fewer dashboard button.
Signed-off-by: librelad <librelad@digitalangels.vip>
The user Dashboard carried a small chevron link "Admin overview →" just
above the installed-apps grid. The topbar already has a top-level "Admin"
nav-item (topbar.html:34) that goes to the same /admin route. The
dashboard link was a redundant second entry point with no extra value;
removing it tightens the dashboard layout without losing navigation.
Drops:
- dashboard-content.html: the <a class="dashboard-admin-link"> block
- admin.css: the .dashboard-admin-link rule + :hover (now orphaned)
The /admin route, the topbar Admin nav-item, and the AdminOverview JS
component all stay as-is — only the dashboard-side entry point goes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Was: 'What's saved. Save System config first — if anything breaks, you
need it to get everything else back.' — read a bit kindergarten.
Now: 'Latest backup per app + System config. Back up System first —
it's needed to restore the rest.' — same info, tighter, still
reads at a glance.
Signed-off-by: librelad <librelad@digitalangels.vip>
The two-line hint under 'Backup status' was redundant — the System
config tile speaks for itself once it's there. Replaced with an ℹ️
tooltip on the heading (same pattern as 'Cross-host migrate' on the
Migrate tab).
Tooltip text deliberately plain: 'What's saved. Save System config
first — if anything breaks, you need it to get everything else back.'
No 'bare-metal restore' jargon, no 'snapshot' — the kind of sentence
that lands for someone who's never heard of either.
Signed-off-by: librelad <librelad@digitalangels.vip>
The dashboard had two parallel sections — 'Per-app status' (every app's
latest backup) and a standalone 'System config' card below it. Folded
them into one grid: a single 'Backup status' card with the System config
tile rendered FIRST, then every app tile.
Why first: a bare-metal restore needs the system config (CFG_* +
backup-location credentials) — without it the backups exist but the
keys to reach them don't. Putting it at eye-level above the app tiles
makes the dependency visible.
System tile reuses the .backup-app-tile shape: server-stack icon,
'System config' as the name, status dot + 'Last backed up X ago' /
'No backup yet'. Plus two compact inline action buttons (Back up /
Restore) on the right that wire into the same data-action handlers
the old standalone card used — no behaviour change, just the visual
container.
grid-column: 1 / -1 on the system tile makes it span the row so the
two action buttons fit alongside the meta text without crushing the
app-tile grid template.
Section header: 'Per-app status' → 'Backup status' + hint 'System
config and every installed app's latest backup. System config always
first — a bare-metal restore needs it.' Dashboard subtitle updated
to match.
Signed-off-by: librelad <librelad@digitalangels.vip>
`libreportal config update` and `libreportal system update` tasks are
submitted with `task.app === 'system'` (see task-actions.js' configUpdate
+ systemUpdate). renderTaskIcons hit its first branch on any truthy
task.app and built an <img src="/icons/apps/system.svg"…> which 404s
(there is no per-app icon called "system"). The onerror handler then
hid the broken image, so those task rows showed only the 🛠️ type emoji
and no LibrePortal logo — visually inconsistent with sibling system-level
rows like "LibrePortal - Finalize Setup" (which happens to carry
`app: 'libreportal'`, matching a real icon, and renders correctly via
the same branch).
Treat `app: 'system'` as a category sentinel rather than a real slug:
skip the per-app icon path, fall through to the system-task branch that
loads /icons/libreportal.svg directly. That icon is already shipped + the
data shape stays intact ('system' is the meaningful category, not a lie
about the app identity).
Net: "LibrePortal - Apply Configuration" and "LibrePortal - System Update"
now show the LibrePortal logo alongside their type emoji, matching the
Setup / Update / Backup-All rows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
formatCommandForUser was a 90-line if/else chain that grew one branch at
a time as new task shapes appeared. Three sites had escaped coverage and
fell through to the raw-command fallback:
libreportal config update '<changes>' → shown as the raw 50-char clip
libreportal peer add <name> <kind> … → same
libreportal regen webui --force → same
Restructure as a declarative `PATTERNS` array of `{match, title}` rows.
Each row is one regex + one title (string OR function for per-app rows
that extract the app slug). The matcher iterates once; first match wins.
Adding a new task shape is now a one-line append — no new code branch,
no copy-paste of the `if/match/return` boilerplate.
Behaviour-equivalent for every previously-formatted command (verified
by running 15 sample command strings through the new function against
the old expected titles); the three previously-broken ones now resolve:
libreportal config update CFG_DEV_MODE=true → "LibrePortal - Apply Configuration"
libreportal peer add Alice host … → "LibrePortal - Add Peer"
libreportal regen webui --force → "LibrePortal - Regenerate WebUI Data"
Plus a couple I noticed while in there:
libreportal backup system → "LibrePortal - Backup System Config"
libreportal peer remove / peer pair → friendly equivalents
The two non-table fall-throughs (the toolsCatalog-aware `app tool` lookup,
and the generic `libreportal app <action> <app>` map) stay inline since
they need richer logic than the table can carry — but everything else
lives in the one scannable list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>
What this delivers (Stage 1+2 of the dev-mode feature):
1. New `**DEV**` marker for config fields. Mirrors the existing
`**ADVANCED**` pattern: stays in the description string, frontend
strips it for display, presence flips a 'hide unless dev mode is on'
behaviour. Implemented in ConfigUtils.cleanDescription /
isDevField / isDevModeOn and in ConfigShared._filterDevKeys, which
the two generateFieldsForCategory* helpers now call before rendering.
2. New CFG_DEV_MODE field in configs/general/general_install. Visible
under Advanced; defaults to false. The canonical place to toggle
dev mode (the WebUI easter egg writes to it, the auto-detector
writes to it, and users can flip it directly here too).
3. Marked CFG_INSTALL_MODE and CFG_RELEASE_CHANNEL with `**DEV**`.
Normal users no longer see either field — they install Release-
Stable and that's the whole story. Devs see both with the
user-facing labels you asked for:
CFG_INSTALL_MODE Release - Stable | Git clone | Local folder
CFG_RELEASE_CHANNEL Release - Stable | Release - Bleeding Edge
(CFG_INSTALL_MODE label for the release option also renamed to match.)
4. 10-click LibrePortal-logo easter egg in topbar.js:
- Counter on any .libreportal-logo click; idle-reset after 3 s
- Toast countdown from click 6 ('4 clicks away from being a developer…')
- At 10: toggles CFG_DEV_MODE via the standard config_update task
(same path the Config form uses); shows '🛠️ Developer mode
unlocked. Reload to see the extra options.'
- Re-using the same logo when dev mode is on toggles it back off
('… away from disabling developer mode') — symmetric, no separate UI
5. Auto-detect: on every WebUI load, if CFG_INSTALL_MODE is git or
local AND CFG_DEV_MODE is off, auto-flip to on with a one-time
toast 'Developer mode auto-enabled — you're on a git install.
Click the LibrePortal logo 10× to disable.' Stops dev-install
users getting locked out of the very options they need to manage
their install. Idempotent — runs once per page load; no-op if
already on or on release.
Disable surfaces: (a) CFG_DEV_MODE in Advanced on the Config form is
the canonical toggle; (b) 10 more logo clicks. A 3rd surface (a System
page banner) is deferred — those two cover the practical cases.
Signed-off-by: librelad <librelad@digitalangels.vip>
The 1h max-age set in Phase A caused a cache-vs-deploy mismatch when
Phase B refactored config-manager.js to lazy-load admin-overview.js et
al. The new index.html no longer eager-loads those scripts, but
browsers with the cached (pre-Phase-B) config-manager.js didn't do the
lazy-load either — so AdminOverview / AdminSystem / etc. were
undefined and the admin tools rendered 'failed to load' errors.
60s is the right balance: rapid in-session clicks skip the network
round-trip, but a deploy is visible within a minute. ETag-based 304s
still keep the per-request cost tiny when nothing changed.
Signed-off-by: librelad <librelad@digitalangels.vip>
7 page-specific controllers were eager-loaded in index.html on every cold
visit, even when the user lands on /dashboard and never opens /backup,
/admin, etc. Moved them to lazy-load via spa.js's existing loadScript()
helper, fired from each route's handler on first navigation:
/js/components/backup/backup-page.js — handleBackup()
/js/components/backup/backup-app-card.js — handleBackup()
/js/components/ssh/ssh-page.js — config-manager ssh-access
/js/components/peers/peers-page.js — config-manager peers
/js/components/admin/admin-overview.js — config-manager overview
/js/components/admin/charts.js — config-manager overview
/js/components/admin/admin-system.js — config-manager system
config-manager.js gets a tiny `lazyLoad` helper that delegates to
window.spaClean.loadScript with a graceful fallback when the SPA hasn't
booted (legacy paths). loadScript is idempotent — subsequent visits to
the same route are no-ops, so we don't re-fetch after the first nav.
Cold-load impact on /dashboard (the most common landing):
Before: 25 sync <script> tags loading ~1.7 MB raw / ~430 KB gzipped
After: 18 sync <script> tags loading ~1.5 MB raw / ~380 KB gzipped
+ corresponding parse-cost reduction on the client (no longer parsing
backup-page.js + apps-related JS just to render the dashboard)
Page-specific JS still loads cleanly when the user navigates there — a
single extra network round-trip per route on first visit, then cached
for 1h (per Phase A's cache headers). Compression (Phase A) means the
deferred JS is ~75 % smaller on the wire than it would have been
pre-Phase-A.
Sister update to .../Scripts/update.sh: rsync now uses --delete so
file removals in the source tree (this commit deletes 7 script tags;
earlier commits deleted config-manager-old.js) propagate to the live
install. Excludes still protect frontend/data/.
Signed-off-by: librelad <librelad@digitalangels.vip>
Three WebUI cold-load wins:
1. DELETED containers/libreportal/frontend/js/components/config/config-manager-old.js
66 KB / 68189 bytes. Zero references anywhere in source or deployed
tree (confirmed via grep across containers/libreportal/). Pure dead
code from a previous refactor — removed.
2. ADDED `compression` middleware (defensive require)
Gzip-compresses JS/CSS/HTML/JSON responses. Typical ~70 % wire-size
reduction → the 1.7 MB cold-load drops to ~500 KB. New package.json
dependency; container's node_modules is baked into the image so the
require is wrapped in try/catch to degrade silently until the image
is next rebuilt (libreportal app install libreportal, or a full
deploy). Once active: free wire-size win on every response.
3. ADDED static cache headers via staticOptions on express.static
- JS/CSS/icons: Cache-Control: max-age=3600 + ETag
(1h browser cache, cheap 304 revalidation after)
- HTML files: Cache-Control: no-cache + ETag
(always revalidates so SPA shell updates land
immediately after a deploy; 304 if unchanged)
Repeat navigation in the same browser session skips ~25 script-tag
round-trips entirely.
Net effect once compression deploys:
- Cold load: 1.7 MB → ~500 KB on the wire (~70 % shrink)
- Warm load: 25 conditional requests → 0 (served from cache for 1h)
- Deploy lands: HTML revalidates immediately, JS/CSS picks up after 1h
or hard refresh
Phase B (defer non-critical scripts via SPA loadScript) and Phase C
(rebuild image / split the bind-mount story for node_modules) come
next; this commit is the safe Phase A foundation.
Signed-off-by: librelad <librelad@digitalangels.vip>