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
parent
9c3094cc9a
commit
a0bc8d41cb
|
|
@ -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 ~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.
|
||||
Loading…
Reference in New Issue