From 688e9e78663a29a4f287e243fd5e1bc49e31eeab Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 14:44:11 +0200 Subject: [PATCH] feat: reference-depth dashboard (sparkline KPIs + live dual chart + table) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the dashboard to the BASTION reference (clusev-template) — the active server's detail view: - x-metric: KPI tiles with sparklines + trend (CPU/Memory/Disk/Load). - live dual-series chart (CPU+MEM, grid + Y/X axes + legend) as a dualChart Alpine island; MetricsTicked + clusev:mock-metrics now broadcast CPU+MEM. Static SSR paths remain as the no-JS fallback. - systemd services as a table. Tokens/currentColor only — no inline styles; Y-axis via utilities. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Console/Commands/MockMetrics.php | 12 +- app/Events/MetricsTicked.php | 5 +- app/Livewire/Dashboard.php | 87 +++++--- resources/js/app.js | 47 +++-- resources/views/components/metric.blade.php | 55 +++++ resources/views/livewire/dashboard.blade.php | 199 ++++++++++--------- 6 files changed, 252 insertions(+), 153 deletions(-) create mode 100644 resources/views/components/metric.blade.php diff --git a/app/Console/Commands/MockMetrics.php b/app/Console/Commands/MockMetrics.php index 9a79338..6b397cb 100644 --- a/app/Console/Commands/MockMetrics.php +++ b/app/Console/Commands/MockMetrics.php @@ -6,26 +6,28 @@ use App\Events\MetricsTicked; use Illuminate\Console\Command; /** - * Dev placeholder for the real MetricsPoller (SSH). Broadcasts a rolling CPU - * value over Reverb so the dashboard chart moves live. + * Dev placeholder for the real MetricsPoller (SSH). Broadcasts rolling CPU+MEM + * values over Reverb so the dashboard chart moves live. */ class MockMetrics extends Command { protected $signature = 'clusev:mock-metrics {--interval=2 : Seconds between ticks}'; - protected $description = 'Broadcast mock CPU metrics over Reverb (dev)'; + protected $description = 'Broadcast mock CPU+MEM metrics over Reverb (dev)'; public function handle(): int { $interval = max(1, (int) $this->option('interval')); $cpu = 40; + $mem = 55; $this->info("Broadcasting mock metrics every {$interval}s on channel 'metrics' …"); while (true) { $cpu = max(2, min(98, $cpu + random_int(-12, 12))); - broadcast(new MetricsTicked($cpu)); - $this->line('tick cpu='.$cpu); + $mem = max(2, min(98, $mem + random_int(-6, 6))); + broadcast(new MetricsTicked($cpu, $mem)); + $this->line("tick cpu={$cpu} mem={$mem}"); sleep($interval); } } diff --git a/app/Events/MetricsTicked.php b/app/Events/MetricsTicked.php index 43c26aa..29f34b1 100644 --- a/app/Events/MetricsTicked.php +++ b/app/Events/MetricsTicked.php @@ -9,7 +9,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; /** - * A single CPU metric sample for a server, pushed to the browser over Reverb. + * A single CPU+MEM metric sample for a server, pushed to the browser over Reverb. * Dev: emitted by `clusev:mock-metrics`. Later: by MetricsPoller (SSH). */ class MetricsTicked implements ShouldBroadcast @@ -18,6 +18,7 @@ class MetricsTicked implements ShouldBroadcast public function __construct( public int $cpu, + public int $mem = 0, public string $server = 'web-01', ) {} @@ -34,6 +35,6 @@ class MetricsTicked implements ShouldBroadcast /** @return array */ public function broadcastWith(): array { - return ['cpu' => $this->cpu, 'server' => $this->server]; + return ['cpu' => $this->cpu, 'mem' => $this->mem, 'server' => $this->server]; } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index d1b3d22..0f0b8eb 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,6 +3,7 @@ namespace App\Livewire; use App\Livewire\Concerns\WithFleetContext; +use App\Models\AuditEvent; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -13,48 +14,72 @@ class Dashboard extends Component { use WithFleetContext; - /** - * Mock fleet data. Replaced by the SSH layer (phpseclib) + Reverb later. - * Shapes mirror what MetricsPoller/FleetService will return. - */ - public array $servers = []; - + /** Mock systemd units for the active server (until the SSH layer). */ public array $services = []; - public array $events = []; - - /** rolling CPU% series for the live chart placeholder */ - public array $metrics = []; - public function mount(): void { - $this->servers = [ - ['name' => 'web-01', 'ip' => '10.10.90.136', 'os' => 'Debian 13', 'status' => 'online', 'cpu' => 34, 'mem' => 61], - ['name' => 'db-02', 'ip' => '10.10.90.140', 'os' => 'Debian 12', 'status' => 'warning', 'cpu' => 78, 'mem' => 83], - ['name' => 'cache-03', 'ip' => '10.10.90.141', 'os' => 'Alpine 3.20', 'status' => 'offline', 'cpu' => 0, 'mem' => 0], - ['name' => 'edge-04', 'ip' => '10.10.90.150', 'os' => 'Ubuntu 24.04','status' => 'online', 'cpu' => 12, 'mem' => 39], - ]; - $this->services = [ - ['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server'], - ['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server'], - ['name' => 'redis-server.service','status' => 'online', 'desc' => 'Advanced key-value store'], - ['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'The PHP 8.3 FastCGI Process Manager'], - ['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts that cause multiple auth errors'], + ['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server', 'cpu' => 1.4, 'mem' => 128], + ['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server', 'cpu' => 3.8, 'mem' => 612], + ['name' => 'redis-server.service', 'status' => 'online', 'desc' => 'Advanced key-value store', 'cpu' => 0.6, 'mem' => 44], + ['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'PHP 8.3 FastCGI Process Manager', 'cpu' => 6.1, 'mem' => 320], + ['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts with auth errors', 'cpu' => 0, 'mem' => 0], ]; + } - $this->events = [ - ['actor' => 'admin', 'action' => 'Dienst neu gestartet', 'target' => 'php8.3-fpm.service', 'when' => 'vor 2 Min'], - ['actor' => 'admin', 'action' => 'SSH-Schlüssel hinzugefügt', 'target' => 'web-01', 'when' => 'vor 18 Min'], - ['actor' => 'system', 'action' => 'Server offline', 'target' => 'cache-03', 'when' => 'vor 1 Std'], - ['actor' => 'admin', 'action' => 'Datei bearbeitet', 'target' => '/etc/nginx/nginx.conf', 'when' => 'vor 3 Std'], + /** Deterministic wavy series around a base — seeds the sparklines/chart. */ + private function series(float $base, int $n = 40, float $amp = 9): array + { + $out = []; + for ($i = 0; $i < $n; $i++) { + $v = $base + sin($i / 3.0) * $amp + sin($i / 6.5) * ($amp * 0.55) + cos($i / 2.0) * 2; + $out[] = (int) max(2, min(98, round($v))); + } + + return $out; + } + + /** @param array $series */ + private function trend(array $series, string $suffix = '%'): array + { + $cur = (int) (end($series) ?: 0); + $prev = (int) ($series[max(0, count($series) - 8)] ?? $cur); + $d = $cur - $prev; + + return [ + 'dir' => $d > 1 ? 'up' : ($d < -1 ? 'down' : 'flat'), + 'text' => ($d >= 0 ? '+' : '').$d.$suffix, ]; - - $this->metrics = [22, 28, 25, 40, 38, 52, 47, 60, 55, 48, 63, 58, 71, 66, 74]; } public function render() { - return view('livewire.dashboard', ['active' => $this->activeServer()]); + $active = $this->activeServer(); + $cpu = (int) ($active?->cpu ?? 0); + $mem = (int) ($active?->mem ?? 0); + $disk = (int) ($active?->disk ?? 0); + + $cpuSeries = $this->series(max(8, $cpu), 40, 10); + $memSeries = $this->series(max(8, $mem), 40, 7); + $diskSeries = $this->series(max(4, $disk), 40, 2); + $loadSeries = $this->series(max(8, (int) round($cpu * 0.8)), 40, 12); + + return view('livewire.dashboard', [ + 'active' => $active, + 'cpu' => $cpu, + 'mem' => $mem, + 'disk' => $disk, + 'load' => round($cpu / 100 * 3.2, 2), + 'cpuSeries' => $cpuSeries, + 'memSeries' => $memSeries, + 'diskSeries' => $diskSeries, + 'loadSeries' => $loadSeries, + 'cpuTrend' => $this->trend($cpuSeries), + 'memTrend' => $this->trend($memSeries), + 'diskTrend' => $this->trend($diskSeries), + 'loadTrend' => $this->trend($loadSeries, ''), + 'events' => AuditEvent::with('server')->latest()->limit(6)->get(), + ]); } } diff --git a/resources/js/app.js b/resources/js/app.js index 26f9287..7eb2dfb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -14,13 +14,13 @@ window.Echo = new Echo({ enabledTransports: ['ws', 'wss'], }); -// Live CPU chart island: seeds from server-rendered data, then appends each -// MetricsTicked broadcast and redraws the sparkline (viewBox 0 0 300 80). document.addEventListener('alpine:init', () => { - window.Alpine.data('metricsChart', (seed = [], max = 40) => ({ - points: seed.slice(-max), + // Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then + // appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100). + window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40) => ({ + cpu: cpuSeed.slice(-max), + mem: memSeed.slice(-max), max, - last: seed.length ? seed[seed.length - 1] : 0, connected: false, init() { @@ -29,33 +29,38 @@ document.addEventListener('alpine:init', () => { this._onState = (s) => { this.connected = s.current === 'connected'; }; conn?.bind('state_change', this._onState); this._channel = window.Echo?.channel('metrics'); - this._channel?.listen('.tick', (e) => this.push(e.cpu)); + this._channel?.listen('.tick', (e) => { + this.append(this.cpu, e.cpu); + this.append(this.mem, e.mem); + }); }, - // Alpine calls this when the island is removed (e.g. wire:navigate) — avoid leaking subscriptions. destroy() { window.Echo?.leave('metrics'); window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState); }, - push(v) { + append(arr, v) { const n = Number(v); if (! Number.isFinite(n)) return; - this.last = n; - this.points.push(n); - if (this.points.length > this.max) this.points.shift(); + arr.push(n); + if (arr.length > this.max) arr.shift(); }, - coords() { - const n = this.points.length; - const w = 300, h = 80, pad = 6; - return this.points.map((v, i) => { - const x = n > 1 ? (i / (n - 1)) * w : 0; - const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad); - return `${x.toFixed(1)},${y.toFixed(1)}`; - }); + _coords(arr) { + const n = arr.length, w = 300, h = 100, pad = 6; + return arr + .map((v, i) => { + const x = n > 1 ? (i / (n - 1)) * w : 0; + const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); }, - get linePoints() { return this.coords().join(' '); }, - get areaPoints() { return `0,80 ${this.coords().join(' ')} 300,80`; }, + + get cpuLine() { return this._coords(this.cpu); }, + get cpuArea() { return `0,100 ${this._coords(this.cpu)} 300,100`; }, + get memLine() { return this._coords(this.mem); }, + get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; }, })); }); diff --git a/resources/views/components/metric.blade.php b/resources/views/components/metric.blade.php new file mode 100644 index 0000000..14522c6 --- /dev/null +++ b/resources/views/components/metric.blade.php @@ -0,0 +1,55 @@ +@props(['label', 'value', 'unit' => null, 'sub' => null, 'icon' => null, 'series' => [], 'tone' => 'accent', 'trend' => null]) +@php + use Illuminate\Support\Str; + + $stroke = [ + 'accent' => 'text-accent', 'cyan' => 'text-cyan', 'online' => 'text-online', + 'warning' => 'text-warning', 'offline' => 'text-offline', + ][$tone] ?? 'text-accent'; + + $gid = 'm-'.Str::slug($label); + + // sparkline path from the series + $n = count($series); $w = 100; $h = 40; $pad = 4; $pts = []; + $min = $n ? min($series) : 0; $max = $n ? max($series) : 1; $range = max(1, $max - $min); + foreach ($series as $i => $v) { + $x = $n > 1 ? round($i / ($n - 1) * $w, 2) : 0; + $y = round($h - $pad - (($v - $min) / $range) * ($h - 2 * $pad), 2); + $pts[] = "$x,$y"; + } + $line = implode(' ', $pts); + $area = $n ? "0,$h ".$line." $w,$h" : ''; + + $trendColor = ['up' => 'text-online', 'down' => 'text-offline', 'flat' => 'text-ink-3'][$trend['dir'] ?? 'flat'] ?? 'text-ink-3'; + $arrow = ['up' => '▲', 'down' => '▼', 'flat' => '▪'][$trend['dir'] ?? 'flat'] ?? '▪'; +@endphp +
merge(['class' => 'relative flex min-h-[122px] flex-col gap-2.5 overflow-hidden rounded-lg border border-line bg-surface pt-4 shadow-panel']) }}> +
+ + @if ($icon)@endif + {{ $label }} + + @if ($trend) + {{ $arrow }} {{ $trend['text'] }} + @endif +
+ +
+ {{ $value }} + @if ($unit){{ $unit }}@endif + @if ($sub){{ $sub }}@endif +
+ +
+ +
+
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 91faa27..bb25858 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,118 +1,129 @@ @php - $total = count($servers); - $online = collect($servers)->where('status', 'online')->count(); - $avgCpu = $total ? (int) round(collect($servers)->avg('cpu')) : 0; - $avgMem = $total ? (int) round(collect($servers)->avg('mem')) : 0; - $svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt']; - $web = collect($servers)->firstWhere('name', 'web-01') ?? ($servers[0] ?? ['cpu' => 0, 'mem' => 0]); - $disk = 71; // mock until the SSH layer reports real disk usage - $ringTone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online'); + // dual-series chart paths (CPU + MEM) — static seed; live wiring via Reverb is a follow-up + $chart = function (array $s) { + $w = 300; $h = 100; $pad = 6; $n = count($s); $pts = []; + foreach ($s as $i => $v) { + $x = $n > 1 ? round($i / ($n - 1) * $w, 2) : 0; + $y = round($h - $pad - ($v / 100) * ($h - 2 * $pad), 2); + $pts[] = "$x,$y"; + } + $line = implode(' ', $pts); + return ['line' => $line, 'area' => "0,$h ".$line." $w,$h"]; + }; + $cpuP = $chart($cpuSeries); + $memP = $chart($memSeries); @endphp
{{-- Header --}}
-

Flotte

+

{{ $active?->name ?? '—' }}

Übersicht

- {{ $online }} / {{ $total }} online + {{ $active?->ip ?? '—' }}
- {{-- KPI grid: 1 → 2 → 4 --}} + {{-- KPI tiles with sparklines (active server) --}}
- - - - + + + +
- {{-- Live chart (2/3) + resource rings (1/3) --}} -
- - - Reverb - + {{-- Big dual-series chart --}} + + + CPU + MEM + Live + -
-
+ {{-- Y axis --}} +
+ 100 + 75 + 50 + 25 + 0 +
+ +
+ -

- - - - - · CPU -

- - -
- - - +
+ -15 Min-10-5jetzt
- -
- - {{-- Server list + systemd services --}} -
- - - - -
- @foreach ($servers as $s) - - @endforeach -
-
- - -
- @foreach ($services as $svc) -
- -
-

{{ $svc['name'] }}

-

{{ $svc['desc'] }}

-
- {{ $svcLabel[$svc['status']] }} -
- @endforeach -
-
-
- - {{-- Audit --}} - -
- @foreach ($events as $e) -
- - - -
-

{{ $e['actor'] }} · {{ $e['action'] }}

-

{{ $e['target'] }}

-
- {{ $e['when'] }} -
- @endforeach
+ + {{-- systemd table + audit --}} +
+ +
+ + + + + + + + + + + @foreach ($services as $svc) + + + + + + + @endforeach + +
UnitStatusCPU
+

{{ $svc['name'] }}

+

{{ $svc['desc'] }}

+
{{ $svcLabel[$svc['status']] }}{{ $svc['status'] === 'offline' ? '—' : number_format($svc['cpu'], 1).'%' }}
+
+
+ + +
+ @forelse ($events as $e) +
+ +
+

{{ $e->actor }} · {{ $e->action }}

+

{{ $e->target }}

+
+ {{ $e->created_at->diffForHumans(null, true) }} +
+ @empty +

Keine Ereignisse.

+ @endforelse +
+
+