From 0a7fc4780cc2f9517db4e53612bc179262aa1789 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 13 Jun 2026 00:07:56 +0200 Subject: [PATCH] feat(ux): detail-page redesign, settings page, key generation, scrollable services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round of UX work from user feedback (browser-verified per R12 — all routes 200, zero console errors). - Server-Details redesign (/frontend-design): hero header band (status-tinted server glyph + name + meta strip + actions), a full-width row of dense vital cards (ring + label + absolute used/total) instead of 3 donuts floating in a height-stretched panel, hardening checklist with status-colored accent borders, and a clear section hierarchy (vitals → specs+security → volumes+net → keys). - New user Settings page (/einstellungen): profile (name/email), password change (current-password gated), 2FA status + enable link / disable (confirm + audit). Sidebar "Konto" nav group + clickable user block. - SSH key generation: "Neues Paar generieren" in the add-key modal makes an ed25519 keypair (phpseclib) — public key installed on save, private shown once. - Services list is height-capped + scrollable so the Journal below is reachable. - now also renders (href) for link-buttons. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Modals/AddSshKey.php | 13 + app/Livewire/Servers/Show.php | 3 + app/Livewire/Settings/Index.php | 103 ++++++ resources/views/components/btn.blade.php | 8 +- resources/views/components/icon.blade.php | 1 + resources/views/components/sidebar.blade.php | 17 +- .../livewire/modals/add-ssh-key.blade.php | 22 +- .../views/livewire/servers/show.blade.php | 307 ++++++++++-------- .../views/livewire/services/index.blade.php | 2 +- .../views/livewire/settings/index.blade.php | 83 +++++ routes/web.php | 3 + 11 files changed, 415 insertions(+), 147 deletions(-) create mode 100644 app/Livewire/Settings/Index.php create mode 100644 resources/views/livewire/settings/index.blade.php diff --git a/app/Livewire/Modals/AddSshKey.php b/app/Livewire/Modals/AddSshKey.php index ce2bb52..bae56af 100644 --- a/app/Livewire/Modals/AddSshKey.php +++ b/app/Livewire/Modals/AddSshKey.php @@ -7,6 +7,7 @@ use App\Models\Server; use App\Services\FleetService; use Illuminate\Support\Facades\Auth; use LivewireUI\Modal\ModalComponent; +use phpseclib3\Crypt\EC; use Throwable; /** @@ -19,6 +20,8 @@ class AddSshKey extends ModalComponent public string $publicKey = ''; + public ?string $generatedPrivate = null; + public ?string $error = null; public function mount(int $serverId): void @@ -31,6 +34,16 @@ class AddSshKey extends ModalComponent return 'lg'; } + /** Generate a fresh ed25519 keypair: the public key is installed on save, + * the private key is shown once for the operator to store. */ + public function generate(): void + { + $this->error = null; + $key = EC::createKey('ed25519'); + $this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']); + $this->generatedPrivate = (string) $key->toString('OpenSSH'); + } + public function save(FleetService $fleet): void { $this->error = null; diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index ad3849a..6f9e0cc 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -21,6 +21,8 @@ class Show extends Component public bool $ready = false; + public float $loadAvg = 0; + public array $volumes = []; public array $interfaces = []; @@ -71,6 +73,7 @@ class Show extends Component $this->interfaces = $snap['interfaces']; $this->hardening = $snap['hardening']; $this->sshKeys = $snap['sshKeys']; + $this->loadAvg = (float) ($snap['metrics']['load'] ?? 0); $this->connected = true; } catch (Throwable) { $this->connected = false; diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php new file mode 100644 index 0000000..a1da499 --- /dev/null +++ b/app/Livewire/Settings/Index.php @@ -0,0 +1,103 @@ +name = Auth::user()->name; + $this->email = Auth::user()->email; + } + + public function updateProfile(): void + { + $data = $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())], + ]); + + Auth::user()->update($data); + $this->audit('profile.update', $data['email']); + $this->dispatch('notify', message: 'Profil gespeichert.'); + } + + public function updatePassword(): void + { + $this->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::min(10)], + ]); + + Auth::user()->update(['password' => $this->password, 'must_change_password' => false]); + $this->reset('current_password', 'password', 'password_confirmation'); + $this->audit('password.change', Auth::user()->email); + $this->dispatch('notify', message: 'Passwort geändert.'); + } + + public function confirmDisableTwoFactor(): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => '2FA deaktivieren', + 'body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.', + 'confirmLabel' => 'Deaktivieren', + 'danger' => true, + 'icon' => 'shield', + 'auditAction' => 'two_factor.disable', + 'auditTarget' => Auth::user()->email, + 'event' => 'twoFactorDisabled', + 'notify' => '2FA deaktiviert.', + ], + ); + } + + #[On('twoFactorDisabled')] + public function disableTwoFactor(): void + { + Auth::user()->forceFill([ + 'two_factor_secret' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()->name, + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function render() + { + return view('livewire.settings.index', [ + 'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(), + ]); + } +} diff --git a/resources/views/components/btn.blade.php b/resources/views/components/btn.blade.php index d146236..fbd92f3 100644 --- a/resources/views/components/btn.blade.php +++ b/resources/views/components/btn.blade.php @@ -1,4 +1,4 @@ -@props(['variant' => 'secondary', 'icon' => false]) +@props(['variant' => 'secondary', 'icon' => false, 'href' => null]) @php // One button style for the whole app — compact, consistent (R10). $variants = [ @@ -14,4 +14,8 @@ .'disabled:opacity-50 disabled:pointer-events-none ' .$shape.' '.($variants[$variant] ?? $variants['secondary']); @endphp - +@if ($href) + merge(['class' => $classes]) }}>{{ $slot }} +@else + +@endif diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index eea1d2f..c40012b 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -20,6 +20,7 @@ 'power' => '', 'rotate' => '', 'trash' => '', + 'settings' => '', ]; @endphp merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor" diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index e0be486..c6c11b8 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -29,17 +29,22 @@ Dienste Dateien Audit-Log + +

Konto

+ Einstellungen {{-- User --}} @php($u = auth()->user())
-
- {{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }} - - {{ $u?->name ?? '—' }} - {{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }} - +
+ + {{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }} + + {{ $u?->name ?? '—' }} + {{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }} + +
@csrf
+
+ + +
+ + @if ($generatedPrivate) +
+

+ Privater Schlüssel — erscheint nur jetzt, sicher speichern. +

+ +
+ @endif + @if ($error)

{{ $error }} diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 4415b9d..d123616 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -1,170 +1,199 @@ @php // Threshold tone for gauges/bars: >=90 offline, >=75 warning, else online. $tone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online'); - $barColor = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline']; - $statusLabel = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$server->status] ?? ucfirst($server->status); $specs = $server->specs ?? []; + $cores = $specs['cores'] ?? null; + $ramGb = $specs['ram_gb'] ?? null; + $diskGb = $specs['disk_gb'] ?? null; + $ramUsed = $ramGb !== null ? round((float) $ramGb * $server->mem / 100, 1) : null; + $diskUsed = $diskGb !== null ? (int) round((float) $diskGb * $server->disk / 100) : null; + $specRows = [ - ['Kerne', $specs['cores'] ?? '—'], - ['RAM', isset($specs['ram_gb']) ? $specs['ram_gb'].' GB' : '—'], - ['Disk', isset($specs['disk_gb']) ? $specs['disk_gb'].' GB' : '—'], - ['Architektur', $specs['arch'] ?? '—'], - ['Kernel', $specs['kernel'] ?? '—'], - ['Virtualisierung', $specs['virt'] ?? '—'], + ['Architektur', $specs['arch'] ?? '—'], + ['Kernel', $specs['kernel'] ?? '—'], + ['Virtualisierung', $specs['virt'] ?? '—'], + ['Kerne', $cores ?? '—'], + ['RAM', $ramGb !== null ? $ramGb.' GB' : '—'], + ['Disk', $diskGb !== null ? $diskGb.' GB' : '—'], + ]; + + $vitals = [ + ['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')], + ['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'], + ['label' => 'Disk', 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? $diskUsed.' / '.$diskGb.' GB' : '—'], ]; @endphp -

- {{-- Header --}} -
- - - Zurück zur Flotte - +
+ + + Zurück zur Flotte + -
+ {{-- Hero header --}} +
+
+ $server->status === 'online', + 'border-warning/30 bg-warning/10 text-warning' => $server->status === 'warning', + 'border-offline/30 bg-offline/10 text-offline' => $server->status === 'offline', + ])> + +
-

Server

-

{{ $server->name }}

-

+

+

{{ $server->name }}

+ {{ $statusLabel }} +
+

{{ $server->hostname }} · {{ $server->ip }} · {{ $server->os }} · - Uptime {{ $server->uptime }} + Uptime {{ $server->uptime ?: '—' }} · - zuletzt gesehen {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }} + zuletzt {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}

-
- - Zugang - - - Dateien - - {{ $statusLabel }} -
+
+
+ + Zugang + + + Dateien +
@if ($ready && ! $connected) -
+
-

Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Credential/Erreichbarkeit prüfen.

+

Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Zugang/Erreichbarkeit prüfen.

@endif - {{-- Auslastung + Spezifikationen --}} -
- -
- - - + {{-- Vitals: live gauges, full width, dense --}} +
+ @foreach ($vitals as $v) +
+ +
+

+ {{ $v['label'] }} +

+

{{ $v['sub'] }}

+
- - - -
- @foreach ($specRows as [$key, $val]) -
-
{{ $key }}
-
{{ $val }}
-
- @endforeach -
-
+ @endforeach
@if ($ready) - {{-- Volumes + Netzwerk-Interfaces --}} -
- -
- @foreach ($volumes as $vol) - @php $vt = $tone((int) $vol['used']); @endphp -
-
-

{{ $vol['mount'] }}

-

- {{ $vol['fs'] }} · {{ $vol['size'] }} -

+ {{-- Spezifikationen + Sicherheit --}} +
+ +
+ @foreach ($specRows as [$key, $val]) +
+
{{ $key }}
+
{{ $val }}
-
-
-
+ @endforeach +
+
+ + +
+ @foreach ($hardening as $check) +
$check['status'] === 'online', + 'border-warning' => $check['status'] === 'warning', + 'border-offline' => $check['status'] === 'offline', + ])> +
+

{{ $check['label'] }}

+

{{ $check['detail'] }}

- {{ $vol['used'] }}% + + {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} +
-
- @endforeach -
- + @endforeach +
+ +
- - {{-- Dense table on desktop; horizontal scroll keeps columns aligned on small screens (R7) --}} -
- - - - - - - - - - - - @foreach ($interfaces as $if) - - - - - - + {{-- Volumes + Netzwerk-Interfaces --}} +
+ +
+ @forelse ($volumes as $vol) + @php $vt = $tone((int) $vol['used']); @endphp +
+
+

{{ $vol['mount'] }}

+

+ {{ $vol['fs'] }} · {{ $vol['size'] }} +

+
+
+
+
+
+ {{ $vol['used'] }}% +
+
+ @empty +

Keine Volumes gelesen.

+ @endforelse +
+
+ + +
+
NameIPMACRXTX
{{ $if['name'] }}{{ $if['ip'] }}{{ $if['mac'] }}{{ $if['rx'] }}{{ $if['tx'] }}
+ + + + + + + - @endforeach - -
NameIPMACRXTX
-
-
-
- - {{-- Sicherheit (Hardening) + SSH-Schlüssel --}} -
- -
- @foreach ($hardening as $check) -
-
-

{{ $check['label'] }}

-

{{ $check['detail'] }}

-
- - {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} - -
- @endforeach -
-
+ + + @foreach ($interfaces as $if) + + {{ $if['name'] }} + {{ $if['ip'] }} + {{ $if['mac'] }} + {{ $if['rx'] }} + {{ $if['tx'] }} + + @endforeach + + +
+
+
+ {{-- SSH-Schlüssel --}} - {{-- R5: wire to wire-elements/modal --}} - - Schlüssel hinzufügen + Schlüssel hinzufügen
- @foreach ($sshKeys as $key) + @forelse ($sshKeys as $key)
@@ -176,31 +205,37 @@

{{ $key['fingerprint'] }}

- {{-- R5: destructive → wire-elements/modal confirm + audit --}}
- @endforeach + @empty +

Keine autorisierten Schlüssel.

+ @endforelse
-
@else + {{-- skeletons for the SSH-loaded sections --}}
- -
-
- -
-
+ @foreach (['Spezifikationen', 'Sicherheit'] as $t) + +
+ +
+
+ @endforeach
- -
-
- -
-
+ @foreach (['Volumes', 'Netzwerk-Interfaces'] as $t) + +
+ +
+
+ @endforeach
+ +
+
@endif
diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php index 80d4389..c3e7cc3 100644 --- a/resources/views/livewire/services/index.blade.php +++ b/resources/views/livewire/services/index.blade.php @@ -36,7 +36,7 @@ -
+
@if (! $ready) @for ($i = 0; $i < 7; $i++)
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php new file mode 100644 index 0000000..84e72ed --- /dev/null +++ b/resources/views/livewire/settings/index.blade.php @@ -0,0 +1,83 @@ +@php + $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+ {{-- Header --}} +
+

Konto

+

Einstellungen

+
+ + {{-- Profil --}} + + +
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + Speichern + +
+ +
+ + {{-- Passwort --}} + +
+
+ + + @error('current_password')

{{ $message }}

@enderror +
+
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+
+
+ + + Passwort ändern + +
+
+
+ + {{-- 2FA --}} + +
+
+ +
+

{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}

+

{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}

+
+
+ @if ($twoFactorEnabled) + Deaktivieren + @else + + Einrichten + + @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index 332a131..7a5b371 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Livewire\Dashboard; use App\Livewire\Files; use App\Livewire\Servers; use App\Livewire\Services; +use App\Livewire\Settings; use Illuminate\Support\Facades\Auth as AuthFacade; use Illuminate\Support\Facades\Route; @@ -38,5 +39,7 @@ Route::middleware('auth')->group(function () { Route::get('/services', Services\Index::class)->name('services.index'); Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); + + Route::get('/einstellungen', Settings\Index::class)->name('settings'); }); });