# 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 ~197–204) 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 ~185–191) 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.
