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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 22:38:51 +02:00
parent 5a9b6d317f
commit b7fe66406c
8 changed files with 120 additions and 77 deletions

View File

@ -85,3 +85,13 @@ SITE_ADDRESS=:80
CLUSEV_IMAGE=clusev-app:prod CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. # HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_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)

View File

@ -18,7 +18,7 @@ class Dashboard extends Component
{ {
use WithFleetContext; 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 private function series(float $base, int $n = 40, float $amp = 9): array
{ {
$out = []; $out = [];
@ -30,6 +30,20 @@ class Dashboard extends Component
return $out; 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<int,int> $series */ /** @param array<int,int> $series */
private function trend(array $series, string $suffix = '%'): array 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. */ /** A handful of notable systemd units (failed + running first), cached. */
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). */
private function liveServices(FleetService $fleet, ?Server $active): array private function liveServices(FleetService $fleet, ?Server $active): array
{ {
if (! $active || ! $active->credential_exists) { if (! $active || ! $active->credential_exists) {
@ -72,7 +65,7 @@ class Dashboard extends Component
} }
try { 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) { } catch (Throwable) {
return []; return [];
} }
@ -90,22 +83,26 @@ class Dashboard extends Component
public function render(FleetService $fleet) public function render(FleetService $fleet)
{ {
$active = $this->activeServer(); $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); // Metrics come from the poller (DB + cache) — no SSH on web render.
$memSeries = $this->series(max(8, $mem), 40, 7); $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); $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', [ return view('livewire.dashboard', [
'active' => $active, 'active' => $active,
'cpu' => $cpu, 'cpu' => $cpu,
'mem' => $mem, 'mem' => $mem,
'disk' => $disk, 'disk' => $disk,
'load' => round($m['load'], 2), 'load' => round($load, 2),
'cpuSeries' => $cpuSeries, 'cpuSeries' => $cpuSeries,
'memSeries' => $memSeries, 'memSeries' => $memSeries,
'diskSeries' => $diskSeries, 'diskSeries' => $diskSeries,

View File

@ -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 * Revoke an SSH key (R5): opens the confirm modal, which writes the
* AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later. * AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later.

View File

@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\Server; use App\Models\Server;
use App\Support\Ssh\CredentialVault; use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\SshClient; use App\Support\Ssh\SshClient;
use Illuminate\Support\Facades\Cache;
use Throwable; use Throwable;
/** /**
@ -95,7 +96,7 @@ class FleetService
return $m; 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 public function applyMetrics(Server $server, array $m): void
{ {
$server->forceFill([ $server->forceFill([
@ -105,6 +106,33 @@ class FleetService
'status' => $this->statusFor($m), 'status' => $this->statusFor($m),
'last_seen_at' => now(), 'last_seen_at' => now(),
])->save(); ])->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<int,int>, mem: array<int,int>}
*/
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 private function statusFor(array $m): string

View File

@ -2,44 +2,42 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\AuditEvent;
use App\Models\Server; use App\Models\Server;
use Illuminate\Database\Seeder; 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 class FleetSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
$fleet = [ $host = env('CLUSEV_DEMO_SSH_HOST');
['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'],
];
foreach ($fleet as $s) { if (! $host) {
Server::updateOrCreate(['name' => $s['name']], [ return;
...$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(),
]);
} }
$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 = [ $key = env('CLUSEV_DEMO_SSH_KEY');
['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)],
];
foreach ($events as $e) { $server->credential()->updateOrCreate([], [
AuditEvent::updateOrCreate( 'username' => env('CLUSEV_DEMO_SSH_USER', 'root'),
['actor' => $e['actor'], 'action' => $e['action'], 'target' => $e['target']], 'auth_type' => $key ? 'key' : 'password',
$e, 'secret' => $key ?: env('CLUSEV_DEMO_SSH_PASSWORD', ''),
); ]);
}
} }
} }

View File

@ -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 @php
$v = max(0, min(100, (float) $value)); $v = max(0, min(100, (float) $value));
$r = 34; $r = 34;
@ -8,17 +8,19 @@
'accent' => 'text-accent', 'online' => 'text-online', 'accent' => 'text-accent', 'online' => 'text-online',
'warning' => 'text-warning', 'offline' => 'text-offline', 'cyan' => 'text-cyan', 'warning' => 'text-warning', 'offline' => 'text-offline', 'cyan' => 'text-cyan',
][$tone] ?? 'text-accent'; ][$tone] ?? 'text-accent';
$box = $size === 'lg' ? 'h-24 w-24' : 'h-16 w-16';
$num = $size === 'lg' ? 'text-lg' : 'text-xs';
@endphp @endphp
<div {{ $attributes->merge(['class' => 'flex flex-col items-center']) }}> <div {{ $attributes->merge(['class' => 'flex flex-col items-center']) }}>
<div class="relative h-16 w-16"> <div class="relative {{ $box }}">
{{-- progress via SVG stroke attributes (not inline style) --}} {{-- progress via SVG stroke attributes (not inline style) --}}
<svg class="h-16 w-16 -rotate-90" viewBox="0 0 80 80" aria-hidden="true"> <svg class="{{ $box }} -rotate-90" viewBox="0 0 80 80" aria-hidden="true">
<circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" class="text-line" /> <circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" class="text-line" />
<circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" <circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"
stroke-dasharray="{{ $circ }}" stroke-dashoffset="{{ $offset }}" class="{{ $stroke }}" /> stroke-dasharray="{{ $circ }}" stroke-dashoffset="{{ $offset }}" class="{{ $stroke }} transition-[stroke-dashoffset] duration-700" />
</svg> </svg>
<div class="absolute inset-0 grid place-items-center"> <div class="absolute inset-0 grid place-items-center">
<span class="tabular font-mono text-xs font-semibold text-ink">{{ round($v) }}<span class="text-ink-3">%</span></span> <span class="tabular font-mono {{ $num }} font-semibold text-ink">{{ round($v) }}<span class="text-ink-3">%</span></span>
</div> </div>
</div> </div>
@if ($label) @if ($label)

View File

@ -16,7 +16,7 @@
$memP = $chart($memSeries); $memP = $chart($memSeries);
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4" wire:poll.10s>
{{-- Header --}} {{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3"> <div class="flex flex-wrap items-end justify-between gap-3">
<div> <div>
@ -42,7 +42,7 @@
<x-badge tone="accent">Live</x-badge> <x-badge tone="accent">Live</x-badge>
</x-slot:actions> </x-slot:actions>
<div class="relative pl-7" x-data="dualChart(@js($cpuSeries), @js($memSeries), 40, @js($active?->name))"> <div class="relative pl-7">
{{-- Y axis --}} {{-- Y axis --}}
<div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4"> <div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4">
<span class="absolute right-0 top-0 -translate-y-1/2">100</span> <span class="absolute right-0 top-0 -translate-y-1/2">100</span>
@ -62,13 +62,13 @@
</g> </g>
{{-- MEM (cyan) --}} {{-- MEM (cyan) --}}
<g class="text-cyan"> <g class="text-cyan">
<polyline points="{{ $memP['area'] }}" :points="memArea" fill="currentColor" fill-opacity="0.10" stroke="none" /> <polyline points="{{ $memP['area'] }}" fill="currentColor" fill-opacity="0.10" stroke="none" />
<polyline points="{{ $memP['line'] }}" :points="memLine" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" /> <polyline points="{{ $memP['line'] }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g> </g>
{{-- CPU (accent) --}} {{-- CPU (accent) --}}
<g class="text-accent"> <g class="text-accent">
<polyline points="{{ $cpuP['area'] }}" :points="cpuArea" fill="currentColor" fill-opacity="0.13" stroke="none" /> <polyline points="{{ $cpuP['area'] }}" fill="currentColor" fill-opacity="0.13" stroke="none" />
<polyline points="{{ $cpuP['line'] }}" :points="cpuLine" fill="none" stroke="currentColor" stroke-width="1.75" vector-effect="non-scaling-stroke" stroke-linejoin="round" /> <polyline points="{{ $cpuP['line'] }}" fill="none" stroke="currentColor" stroke-width="1.75" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g> </g>
</svg> </svg>
</div> </div>

View File

@ -17,7 +17,7 @@
]; ];
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4" wire:poll.10s="pollMetrics">
{{-- Header --}} {{-- Header --}}
<div class="space-y-3"> <div class="space-y-3">
<a href="{{ route('servers.index') }}" <a href="{{ route('servers.index') }}"
@ -55,11 +55,11 @@
{{-- Auslastung + Spezifikationen --}} {{-- Auslastung + Spezifikationen --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Auslastung" subtitle="Echtzeit-Gauges"> <x-panel title="Auslastung" subtitle="Live · 10s">
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-4 py-3">
<x-ring :value="$server->cpu" label="CPU" :tone="$tone($server->cpu)" /> <x-ring :value="$server->cpu" label="CPU" size="lg" :tone="$tone($server->cpu)" />
<x-ring :value="$server->mem" label="RAM" :tone="$tone($server->mem)" /> <x-ring :value="$server->mem" label="RAM" size="lg" :tone="$tone($server->mem)" />
<x-ring :value="$server->disk" label="Disk" :tone="$tone($server->disk)" /> <x-ring :value="$server->disk" label="Disk" size="lg" :tone="$tone($server->disk)" />
</div> </div>
</x-panel> </x-panel>