Commit Graph

19 Commits (645d7dd4a45f21d8e2dfd73fbce2840ee84848a6)

Author SHA1 Message Date
boban 28c28df868 feat(help): in-panel help page scaffold + overview topic
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:14:15 +02:00
boban 1e6fdb457e fix(js): never let Reverb/Echo init abort Alpine component registration
The command-palette opened itself full-screen and undismissable right after
first login on production (bare-IP) installs. Root cause: app.js constructed
`window.Echo = new Echo({...})` at module top level. On auth-layout pages the
runtime reverb config (window.__clusev) is absent and the prod bundle bakes no
VITE_REVERB_* fallback (by design — the endpoint is resolved at runtime), so
key/wsHost were undefined and the constructor threw. That throw aborted app.js
BEFORE the alpine:init block registered cmdk / modalTrigger / dualChart. app.js
does not re-run across wire:navigate, so the dashboard inherited the broken
state: Alpine could not resolve x-data="cmdk(...)", stripped x-cloak but could
not evaluate x-show="open", and the palette div fell back to its default flex
display — a dimmed, undismissable overlay. Dev (Vite) hid the bug because it
bakes VITE_REVERB_* from the dev .env, so Echo never threw.

Fix: construct Echo only when a usable runtime config is present, wrap it in
try/catch so a websocket/config failure can never take down the UI, and re-run
the guarded init on livewire:navigated so realtime still connects on a
dashboard reached via SPA navigation from an auth page.

Verified on the real prod build (rebuilt VM image): palette stays closed after
first-login -> dashboard, opens on Ctrl+K and closes on ESC, Echo reaches
connected state, zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:19:31 +02:00
boban 62158eecdc fix(modals): guard wire-elements/modal close path against activeComponent/components desync
Wrap window.LivewireUIModal at alpine:init to null-guard closingModal()
the way the vendor already guards getActiveComponentModalAttribute, so the
click-away path no longer throws "Cannot read properties of undefined
(reading 'name')" when @persist widens the Alpine/Livewire desync window.
In-sync close behavior (incl. a modal vetoing its own close) is unchanged.

Verified: deterministic browser repro flips threw:true -> threw:false; the
real closeModalOnClickAway() entry is clean under forced desync and the modal
still closes; @persist first-open after navigate, spinner, and Escape/Abbrechen
all remain clean (0 console errors). 182 tests pass; Vite build OK; Codex review
approved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:30:24 +02:00
boban e58a08b974 fix(modals): reliable open + spinner/error feedback on every modal trigger
Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.

Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.

CHANGELOG also backfills the prior reset-timing residual fix (2520b87).

Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:07:25 +02:00
boban 44d4046526 fix(services): live journal poll + command-palette server search
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last
N lines once and never refreshed, so it was neither live nor complete.

- FleetService: `journalctl --show-cursor` + new journalSince() reads only entries
  after the stored journald cursor (no gaps, no dup appends), capped to a bounded
  live tail; parseJournal split into splitJournalCursor + parseJournalLines.
- Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor
  and caps retained rows at 300 (fetch ceiling == display ceiling).
- Honest labels: drop the misleading "-f"; badge stays "live" (now true).

Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine
cmdk x-data; servers surface only while searching, matched on name OR IP, and
selecting one navigates to /servers/<uuid>.

Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell —
no injection (Codex-reviewed: no P1, no security findings). Removes stock
ExampleTest (asserted 200 on an auth-gated route → always 302).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:13:08 +02:00
boban 0088122ff1 revert(ui): drop racy bfcache JS auto-sync; keep race-free mount/targetFor
The livewire:navigated activate-POST hook introduced unbounded cross-request
ordering races (the on-screen confusion is already fixed race-free by
Show::mount setting the active server + ServerSwitcher::targetFor navigating on
switch). Remove the JS hook + activate endpoint. The remaining bfcache edge is
invisible (the cached page shows the right server) and a transient stale session
resolves on the next deliberate navigation — documented in the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:37:54 +02:00
boban a69551a7eb fix(ui): serialize active-server activations to guarantee ordering
Aborting a fetch can't stop a POST already running server-side, so a stale
activation could still land last. Chain the requests so each runs after the
previous resolves — the latest navigation's activation is always last. A 5s
per-request timeout keeps a hung request from stalling the chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:34:19 +02:00
boban f4c24d51a6 fix(ui): cancel stale activate requests + onboarded user in test
Abort any in-flight active-server POST on each navigation so a slower stale
request can't overwrite the latest selection. Fix the activate-endpoint test to
use an onboarded user (the route is behind EnsureSecurityOnboarded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:32:16 +02:00
boban 6e0ec724ca fix(ui): re-sync active server on bfcache restore of a details page
wire:navigate restores Back/Forward pages from its client cache without re-running
Show::mount(), which could leave active_server_id pointing at a different server
than the details page on screen. Add a servers.activate endpoint + a global
livewire:navigated hook that re-asserts the on-screen server on every navigation
(including cache restores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:29:24 +02:00
boban fe5c190eb0 feat(webauthn): register/list/remove security keys in settings
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:29:51 +02:00
boban f5f903046d fix(blade): garbled header on every signed-in page + private metrics channel (v0.4.1)
Two fixes/hardenings on top of v0.4.0:

1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
   tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
   as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
   every authenticated page (and left the page title empty). R12 missed it: the page still
   returned 200 with no console error and the <title> comes from the Livewire component. Fixed
   the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
   R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.

2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
   v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
   (withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
   anyone holding the bundled app key — including over a stale Caddy host after a domain change.
   The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
   the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
   CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
   channel is private.

Bump 0.4.0 -> 0.4.1; CHANGELOG.

Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:34:07 +02:00
boban a44e29426a feat(domain): change the panel domain from the dashboard (v0.4.0)
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.

How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
  setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
  DIRECTLY (cache-independent) and retries until the settings table is readable; if it
  never is, it freezes the env fallback so the active domain is always a FIXED value
  (never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
  publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
  domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
  hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
  (/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
  no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
  command; DE/EN strings (R16).

Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
  always a recovery path. trustProxies('*') only in production (dev can't be tricked into
  faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
  the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
  and IP-recovery redirects stay on the IP.

Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).

Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:38:16 +02:00
boban ec1516184e i18n: complete DE/EN localization (modals, shell, backend) + v0.3.0
- modals: wire 5 hard-coded views + 10 components to lang/{de,en}/modals.php
- shell: command-palette (incl. app.js cmdk(nav,actions) refactor), server-item,
  toaster fallback
- backend: new lang/{de,en}/backend.php; localize Hardening/Firewall/Fail2ban/
  Maintenance services, OsProfile, plus FleetService + CredentialVault errors
- fix: localized page <title> on Dashboard/Servers index+show (->title(), the
  Livewire-3 way; the stray title() methods were never auto-called)
- fix: topbar inline @php() was swallowed by Blade's raw-block precompiler up to a
  later @endphp -> 500 on every authed page; converted to block @php ... @endphp
- versions: localize changelog section labels via a canonical heading map
- translation-quality pass: Unlock, Add credentials, clearer auth/notify copy

Bump 0.2.1 -> 0.3.0; CHANGELOG.

Verified: Pint clean; npm build OK; R12 all routes HTTP 200 + 0 console errors in
BOTH locales (titles/nav/labels switch language); 589 used keys resolve in de+en
with full group parity; Codex review clean; adversarial DE/EN audit (0 high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:27:42 +02:00
boban 2c533bf821 feat(ux): command palette (Strg/⌘-K) + keyboard shortcuts
A global command palette and keyboard shortcuts, mounted once in the persistent
layout with a single global keydown listener.

- Strg/⌘-K opens the palette: fuzzy-filter nav targets (German, from the sidebar) +
  "Server hinzufügen"; ↑/↓ to move, Enter to run, Esc to close. Navigation uses
  window.Livewire.navigate (SPA-fast).
- Leader "g" + key: d=Dashboard, s=Server, i=Dienste, f=Dateien, l=Audit-Log,
  e=Einstellungen, y=System, v=Version.
- "/" focuses the page search ([data-page-search] on services/servers/audit) — but
  only consumes the key when such a field exists, so the browser quick-find still
  works elsewhere.
- "?" shows a shortcut-help overlay.
- Typing guard: shortcuts never fire inside inputs/textarea/select/contentEditable.
- Alpine destroy() removes the navigation listener (no leak across wire:navigate);
  topbar gains a "Strg K" trigger button. New icons: command, corner-down-left.

R5/R3/R9 respected: the palette only navigates or opens the existing (R5) create-server
modal — zero direct destructive actions; tokens-only styling, German, no emoji.

Pint clean; Codex review clean (destroy cleanup + slash-passthrough hardened); R12 —
verified in-browser: Ctrl-K opens, filter works, Esc closes, g+f navigates, ? help,
HTTP 200, 0 console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:38:22 +02:00
boban b9b0f62d78 feat(ssh): wire real fleet data over SSH (FleetService + live poller)
Replace the page mock data with real reads from the target host via the
phpseclib SSH layer. New FleetService parses raw command output into the exact
shapes the Livewire pages already consume; all parsing forces LC_ALL=C and reads
/proc to stay locale-independent.

- app/Services/FleetService.php: metrics (cpu via two /proc/stat samples, mem via
  /proc/meminfo, disk via df), full snapshot (identity/specs incl. virt+disk_gb,
  volumes, interfaces from ip+/proc/net/dev, sshd/fail2ban/ufw hardening, authorized
  keys), systemd units + journal, and an ls-based directory listing. One compound
  command per read; connect/parse failures bubble up.
- clusev:poll-metrics command replaces the mock emitter in the dev supervisor:
  polls every credentialed server, persists cpu/mem/disk/status, broadcasts
  MetricsTicked(server) — unreachable boxes flagged offline, loop never dies.
- Pages wired with graceful failure (offline state, never a 500):
  Dashboard (live cached metrics + notable units), Services (real units+journal),
  Files (real listing + dir navigation via open/go/up), Server-Details (live
  snapshot persisted onto the row + offline banner).
- WithFleetContext prefers a credentialed, non-offline server as the default.
- dualChart filters ticks by server name so the chart tracks the active host.

Verified live against a real Debian 13 box: metrics/services(91)/journal(25)/
files(navigable)/snapshot(5 ifaces, real hardening, real key) all parse correctly.
Credentials are stored encrypted in the vault — never in source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:34:13 +02:00
boban 688e9e7866 feat: reference-depth dashboard (sparkline KPIs + live dual chart + table)
Align the dashboard to the BASTION reference (clusev-template) — the active
server's detail view:
- x-metric: KPI tiles with sparklines + trend (CPU/Memory/Disk/Load).
- live dual-series chart (CPU+MEM, grid + Y/X axes + legend) as a dualChart
  Alpine island; MetricsTicked + clusev:mock-metrics now broadcast CPU+MEM.
  Static SSR paths remain as the no-JS fallback.
- systemd services as a table.
Tokens/currentColor only — no inline styles; Y-axis via utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:44:11 +02:00
boban 47eae61371 fix: apply v1 UI review (a11y, contrast, live-chart robustness)
- Focus-visible token ring + prefers-reduced-motion guard (app.css).
- Lift ink-3/ink-4 to WCAG-AA text contrast.
- Touch targets >=44px (topbar, sidebar, dashboard buttons).
- Off-canvas drawer removed from tab order when closed (breakpoint-aware inert)
  + sidebar aria-label.
- Live chart: NaN guard on incoming cpu; Echo.leave('metrics') + unbind on
  destroy (no subscription leak under wire:navigate).
- KPI progress bar follows the tone (status triad), not always accent.
- Resource rings bound to real server data with threshold-based tone.
- docs/v1-ui-review.md: full adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 06:16:52 +02:00
boban 8df4456108 feat: live metrics over Reverb (mock emitter)
- MetricsTicked broadcast event (channel 'metrics', as 'tick') + a
  clusev:mock-metrics command (dev placeholder for the SSH MetricsPoller),
  broadcasting a rolling CPU value every 2s.
- Echo + pusher-js client in app.js with an env-driven Reverb connection
  (VITE_REVERB_*). metricsChart Alpine island seeds from server data, appends
  each tick and redraws the sparkline; the indicator reflects the real WS state.
- Published config/reverb.php + config/broadcasting.php.
- Backend verified: the queue processes MetricsTicked every 2s with no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:23:10 +02:00
boban 53f7309c0b feat: scaffold — Dockerized Laravel 13 + Livewire 3 + Tailwind 4 + Reverb
Working dev stack on :80 (Freigabe step 5: app boots, page renders, Vite HMR runs):
- Multi-stage Dockerfile (php8.3-fpm + nginx + supervisor + node). Dev runs
  php-fpm + nginx + vite in ONE app container; prod target bakes vendor + assets.
- docker-compose.yml (dev, bind-mount) + docker-compose.prod.yml (GHCR image).
  Env-driven ports/UID (APP_PORT, VITE_PORT, REVERB_HOST_PORT, DB_PORT,
  HOST_UID/HOST_GID) — nothing hardcoded. MariaDB only on 127.0.0.1:3306.
- Laravel 13, Livewire 3.8 (class-based, no Volt), wire-elements/modal 2,
  phpseclib 3, laravel/reverb 1. composer platform pinned to php 8.3.
- Tailwind v4 @theme tokens ("Tactical Terminal") in app.css; Vite remote HMR
  (host 0.0.0.0, hmr.host 10.10.90.136).
- Dashboard as a full-page Livewire route (/) in layouts/app; §5 folder skeleton.
- Docs: rules.md R1-R11 (incl. R11 UUID-in-URLs) + CLAUDE.md updated for
  Laravel 13, clusev/ root, port 80, vite-in-app, env-driven config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:31:50 +02:00