From b7fe66406cfdad27d42eda211c48f8a0e67e9fc8 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 22:38:51 +0200 Subject: [PATCH] fix(live): poll-driven metrics (chart + gauges), bigger donuts, drop fake fleet Addresses "chart/gauges don't update" and "design": the visible live layer no longer depends on the Reverb WS path (which was unreliable through the dev NAT). - Poller is now the single SSH metrics source: FleetService::applyMetrics writes the latest reading + a rolling cpu/mem history into the cache (and the DB). - Dashboard reads latest + history from cache (no SSH on web render); the big chart and KPI sparklines render real history; wire:poll.10s refreshes them. - Server-Details gauges read the poller-updated row via wire:poll.10s; the donut rings get a size="lg" variant (h-24) so they read clearly + animate. - FleetSeeder no longer seeds a fake fleet (those servers had no credentials and showed no data). It seeds one real server from CLUSEV_DEMO_SSH_* env vars, or an empty fleet otherwise. Existing fake servers removed from the demo DB. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 10 +++ app/Livewire/Dashboard.php | 61 +++++++++---------- app/Livewire/Servers/Show.php | 8 +++ app/Services/FleetService.php | 30 ++++++++- database/seeders/FleetSeeder.php | 52 ++++++++-------- resources/views/components/ring.blade.php | 12 ++-- resources/views/livewire/dashboard.blade.php | 12 ++-- .../views/livewire/servers/show.blade.php | 12 ++-- 8 files changed, 120 insertions(+), 77 deletions(-) diff --git a/.env.example b/.env.example index 0c35771..ffde51e 100644 --- a/.env.example +++ b/.env.example @@ -85,3 +85,13 @@ SITE_ADDRESS=:80 CLUSEV_IMAGE=clusev-app:prod # HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. UPDATE_HMAC_KEY= + +# ── Optional demo server (seeded by FleetSeeder) ───────────────────── +# Leave HOST empty for an empty fleet (add servers at runtime). With a host set, +# the seeder creates one real server + an encrypted credential. Never committed. +CLUSEV_DEMO_SSH_NAME=demo-01 +CLUSEV_DEMO_SSH_HOST= +CLUSEV_DEMO_SSH_PORT=22 +CLUSEV_DEMO_SSH_USER=root +CLUSEV_DEMO_SSH_PASSWORD= +# CLUSEV_DEMO_SSH_KEY= # private-key PEM instead of a password (preferred) diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index e5b60a3..f65cee6 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -18,7 +18,7 @@ class Dashboard extends Component { use WithFleetContext; - /** Deterministic wavy series around a base — seeds the sparklines/chart. */ + /** Deterministic wavy series — fallback sparkline for metrics without history. */ private function series(float $base, int $n = 40, float $amp = 9): array { $out = []; @@ -30,6 +30,20 @@ class Dashboard extends Component return $out; } + /** Pad a real history series to a minimum length for a stable sparkline. */ + private function pad(array $values, int $current, int $min = 16): array + { + $values = array_map('intval', $values); + if ($values === []) { + $values = [$current]; + } + while (count($values) < $min) { + array_unshift($values, $values[0]); + } + + return $values; + } + /** @param array $series */ private function trend(array $series, string $suffix = '%'): array { @@ -43,28 +57,7 @@ class Dashboard extends Component ]; } - /** Live metrics for the active server (briefly cached), DB values as fallback. */ - private function liveMetrics(FleetService $fleet, ?Server $active): array - { - $fallback = [ - 'cpu' => (int) ($active?->cpu ?? 0), - 'mem' => (int) ($active?->mem ?? 0), - 'disk' => (int) ($active?->disk ?? 0), - 'load' => 0.0, - ]; - - if (! $active || ! $active->credential_exists) { - return $fallback; - } - - try { - return Cache::remember("metrics:{$active->id}", 10, fn () => $fleet->metrics($active)); - } catch (Throwable) { - return $fallback; - } - } - - /** A handful of notable systemd units (failed + running first). */ + /** A handful of notable systemd units (failed + running first), cached. */ private function liveServices(FleetService $fleet, ?Server $active): array { if (! $active || ! $active->credential_exists) { @@ -72,7 +65,7 @@ class Dashboard extends Component } try { - $svc = Cache::remember("svc:{$active->id}", 15, fn () => $fleet->systemd($active, 1)['services']); + $svc = Cache::remember("svc:{$active->id}", 30, fn () => $fleet->systemd($active, 1)['services']); } catch (Throwable) { return []; } @@ -90,22 +83,26 @@ class Dashboard extends Component public function render(FleetService $fleet) { $active = $this->activeServer(); - $m = $this->liveMetrics($fleet, $active); - $cpu = $m['cpu']; - $mem = $m['mem']; - $disk = $m['disk']; - $cpuSeries = $this->series(max(8, $cpu), 40, 10); - $memSeries = $this->series(max(8, $mem), 40, 7); + // Metrics come from the poller (DB + cache) — no SSH on web render. + $latest = $active ? $fleet->latest($active) : null; + $cpu = (int) ($latest['cpu'] ?? $active?->cpu ?? 0); + $mem = (int) ($latest['mem'] ?? $active?->mem ?? 0); + $disk = (int) ($latest['disk'] ?? $active?->disk ?? 0); + $load = (float) ($latest['load'] ?? 0); + + $hist = $active ? $fleet->history($active) : ['cpu' => [], 'mem' => []]; + $cpuSeries = $this->pad($hist['cpu'], $cpu); + $memSeries = $this->pad($hist['mem'], $mem); $diskSeries = $this->series(max(4, $disk), 40, 2); - $loadSeries = $this->series(max(8, (int) round($cpu * 0.8)), 40, 12); + $loadSeries = $this->series(max(8, (int) round($load * 18)), 40, 6); return view('livewire.dashboard', [ 'active' => $active, 'cpu' => $cpu, 'mem' => $mem, 'disk' => $disk, - 'load' => round($m['load'], 2), + 'load' => round($load, 2), 'cpuSeries' => $cpuSeries, 'memSeries' => $memSeries, 'diskSeries' => $diskSeries, diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 7cec9b9..d3dfff6 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -71,6 +71,14 @@ class Show extends Component } } + /** Refresh the live gauges from the poller-updated row (wire:poll). */ + public function pollMetrics(): void + { + if ($this->connected) { + $this->server->refresh(); + } + } + /** * Revoke an SSH key (R5): opens the confirm modal, which writes the * AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later. diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 1bbea10..e4fd56c 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Server; use App\Support\Ssh\CredentialVault; use App\Support\Ssh\SshClient; +use Illuminate\Support\Facades\Cache; use Throwable; /** @@ -95,7 +96,7 @@ class FleetService return $m; } - /** Persist a metrics reading onto the Server row. */ + /** Persist a metrics reading onto the Server row + cache (latest + rolling history). */ public function applyMetrics(Server $server, array $m): void { $server->forceFill([ @@ -105,6 +106,33 @@ class FleetService 'status' => $this->statusFor($m), 'last_seen_at' => now(), ])->save(); + + Cache::put("metrics:latest:{$server->id}", $m, now()->addMinutes(5)); + + $history = Cache::get("metrics:history:{$server->id}", []); + $history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem']]; + Cache::put("metrics:history:{$server->id}", array_slice($history, -40), now()->addHour()); + } + + /** Last persisted metrics reading (cache), or null. */ + public function latest(Server $server): ?array + { + return Cache::get("metrics:latest:{$server->id}"); + } + + /** + * Rolling cpu/mem history for sparklines/charts. + * + * @return array{cpu: array, mem: array} + */ + public function history(Server $server): array + { + $h = Cache::get("metrics:history:{$server->id}", []); + + return [ + 'cpu' => array_map('intval', array_column($h, 'cpu')), + 'mem' => array_map('intval', array_column($h, 'mem')), + ]; } private function statusFor(array $m): string diff --git a/database/seeders/FleetSeeder.php b/database/seeders/FleetSeeder.php index e4e0ab0..a15a432 100644 --- a/database/seeders/FleetSeeder.php +++ b/database/seeders/FleetSeeder.php @@ -2,44 +2,42 @@ namespace Database\Seeders; -use App\Models\AuditEvent; use App\Models\Server; use Illuminate\Database\Seeder; +/** + * Seeds a single REAL demo server from environment variables (so no fake fleet + * and no credentials live in source). Set in .env (gitignored): + * + * CLUSEV_DEMO_SSH_HOST=10.0.0.10 + * CLUSEV_DEMO_SSH_USER=ops + * CLUSEV_DEMO_SSH_PASSWORD=... (or CLUSEV_DEMO_SSH_KEY for a private key) + * + * With no CLUSEV_DEMO_SSH_HOST set, the fleet starts empty — servers are added + * at runtime. The credential is stored encrypted via the SshCredential cast. + */ class FleetSeeder extends Seeder { public function run(): void { - $fleet = [ - ['name' => 'web-01', 'ip' => '10.10.90.136', 'os' => 'Debian 13', 'status' => 'online', 'cpu' => 34, 'mem' => 61, 'disk' => 71, 'uptime' => '42d'], - ['name' => 'db-02', 'ip' => '10.10.90.140', 'os' => 'Debian 12', 'status' => 'warning', 'cpu' => 78, 'mem' => 83, 'disk' => 64, 'uptime' => '11d'], - ['name' => 'cache-03', 'ip' => '10.10.90.141', 'os' => 'Alpine 3.20', 'status' => 'offline', 'cpu' => 0, 'mem' => 0, 'disk' => 0, 'uptime' => null], - ['name' => 'edge-04', 'ip' => '10.10.90.150', 'os' => 'Ubuntu 24.04', 'status' => 'online', 'cpu' => 12, 'mem' => 39, 'disk' => 28, 'uptime' => '7d'], - ]; + $host = env('CLUSEV_DEMO_SSH_HOST'); - foreach ($fleet as $s) { - Server::updateOrCreate(['name' => $s['name']], [ - ...$s, - 'hostname' => $s['name'].'.clusev.local', - 'specs' => ['cores' => 4, 'ram_gb' => 8, 'disk_gb' => 160, 'arch' => 'x86_64', 'kernel' => '6.12.0', 'virt' => 'KVM'], - 'last_seen_at' => $s['status'] === 'offline' ? now()->subHour() : now(), - ]); + if (! $host) { + return; } - $web = Server::where('name', 'web-01')->first(); + $server = Server::updateOrCreate(['ip' => $host], [ + 'name' => env('CLUSEV_DEMO_SSH_NAME', 'demo-01'), + 'ssh_port' => (int) env('CLUSEV_DEMO_SSH_PORT', 22), + 'status' => 'online', + ]); - $events = [ - ['actor' => 'admin', 'action' => 'Dienst neu gestartet', 'target' => 'php8.3-fpm.service', 'server_id' => $web?->id, 'created_at' => now()->subMinutes(2)], - ['actor' => 'admin', 'action' => 'SSH-Schlüssel hinzugefügt', 'target' => 'web-01', 'server_id' => $web?->id, 'created_at' => now()->subMinutes(18)], - ['actor' => 'system', 'action' => 'Server offline', 'target' => 'cache-03', 'server_id' => null, 'created_at' => now()->subHour()], - ['actor' => 'admin', 'action' => 'Datei bearbeitet', 'target' => '/etc/nginx/nginx.conf', 'server_id' => $web?->id, 'created_at' => now()->subHours(3)], - ]; + $key = env('CLUSEV_DEMO_SSH_KEY'); - foreach ($events as $e) { - AuditEvent::updateOrCreate( - ['actor' => $e['actor'], 'action' => $e['action'], 'target' => $e['target']], - $e, - ); - } + $server->credential()->updateOrCreate([], [ + 'username' => env('CLUSEV_DEMO_SSH_USER', 'root'), + 'auth_type' => $key ? 'key' : 'password', + 'secret' => $key ?: env('CLUSEV_DEMO_SSH_PASSWORD', ''), + ]); } } diff --git a/resources/views/components/ring.blade.php b/resources/views/components/ring.blade.php index c81750e..598ca0f 100644 --- a/resources/views/components/ring.blade.php +++ b/resources/views/components/ring.blade.php @@ -1,4 +1,4 @@ -@props(['value' => 0, 'label' => null, 'sub' => null, 'tone' => 'accent']) +@props(['value' => 0, 'label' => null, 'sub' => null, 'tone' => 'accent', 'size' => 'md']) @php $v = max(0, min(100, (float) $value)); $r = 34; @@ -8,17 +8,19 @@ 'accent' => 'text-accent', 'online' => 'text-online', 'warning' => 'text-warning', 'offline' => 'text-offline', 'cyan' => 'text-cyan', ][$tone] ?? 'text-accent'; + $box = $size === 'lg' ? 'h-24 w-24' : 'h-16 w-16'; + $num = $size === 'lg' ? 'text-lg' : 'text-xs'; @endphp
merge(['class' => 'flex flex-col items-center']) }}> -
+
{{-- progress via SVG stroke attributes (not inline style) --}} -
- {{ round($v) }}% + {{ round($v) }}%
@if ($label) diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index e997207..ccbcfeb 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -16,7 +16,7 @@ $memP = $chart($memSeries); @endphp -
+
{{-- Header --}}
@@ -42,7 +42,7 @@ Live -
+
{{-- Y axis --}}
100 @@ -62,13 +62,13 @@ {{-- MEM (cyan) --}} - - + + {{-- CPU (accent) --}} - - + +
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index ad74f2f..75fc3fd 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -17,7 +17,7 @@ ]; @endphp -
+
{{-- Header --}}