clusev/docs/superpowers/specs/2026-06-14-server-details-h...

12 KiB
Raw Blame History

Server-Details UX + Hardening Polish — Design

Date: 2026-06-14 · Branch: feat/v1-foundation · Status: approved (design), pending spec review

Fixes seven issues reported against the Server-Details page (servers.show), the button system, and the create-server flow. All UI copy stays bilingual (DE default + EN, R16); all visible colors come from @theme tokens (R3); buttons go through the single x-btn component (R10).


1. Unified button system (issues 6 + 7)

Problem: x-btn is the single source of truth, but two variants are borderlessghost (no border/bg) and ghost-danger (no border/bg, turns red only on hover). Used for "Entsperren", config gears, and "Löschen"/trash, they read as inconsistent next to the bordered secondary/accent buttons. The delete button's hover-only red is jarring.

Changeresources/views/components/btn.blade.php:

  • Every variant carries a persistent border + bg. No borderless variants in visible use.
  • Variant map becomes:
    • primarybg-accent text-void hover:bg-accent-bright (filled CTA, unchanged).
    • accentborder border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15 (primary panel action, unchanged).
    • secondaryborder border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink (default row/toolbar and icon-only buttons, unchanged).
    • dangerbg-offline text-void hover:bg-offline/90 (filled; modal-confirm only, unchanged).
    • danger-soft (new)border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15. Persistent red-tint border+bg for destructive row/toolbar actions (Löschen, trash, whitelist-remove). Mirrors the accent structure so the kit stays uniform.
    • Remove ghost and ghost-danger from the map.
  • The icon-only size=sm button stays h-8 w-8 (≥ touch target handled by lg, R7) — unchanged.

Usage migration (grep variant="ghost" / variant="ghost-danger" across resources/views):

  • ghostsecondary (config gear, Entsperren, unban).
  • ghost-dangerdanger-soft (Löschen credential, trash rule, trash SSH key).
  • The whitelist chip's raw <button> "x" (show.blade.php ~line 417) stays a chip-x but gains a consistent text-ink-4 hover:text-offline → keep (it is a tag affordance, not a button); no change required beyond confirming it reads as part of the chip.

"One CSS variable for buttons" is satisfied by x-btn being the single definition point — the variant table is the one place button looks are declared; every button references it.


2. Firewall + fail2ban panels → grid, installed-only (issues 1 + 5)

Problem: Both panels render full-width and always — including a redundant "UFW ist nicht installiert" box when the tool is absent. Too much vertical space for little content; the not-installed box duplicates the checklist's "Aktivieren".

Changeresources/views/livewire/servers/show.blade.php:

  • Compute visibility:
    • $showFw = ($fw['supported'] ?? false) && ($fw['installed'] ?? false)
    • $showF2b = ($f2b['supported'] ?? false) && ($f2b['installed'] ?? false)
    • "Installed" already covers active-and-inactive; an installed-but-inactive tool still shows its box (with its existing enable affordance for firewalld / fail2ban-inactive states).
  • Layout:
    • Both shown → wrap in <div class="grid grid-cols-1 gap-3 lg:grid-cols-2 lg:items-start"> … </div>.
    • Exactly one shown → that panel renders full-width (no grid wrapper).
    • Neither shown → render nothing (the Sicherheit checklist rows carry "Aktivieren" to install).
  • Delete the now-unreachable inline branches inside each panel that handled ! $installed / not-supported (the box only renders when installed), keeping readError handling.
  • lg:items-start so a short panel does not stretch to a tall neighbor's height.

Entry point to install when hidden is unchanged: the Sicherheit checklist rows for firewall and fail2ban open the hardening-action modal with enable: true.


3. Auto-updates = operator preference, not a security gate (issue 2)

Problem: "Automatische Updates" is rendered as a security verdict (SICHER/OFFEN). A fresh Debian ships unattended-upgrades enabled, so it reads "SICHER · aktiv" by default. Keeping a server patched manually is a legitimate operator choice; Clusev should not imply auto-updates are required, nor flag "off" as insecure. Default framing must be neutral, the operator decides.

Changeapp/Services/HardeningService.php::parseState(), the unattended row only:

  • secure for the auto-update row = always true (never an insecure/"OFFEN" verdict).
  • The row's state still reflects the real host (featureOn = installed && active) so the toggle shows the correct Aktivieren/Deaktivieren direction.
  • Detection logic is unchanged (stays truthful about the host config). No new probe.

Changeresources/views/livewire/servers/show.blade.php, Sicherheit checklist:

  • The state chip (check_secure/check_open, line ~197204) must show a neutral state for the auto-update row instead of the green/orange SICHER/OFFEN pair. Render common.active / common.inactive in a muted tone (text-ink-3) for this row. Drive this off a per-row flag so only the auto-update row is reframed; SSH/fail2ban/firewall keep SICHER/OFFEN.
  • The lock glyph tint (line ~185191) for the auto-update row uses the neutral (border-line bg-raised text-ink-4) treatment when inactive — not the warning-orange — so an off state never looks like a problem. When active, a calm online tint is fine.

Implementation note: add a marker to the row data — e.g. 'neutral' => true on the unattended row from parseState() — and branch on it in the Blade, rather than string-matching the key in the view.


4. Create-server: verify SSH before save + "Initialisierung" status (issue 4)

4a. Verify the credential on creation

Problem: CreateServer::save() persists the server with status='offline' without ever checking that the entered SSH login works. A wrong password yields a server that simply sits red.

Changeapp/Services/FleetService.php: add

/** Probe the stored credential: connect + trivial exec. Never throws. */
public function testConnection(Server $server): array  // {ok: bool, error: ?string}

that builds an SshClient (timeout ~10s), connect($server), runs true, returns ['ok' => $code === 0, 'error' => null], and on any Throwable returns ['ok' => false, 'error' => $e->getMessage()]. (secret is an encrypted cast, so the probe must run against a persisted credential — an in-memory one cannot be decrypted by the vault.)

Changeapp/Livewire/Modals/CreateServer.php::save():

  • Inside the existing DB::transaction, after creating the server + credential, call app(FleetService::class)->testConnection($server).
  • If ! ok: throw a ValidationException for the secret field with a localized message that includes the SSH reason (truncated) — this rolls back the transaction (no row persists) and surfaces the error on the form. New key modals.create_server.validation_ssh_failed (:error).
  • If ok: proceed (audit + notify + close), but create with status='pending' (see 4b).
  • Tradeoff (documented): the SSH handshake runs inside the DB transaction; bounded by the 10s client timeout. Acceptable for a single interactive create; the alternative (persist → test → delete-on-fail) is non-atomic.

4b. "Initialisierung" status

Problem: A freshly created server shows red/Offline before it has ever been contacted.

Change:

  • New status value pending → label "Initialisierung", calm tone (cyan dot + soft ping, cyan-tinted pill), never red. status is a free-form string column — no migration needed.
  • CreateServer::save() sets status => 'pending' (the credential is already verified, so first contact will promote it).
  • Promotion paths already set online/offline on real contact (Show::load(), PollMetrics), so pending resolves on the first poll/snapshot.
  • Add pending to every status map:
    • resources/views/components/status-pill.blade.phppending => 'text-cyan border-cyan/20 bg-cyan/10'.
    • resources/views/components/status-dot.blade.phppending => 'bg-cyan', ping enabled.
    • resources/views/components/server-item.blade.php — label map + :ping includes pending.
    • resources/views/livewire/servers/index.blade.php$label['pending'], dot ping.
    • resources/views/livewire/servers/show.blade.php$statusLabel map + hero icon @class (cyan branch) + suppress the "offline" banner while pending.
    • New lang key servers.status_pending => 'Initialisierung' / 'Initializing'.
  • KPI counters (Index.php groupBy status) are unchanged: pending counts in total only, not in online/warning/offline. Acceptable — it is a transient state.

5. SSH key-only hint on the checklist (issue 3)

Problem: The confirm-modal copy desc_ssh_password_disable already states access becomes key-only and needs a deposited key. But the checklist row itself gives no such hint before the operator clicks.

Changeresources/views/livewire/servers/show.blade.php, Sicherheit checklist:

  • For the ssh_password (and ssh_root) rows, append a muted one-line hint under the detail line when the feature is still ON (password/ root login enabled), e.g. servers.ssh_key_only_hint => 'Nach dem Deaktivieren ist der Zugang nur per SSH-Key möglich — Key zuerst hinterlegen.' (EN: 'After disabling, access is SSH-key only — deposit a key first.').
  • Rendered as text-[11px] text-ink-4, only on the SSH rows, only while the feature is on.

No backend change; this is presentational copy that mirrors the existing guard (ssh_password_no_key / ssh_password_self_lockout).


Files touched (summary)

Area File
Buttons resources/views/components/btn.blade.php; all ghost/ghost-danger usages in resources/views/**
Boxes grid + visibility resources/views/livewire/servers/show.blade.php
Auto-updates reframe app/Services/HardeningService.php; show.blade.php
Create-verify app/Services/FleetService.php; app/Livewire/Modals/CreateServer.php; lang/{de,en}/modals.php
Pending status status-pill, status-dot, server-item, index.blade.php, show.blade.php; lang/{de,en}/servers.php
SSH key hint show.blade.php; lang/{de,en}/servers.php

Testing

  • TDD (logic):
    • HardeningService — the unattended row reports secure === true regardless of active state, and carries the neutral marker; other rows keep SICHER/OFFEN semantics.
    • FleetService::testConnection — returns ok:false,error on connect failure (mock SshClient boundary), ok:true on success.
    • CreateServer — invalid SSH → ValidationException, no Server row persisted (rollback); valid SSH → server persisted with status='pending'.
  • R12 browser (DE + EN, 375 / 768 / 1280): Server-Details page — buttons uniform (border+bg, delete red-tinted persistent), firewall/fail2ban grid + hidden-when-absent, auto-update row neutral, SSH key hint visible; create modal blocks on bad password; new server shows "Initialisierung". DOM scan for leaked @/{{ }}/$var/group.key (R17).
  • Pint clean (--dirty), Codex (codex review --uncommitted) clean (R15).

Out of scope (YAGNI)

  • No change to firewall/fail2ban detection in their services (the checklist↔box installed states already agree via the shared command -v arm).
  • No new auto-update detection accuracy work (operator decided: reframe only).
  • Dead dualChart removal, Caddy/ACME live test, firewalld rule mutation — tracked separately in docs/session-handoff.md §3.