650 Commits

Author SHA1 Message Date
librelad
513937792a Merge claude/2 2026-05-27 23:59:16 +01:00
librelad
ca3b4ed61b chore(uninstall): --skip-rootless alias + clearer naming on the keep-the-layer flag
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>
2026-05-27 23:59:16 +01:00
librelad
06f6e5c71d Merge claude/2 2026-05-27 23:31:50 +01:00
librelad
9a92805bdb feat(ui): Beginner/Advanced experience level + linked dev mode + setup-wizard step
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>
2026-05-27 23:31:50 +01:00
librelad
b19e5ac3d4 Merge claude/1 2026-05-27 23:28:25 +01:00
librelad
dd1264e335 ui(spa): stamp initial history entry + close live buses on pagehide so back-button works like a real SPA
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>
2026-05-27 23:28:25 +01:00
librelad
51069ae05a Merge claude/2 2026-05-27 23:09:07 +01:00
librelad
8001e678e0 ux(services): global Beginner/Advanced UI mode + log block first in panel
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>
2026-05-27 23:09:07 +01:00
librelad
092c1c215e Merge claude/2 2026-05-27 22:51:53 +01:00
librelad
57a565aac2 refactor(system): per-app deep-dive moves to the app's Services tab
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>
2026-05-27 22:51:53 +01:00
librelad
e734c12ff0 Merge claude/2 2026-05-27 21:53:13 +01:00
librelad
dbcab8614f feat(system): route-based sub-pages — metric / per-container / storage
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>
2026-05-27 21:53:13 +01:00
librelad
5915014c2e Merge claude/2 2026-05-27 21:17:37 +01:00
librelad
a6b0fd1bcc fix(tasks): no ghost completion toasts for tasks the bus didn't witness
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>
2026-05-27 21:17:37 +01:00
librelad
2ebbadbeff Merge claude/1 2026-05-27 21:09:09 +01:00
librelad
aa702eed9d Merge claude/2 2026-05-27 21:04:27 +01:00
librelad
6346d76a92 feat(system): binary ring history with 7-day retention + fullscreen detail UI
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>
2026-05-27 21:04:27 +01:00
librelad
0ba8e980ea ui(apps): shrink apps-section to visible-card count so few apps don't leave card-shaped gaps
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>
2026-05-27 20:54:39 +01:00
librelad
70415b0223 Merge claude/1 2026-05-27 20:44:34 +01:00
librelad
00a76e86de fix(topbar): don't push relatively-positioned sidebar/apps-layout when dev banner is on
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>
2026-05-27 20:44:34 +01:00
librelad
f7d7e21335 Merge claude/1 2026-05-27 20:23:30 +01:00
librelad
fa751e6cff ui(topbar): dev-mode banner sits above the topbar, not under it
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>
2026-05-27 20:23:30 +01:00
librelad
e961c163d5 Merge claude/2 2026-05-27 20:17:59 +01:00
librelad
9f7ad8f177 feat(system): live 1 Hz SSE stream behind admin gauges + dashboard tile
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>
2026-05-27 20:17:58 +01:00
librelad
31c71a212d Merge claude/1 2026-05-27 19:13:41 +01:00
librelad
5984869c7a copy(backup): tighter dashboard subtitle, action-led
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>
2026-05-27 19:13:41 +01:00
librelad
8a14683065 Merge claude/1 2026-05-27 18:30:13 +01:00
librelad
c549870ab8 ui(tasks): adopt the setup-wizard checkbox style for select/select-all
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>
2026-05-27 18:30:12 +01:00
librelad
42244fe14a Merge claude/2 2026-05-27 16:01:33 +01:00
librelad
57d8e82949 ui(devmode): roll the easter-egg countdown into one updating toast
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>
2026-05-27 16:01:32 +01:00
librelad
38b6dccd4a Merge claude/1 2026-05-27 15:49:42 +01:00
librelad
8681c8206d ui(tasks): move Select-all toggle right of Clear All
Reads more naturally — primary action (Clear All) leads, the
multi-select affordance follows.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:49:42 +01:00
librelad
13cafcb056 Merge claude/1 2026-05-27 15:46:18 +01:00
librelad
9b158fcaa0 feat(tasks): multi-select + Delete-Selected, reusing the redesigned modal
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>
2026-05-27 15:46:18 +01:00
librelad
02a7d0f9ba Merge claude/2 2026-05-27 15:41:19 +01:00
librelad
66e747e1ba fix(uninstall): always run name-based container cleanup + drop CLI hint
Two small uninstall-output tweaks.

1. dockerComposeDownRemove now ALWAYS calls dockerRemoveApp (the
   `docker ps -aqf name=…` → stop + rm sweep) as a fallback, even when
   the compose-down step is skipped because the app dir is missing.
   Before, a partial prior uninstall (compose file gone but containers
   still running) produced "App directory not found. Skipping container
   shutdown." and then proceeded as if the uninstall were complete —
   leaving the actual containers running. The name-based sweep also
   runs after a successful compose-down to catch anything compose
   wouldn't pick up (renamed services, orphans from earlier failures).

   While here: the OS_TYPE gate (only Ubuntu/Debian) is gone too —
   `docker compose down` works on any OS with docker, and gating it
   meant Arch/etc. users got NO compose teardown at all.

2. The step-2 header "Keeping Docker images (pass --delete-images to
   remove)" trimmed to just "Keeping Docker images". The `isNotice`
   line below already explains the reuse-on-reinstall behaviour; the
   CLI-flag hint reads as noise in the WebUI task log where users
   can't act on it anyway. CLI users can still pass --delete-images
   (cli_app_commands.sh wires it as before) or tick the WebUI's
   "Also delete docker image" checkbox.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:41:19 +01:00
librelad
e419466257 Merge claude/1 2026-05-27 15:39:39 +01:00
librelad
22203a7f60 ui(tasks): brighten empty-state $ line + fix "No all tasks tasks found"
.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>
2026-05-27 15:39:38 +01:00
librelad
4d982680b3 Merge claude/2 2026-05-27 15:32:44 +01:00
librelad
cb055b4b1f fix(uninstall): wipe container sub-UID app data via root helper
dockerDeleteData (uninstall) and the wipe-before-restore step in
restoreAppStart both did `runFileOp rm -rf $containers_dir$app_name`,
which runs as $CFG_DOCKER_INSTALL_USER (dockerinstall, uid 1002 on
rootless). That user owns app-template files but CANNOT remove
container sub-UID dirs created by the daemon's userns mapping —
postgres data at uid 232070, nextcloud html at uid 33, etc. The rm
therefore silently failed with

  rm: cannot remove '/libreportal-containers/invidious/postgresdata':
    Permission denied

while still reporting "<app> successfully uninstalled" — leaving the
sub-UID directory tree on disk to confuse the next install and leak
storage.

Fix: route the wipe through a new `app-data-remove` action in the
root-owned libreportal-ownership helper. Root can rm sub-UID files
unconditionally. The helper validates the app name (alphanumeric +
. _ -, no traversal), refuses the WebUI's own slot (libreportal), and
is idempotent when the dir is already gone.

Two callers updated:
- scripts/docker/app/uninstall/delete_data.sh
- scripts/restore/restore_app_start.sh

The helper itself ships root-owned at /usr/local/lib/libreportal/, so a
fresh install or release upgrade is needed to pick up the new action.
Bumped init.sh footprint_version 2 → 3 so the runtime updater
prompts a root re-install on the next release.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:32:44 +01:00
librelad
496c9ed1b3 Merge claude/1 2026-05-27 15:21:42 +01:00
librelad
1ccc4bba49 feat(tasks): redesign Clear All modal + add "cancel running too" toggle
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>
2026-05-27 15:21:42 +01:00
librelad
eb8192a84f Merge claude/2 2026-05-27 15:14:09 +01:00
librelad
9817d6945a fix(config): silence app-data Permission-denied chatter from config reconcile
checkApplicationsConfigFilesMissingVariables does a `find $containers_dir
-maxdepth 2 -type f -name '*.config'` to enumerate every app's live
config. runFileOp drops privileges to $CFG_DOCKER_INSTALL_USER
(dockerinstall), which is intentionally the *manager* for the rootless
data plane — but doesn't own the per-app container sub-UID dirs (e.g.
invidious/postgresdata uid 232070, nextcloud/html uid 33).

At maxdepth 2 find doesn't actually need to descend into those dirs to
satisfy the name filter, but it tries to anyway and emits chatter like

  find: '.../invidious/postgresdata': Permission denied

every time the function runs (config-reconcile path on install / app
start / restart). Cosmetic only — the actual .config files are at the
right depth and ARE found — but it shows up in the live CLI output
during installs.

2>/dev/null on the find. The function's purpose is purely to enumerate
LibrePortal-managed .config files; sub-UID data dirs are by design
unreachable to the manager and there's no signal in that error.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:14:08 +01:00
librelad
338cd801fd Merge claude/2 2026-05-27 15:05:02 +01:00
librelad
a1315024c5 ui(tasks): delete notification matches the started/completed format
"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>
2026-05-27 15:05:02 +01:00
librelad
a1a896e59c Merge claude/1 2026-05-27 14:58:43 +01:00
librelad
3294ca4e41 fix(boot): source run_privileged.sh before checkConfigFilesMissingFiles
load_sources.sh calls checkConfigFilesMissingFiles() after init.sh +
variables.sh but BEFORE initilize_files.sh sources the function
manifest. checkConfigFilesMissingFiles uses runInstallOp (in
docker/command/run_privileged.sh) to copy missing config templates —
under LP_LAZY=1 that's an autoload stub that only exists once the
manifest is sourced. So when any template is genuinely missing, the
copy call hits "runInstallOp: command not found" and the file silently
never gets copied.

Symptom on a fresh CLI invocation (foreground or processor subprocess
inheriting LIBREPORTAL_TASK_EXEC=1) where a new config category was
added:
  config_check_missing.sh: line 33: runInstallOp: command not found
  ✓ Success 1 config files were missing and have been added to the
    configs folder.    ← false success: the count incremented but the
                         copy itself didn't happen

Fix: source run_privileged.sh directly in load_sources.sh just before
the missing-files check. The file is pure function definitions (runAsManager
/ runFileOp / runFileWrite / runInstallOp / runInstallWrite), no side
effects, ~150 lines — safe to source unconditionally and idempotent
with the eager/lazy load that happens later. Adds <1ms to every CLI
invocation; saves silent failures on the rare path that calls it.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 14:58:43 +01:00
librelad
68bade609b Merge claude/1 2026-05-27 14:51:13 +01:00
librelad
f252d7680b fix(cli): camelCase task fields match WebUI shape (createdAt, not created_at)
WebUI-created tasks emit camelCase initial fields (createdAt, startedAt,
completedAt, heartbeatAt, exitCode, errorMessage) per
tasks-manager.js / task-manager.js conventions, with createdAt in
ISO-UTC-with-ms (`2026-05-27T13:01:26.345Z`). The processor then layers
snake_case status fields (started_at, heartbeat_at, …) on top as the
task runs.

The CLI's cliTaskRun was writing snake_case only — `created_at` with
local-tz offset. The task panel's renderer reads `task.createdAt`
directly (no alias), so CLI-queued tasks showed blank Created/Started
columns until the processor wrote its own snake_case overlay
(which doesn't include createdAt at all). Visible symptom: dates
"broken" on CLI-queued tasks.

Now the initial JSON cliTaskRun writes matches what the WebUI's
"Install" button writes:

  {
    id, command, status: queued,
    createdAt: "<ISO-UTC-with-ms>",
    startedAt: null, completedAt: null, heartbeatAt: null,
    exitCode: null, errorMessage: null,
    type, app
  }

Processor side is unchanged (still adds snake_case overlay on
status transitions — that's how WebUI tasks already work). No JSON
shape change for in-flight tasks.

ALSO (out-of-repo): /home/user/Documents/Scripts/update.sh now restarts
the systemd `libreportal.service` task processor after the docker
`libreportal-service` container restart. Same reason — both pre-load
code at startup, both need a restart to pick up changes. Without this,
deploys silently kept a stale processor running old code while the
disk reflected the new code; the install task-routing recursion I just
saw was a direct consequence.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 14:51:13 +01:00