docs(spec): server-details UX + hardening polish design

Design for seven reported fixes: unified bordered button kit (retire
ghost/ghost-danger, add danger-soft), firewall+fail2ban panels in an
installed-only responsive grid, auto-updates reframed as a neutral operator
preference (never an insecure verdict), create-server SSH verification with
atomic rollback, a new "Initialisierung" (pending) server status, and an
SSH key-only hint on the checklist. Includes file map, TDD + R12 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 10:09:17 +02:00
parent 9c3094cc9a
commit a0bc8d41cb
1 changed files with 194 additions and 0 deletions

View File

@ -0,0 +1,194 @@
# 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 *borderless*
`ghost` (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.
**Change** — `resources/views/components/btn.blade.php`:
- Every variant carries a **persistent** `border` + `bg`. No borderless variants in visible use.
- Variant map becomes:
- `primary``bg-accent text-void hover:bg-accent-bright` (filled CTA, unchanged).
- `accent``border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15` (primary panel action, unchanged).
- `secondary``border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink` (default row/toolbar **and** icon-only buttons, unchanged).
- `danger``bg-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`):
- `ghost``secondary` (config gear, Entsperren, unban).
- `ghost-danger``danger-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".
**Change** — `resources/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.
**Change** — `app/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.
**Change** — `resources/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.
**Change** — `app/Services/FleetService.php`: add
```php
/** 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.)
**Change** — `app/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.php``pending => 'text-cyan border-cyan/20 bg-cyan/10'`.
- `resources/views/components/status-dot.blade.php``pending => '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.
**Change** — `resources/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.