12 KiB
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 theaccentstructure so the kit stays uniform.- Remove
ghostandghost-dangerfrom the map.
- The icon-only
size=smbutton staysh-8 w-8(≥ touch target handled bylg, 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 consistenttext-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-btnbeing 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).
- Both shown → wrap in
- Delete the now-unreachable inline branches inside each panel that handled
! $installed/ not-supported (the box only renders when installed), keepingreadErrorhandling. lg:items-startso 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:
securefor the auto-update row = alwaystrue(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. Rendercommon.active/common.inactivein 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
/** 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, callapp(FleetService::class)->testConnection($server). - If
! ok: throw aValidationExceptionfor thesecretfield 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 keymodals.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.statusis a free-form string column — no migration needed. CreateServer::save()setsstatus => 'pending'(the credential is already verified, so first contact will promote it).- Promotion paths already set
online/offlineon real contact (Show::load(),PollMetrics), sopendingresolves on the first poll/snapshot. - Add
pendingto 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 +:pingincludes pending.resources/views/livewire/servers/index.blade.php—$label['pending'], dot ping.resources/views/livewire/servers/show.blade.php—$statusLabelmap + hero icon@class(cyan branch) + suppress the "offline" banner whilepending.- New lang key
servers.status_pending => 'Initialisierung' / 'Initializing'.
- KPI counters (
Index.phpgroupBy status) are unchanged:pendingcounts intotalonly, 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(andssh_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— theunattendedrow reportssecure === trueregardless of active state, and carries the neutral marker; other rows keep SICHER/OFFEN semantics.FleetService::testConnection— returnsok:false,erroron connect failure (mock SshClient boundary),ok:trueon success.CreateServer— invalid SSH →ValidationException, noServerrow persisted (rollback); valid SSH → server persisted withstatus='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 -varm). - No new auto-update detection accuracy work (operator decided: reframe only).
- Dead
dualChartremoval, Caddy/ACME live test, firewalld rule mutation — tracked separately indocs/session-handoff.md§3.