Commit Graph

16 Commits (feat/v1-foundation)

Author SHA1 Message Date
boban 8b34ed8ef5 feat(rbac): hide gated controls in the UI with @can (operator/viewer see no dead buttons) 2026-07-05 01:49:38 +02:00
boban 7a6d20cbed fix(ui): add wire:key to list rows — stops "pending is not defined" (v0.9.53)
Lists rendered loop rows without a wire:key. On re-render Livewire's morph
matched rows by position and cloned them, dropping the inline Alpine x-data
(modalTrigger) scope on the buttons — the console threw "Alpine Expression
Error: pending is not defined" (and a follow-on "undefined is not valid JSON"
as the snapshot merge broke). Reported in the file manager; the same pattern
existed across the app.

Added a stable wire:key to every loop whose rows carry inline Alpine / x-modal-
trigger state: files (entries + breadcrumbs), services, the release-channel
selector, settings (users, webauthn keys, login-protection bans) and the server
detail page (hardening, firewall rules, SSH keys, fail2ban exceptions). Keys use
a stable identifier (id / fingerprint / md5 of the natural key), not the loop
index.

372 tests pass (the Livewire tests render these blades, so a broken key would
fail them); /system, /settings, /files load 200 with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:44:03 +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 a8616809f1 fix(services): cap live journal at 200 rows in a scrollable, auto-sticking box 2026-06-14 20:58:48 +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 5691051ad8 feat(i18n): bilingual foundation (DE/EN) + localize auth/dashboard/servers/fleet/account
Adds user-selectable German (default) + English, per new rule R16.

Foundation:
- config locale=de + fallback=de; User.locale column (+migration); SetLocale middleware
  (user → session → default; SUPPORTED=[de,en]); public /locale/{code} route; DE/EN
  switcher (x-lang-switch) in topbar + auth layout; <html lang> dynamic.
- Translation structure: lang/{de,en}/<group>.php (one group per feature + shared common),
  identical keys across both languages, :placeholder interpolation. Documented as R16 in
  rules.md + CLAUDE.md (R9 reconciled).
- Removed the "Warum Caddy" rationale block from the System page (per feedback).

Localized this pass (views + Livewire components, keys de↔en at parity):
- common, auth, dashboard, servers, services, files, audit, settings, system, versions.
- #[Title] attributes → dynamic title() (attributes can't call __()).

Remaining (next commit): modals components + a few modal views, shell (command palette,
nav-item, server-item, toaster), and backend service messages (app/Services/*, OsProfile).
App is fully functional in German throughout; English covers the groups above.

Boot-verified: /login HTTP 200 in DE + EN, titles + <html lang> localized, locale switch works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:36:16 +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 aa7a7b1db8 fix(ux): editor click bug (@js→index), self-hosted fonts, split-brand auth, R14
- Fix Alpine "Invalid or unexpected token" on file/service/key actions: @js()
  does NOT compile inside a Blade component (x-btn) attribute, so a literal
  @js(...) reached the DOM and Alpine choked on it. Switched all 10 wire:click
  arguments to loop-index lookups (files: open/go/download/edit/confirmDelete;
  services: confirm; keys: confirmKeyRemoval) — the component resolves the value
  server-side by index. {{ $loop->index }} compiles in component attributes; @js
  does not.
- Self-host fonts (R14): Chakra Petch / Space Grotesk / JetBrains Mono as local
  .woff2 in resources/fonts/, declared via @font-face in app.css, Vite-bundled
  with relative urls so they resolve in dev AND the prod build. No Google Fonts
  / CDN link or @import. New rule R14 added to rules.md + CLAUDE.md.
- Auth redesign (split-brand, per reference): new auth layout with a brand panel
  (faux terminal + glow mesh, hidden below lg) + redesigned login,
  two-factor-challenge, two-factor-setup, password-change forms.
- x-btn: add size="lg" (h-11, >=44px touch target) for the full-width auth CTA;
  one shared button component, no bespoke styles.
- Remove unused Laravel welcome.blade.php (was full of raw hex — R3 cleanup).

Verified (R12): /login 200 / 0 console errors, fonts load (Chakra+Grotesk+JB),
brand panel visible @1440; file-editor REAL click -> modal opens, textarea
renders, 0 Alpine errors; editor load/save roundtrip (/etc/hostname='debian');
all 7 routes 200 / 0 console errors. Full rules audit R1-R14 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:16:31 +02:00
boban 0a7fc4780c feat(ux): detail-page redesign, settings page, key generation, scrollable services
Round of UX work from user feedback (browser-verified per R12 — all routes 200,
zero console errors).

- Server-Details redesign (/frontend-design): hero header band (status-tinted
  server glyph + name + meta strip + actions), a full-width row of dense vital
  cards (ring + label + absolute used/total) instead of 3 donuts floating in a
  height-stretched panel, hardening checklist with status-colored accent borders,
  and a clear section hierarchy (vitals → specs+security → volumes+net → keys).
- New user Settings page (/einstellungen): profile (name/email), password change
  (current-password gated), 2FA status + enable link / disable (confirm + audit).
  Sidebar "Konto" nav group + clickable user block.
- SSH key generation: "Neues Paar generieren" in the add-key modal makes an
  ed25519 keypair (phpseclib) — public key installed on save, private shown once.
- Services list is height-capped + scrollable so the Journal below is reachable.
- <x-btn> now also renders <a> (href) for link-buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:07:56 +02:00
boban 5134fe740b fix(services): 500 from @disabled on <x-btn>; add R12 browser-verify rule
/services returned HTTP 500: @disabled() on a Blade *component* injects an
if/endif into the component tag and breaks compilation ("unexpected endif").
@disabled/@checked/@required only work on real HTML elements — switched to the
bound :disabled="…" attribute (the component attribute bag drops a false value).

Caught by a real browser probe (HTTP status + console errors per route), which I
should have run before. Documented that as R12 in rules.md + the CLAUDE.md verify
checklist: every touched page must load at HTTP 200 with zero console errors, in
its *loaded* (post-wire:init) state — a green Livewire::test is not sufficient.

Re-probed all six routes: dashboard/servers/detail/services/files/audit all 200,
zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:43:21 +02:00
boban 5479896bf3 fix(modal)+ux: panel above backdrop, one <x-btn> style, SSH credential form
The modal panel rendered *behind* its own backdrop (it "appeared then vanished"):
Tailwind v4 no longer turns `transform` into a stacking context, so the static
panel sat under the fixed/blurred backdrop. Fixed with `relative z-10` on the
panel (interactively verified by force-opening the modal).

- New <x-btn> component: one compact, consistent style (h-8 text-xs) with
  variants (primary/accent/secondary/danger/ghost/ghost-danger). Replaced every
  ad-hoc action button — modals, service start/stop/restart, file row actions +
  upload, server-details (Zugang/Dateien/key add+remove). No more oversized,
  inconsistent buttons.
- New EditCredential form modal + "Zugang" button on Server-Details: deposit or
  update a server's SSH login (e.g. root) — this is where the privileged
  credential for systemctl/journal gets stored (encrypted vault). Changing it
  re-pulls the snapshot with the new login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:32:28 +02:00
boban 94972c78bd perf+ux: lazy-load pages (skeletons), fix modal, normalize buttons + spinners
Route switching was slow because each page did its SSH reads inside mount(),
blocking the response. Now mount() only sets cheap shell state and the SSH read
runs in load() via wire:init — the page shell + skeletons render instantly and
content streams in.

- Lazy-load Dashboard (services), Services, Files, Server-Details: SSH moved from
  mount() to load(); skeleton placeholders (new <x-skeleton>) while !$ready.
- Modal container: z-10 -> z-50 (was rendering *under* the z-40 sidebar) and a
  real max-w-lg (the dynamic modalWidth classes were never generated by Tailwind,
  so modals spanned full width). This also fixes the "flash + disappear".
- Modal/action buttons: normalize size (min-h-11 uppercase font-display -> h-9
  text-sm font-medium) and add a wire:loading spinner + disabled-while-running on
  the confirm/save actions (no double-submit, clear feedback).
- Server-Details gauges keep wire:poll (live) but no longer block initial render.

Verified: every page renders ready=false + skeleton with no SSH, then load()
populates real data; modal buttons carry the spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:17:02 +02:00
boban 5a9b6d317f fix(ssh): address adversarial review — host-key pinning, escaping, parsers
Resolve all 12 confirmed findings from the SSH-integration review.

Security:
- TOFU host-key pinning (new VerifiesHostKey trait + servers.ssh_host_key column):
  SshClient/Sftp record the server host key on first connect and refuse the
  connection on any later mismatch — phpseclib does not pin host keys itself.
- PollMetrics keeps one long-lived SSH connection per server and reuses it across
  ticks (a single login, not one per interval) — avoids fail2ban/auth-log churn;
  dead connections are dropped and re-established next tick.
- @js() escaping for every server-controlled value interpolated into wire:click
  (file names, paths, service names, SSH-key comments/fingerprints) — prevents
  JS-string breakage / injection from untrusted remote data.

Correctness:
- parseKeys regex makes the key comment optional (keys without a comment were
  silently dropped).
- parseVolumes pops the three trailing single-token df fields and rejoins the
  rest, so mount points containing spaces parse correctly.
- Sftp gains a disconnect() method to match SshClient (explicit cleanup).

Rules:
- Services "Start" -> "Starten" (R9, German).
- Files breadcrumb buttons get min-w-11 + padding (R7, >=44px touch target).

Verified live: host key stored + mismatch refused + correct key reconnects;
poller reuse polls ok; all pages still render real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:55:27 +02:00
boban 218806727c feat(r5): wire-elements/modal confirmations for destructive actions
Add a generic ConfirmAction modal (LivewireUI\Modal\ModalComponent). On confirm
it persists exactly one AuditEvent and re-dispatches a page event so the origin
applies its own state change — domain-agnostic and reused everywhere (R5).

Wired the destructive actions, each writing an audit row:
- Services: start/stop/restart -> confirm + audit; service state reflects result
- Files: delete -> confirm + audit; entry removed
- Server-Details: revoke SSH key (new per-row trash button) -> confirm + audit
  (carries server_id)

Supporting changes:
- Publish + restyle the modal container for the dark theme (void backdrop +
  surface panel + shadow-pop instead of the package's gray/white defaults)
- Toaster island in the app layout that catches the `notify` browser event
- Add alert/power/rotate/trash icons to x-icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:05:50 +02:00
boban 5fce2dd757 feat: v1 pages — servers (index + details), services, files, audit + SSH layer
Five full-page Livewire routes (R1/R2) on the component kit + seeded models,
German UI, responsive, tokens-only:
- /servers: fleet overview (status KPIs, live search, rows linking by uuid).
- /servers/{uuid}: details — resource rings, specs, volumes, network interfaces,
  security hardening checklist, SSH keys (mock until the SSH layer).
- /services: systemd units (enabled/status, start/stop/restart placeholders per R5)
  + journalctl tail.
- /files: SFTP file browser (breadcrumb, perms/size/modified, actions).
- /audit: real AuditEvent log with live filter + empty state.

SSH layer (app/Support/Ssh): SshClient (exec), Sftp, CredentialVault (decrypts the
encrypted vault) — phpseclib3, validated, ready for a target server.

Verified: all pages HTTP 200, no errors; 5/5 screenshot-checked on-brand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 06:33:59 +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