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
# 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)

View File

@ -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<int,int> $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,

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
* 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\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<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

View File

@ -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', ''),
]);
}
}

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
$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
<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) --}}
<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" 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>
<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>
@if ($label)

View File

@ -16,7 +16,7 @@
$memP = $chart($memSeries);
@endphp
<div class="space-y-4">
<div class="space-y-4" wire:poll.10s>
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@ -42,7 +42,7 @@
<x-badge tone="accent">Live</x-badge>
</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 --}}
<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>
@ -62,13 +62,13 @@
</g>
{{-- MEM (cyan) --}}
<g class="text-cyan">
<polyline points="{{ $memP['area'] }}" :points="memArea" 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['area'] }}" fill="currentColor" fill-opacity="0.10" stroke="none" />
<polyline points="{{ $memP['line'] }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g>
{{-- CPU (accent) --}}
<g class="text-accent">
<polyline points="{{ $cpuP['area'] }}" :points="cpuArea" 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['area'] }}" fill="currentColor" fill-opacity="0.13" stroke="none" />
<polyline points="{{ $cpuP['line'] }}" fill="none" stroke="currentColor" stroke-width="1.75" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g>
</svg>
</div>

View File

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