From 6227505528af627c4eb929bb9721ff723073f7d2 Mon Sep 17 00:00:00 2001 From: boban Date: Thu, 25 Jun 2026 01:31:27 +0200 Subject: [PATCH] feat(metrics): persistent resource-history graph on the Server-Details page A CPU/RAM/Disk history graph over selectable ranges (1h/24h/7d/30d) on the server detail page, backed by a persisted time series (the live 15s view stays cache-only). - metric_samples table (server_id cascade, cpu/mem/disk %, load, sampled_at indexed). - clusev:sample-metrics (scheduled every minute, mirrors clusev:wg-sample): persists one row per server from the cached live reading, prunes beyond --retention (30d). - MetricHistory::series() buckets + averages samples for a window; empty buckets stay null so the chart shows gaps, not fabricated values. - Servers/Show: range selector + a gap-aware SVG line chart (theme-token strokes, R3-compliant, mirrors the dashboard chart). historyRange is clamped via setRange() and the updatedHistoryRange() hook (it's a public, client-settable property). Codex review: fixed a right-edge bucket off-by-one, clamped percentages on write, and guarded the public range property against direct client values. Browser-verified: the chart renders with data, range buttons, legend and gaps, zero console errors. 447 tests green, pint clean. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/SampleMetrics.php | 52 +++++++++ app/Livewire/Servers/Show.php | 33 +++++- app/Models/MetricSample.php | 31 ++++++ app/Services/MetricHistory.php | 64 +++++++++++ ..._25_000001_create_metric_samples_table.php | 27 +++++ lang/de/servers.php | 3 + lang/en/servers.php | 3 + .../views/livewire/servers/show.blade.php | 75 +++++++++++++ routes/console.php | 4 + tests/Feature/MetricHistoryTest.php | 105 ++++++++++++++++++ 10 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/SampleMetrics.php create mode 100644 app/Models/MetricSample.php create mode 100644 app/Services/MetricHistory.php create mode 100644 database/migrations/2026_06_25_000001_create_metric_samples_table.php create mode 100644 tests/Feature/MetricHistoryTest.php diff --git a/app/Console/Commands/SampleMetrics.php b/app/Console/Commands/SampleMetrics.php new file mode 100644 index 0000000..948fa13 --- /dev/null +++ b/app/Console/Commands/SampleMetrics.php @@ -0,0 +1,52 @@ +get(['id']) as $server) { + $m = Cache::get("metrics:latest:{$server->id}"); + if (! is_array($m)) { + continue; + } + $rows[] = [ + 'server_id' => $server->id, + // Percentages — clamp to 0..100 so a stray reading can never plot outside the chart. + 'cpu' => min(100, max(0, (int) ($m['cpu'] ?? 0))), + 'mem' => min(100, max(0, (int) ($m['mem'] ?? 0))), + 'disk' => min(100, max(0, (int) ($m['disk'] ?? 0))), + 'load' => round((float) ($m['load'] ?? 0), 2), + 'sampled_at' => $now, + ]; + } + + if ($rows !== []) { + MetricSample::insert($rows); + } + + $retention = max(1, (int) $this->option('retention')); + MetricSample::where('sampled_at', '<', now()->subDays($retention))->delete(); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index ea43646..9a4d659 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -8,6 +8,7 @@ use App\Services\Fail2banService; use App\Services\FirewallService; use App\Services\FleetService; use App\Services\HardeningService; +use App\Services\MetricHistory; use App\Support\Confirm\ConfirmToken; use App\Support\Confirm\InvalidConfirmToken; use App\Support\Os\OsDetector; @@ -56,6 +57,12 @@ class Show extends Component public array $sshKeys = []; + /** Selected time window for the metric-history graph (one of HISTORY_RANGES). */ + public string $historyRange = '24h'; + + /** History-graph range key → window in seconds. */ + private const HISTORY_RANGES = ['1h' => 3600, '24h' => 86400, '7d' => 604800, '30d' => 2592000]; + /** * Pull a live snapshot over SSH and persist identity/metrics onto the row. * On any failure (no credential, unreachable) the page renders an offline @@ -532,8 +539,32 @@ class Show extends Component ]); } + /** Switch the history-graph time window (clamped to the allowed range keys). */ + public function setRange(string $key): void + { + if (isset(self::HISTORY_RANGES[$key])) { + $this->historyRange = $key; + } + } + + /** historyRange is a public property the client can set directly — clamp any unknown value. */ + public function updatedHistoryRange(string $value): void + { + if (! isset(self::HISTORY_RANGES[$value])) { + $this->historyRange = '24h'; + } + } + + /** Bucketed cpu/mem/disk history for the selected range, for the detail-page chart. */ + private function historySeries(): array + { + $window = self::HISTORY_RANGES[$this->historyRange] ?? self::HISTORY_RANGES['24h']; + + return app(MetricHistory::class)->series($this->server, $window, 80); + } + public function render() { - return view('livewire.servers.show')->title($this->title()); + return view('livewire.servers.show', ['history' => $this->historySeries()])->title($this->title()); } } diff --git a/app/Models/MetricSample.php b/app/Models/MetricSample.php new file mode 100644 index 0000000..4161c4f --- /dev/null +++ b/app/Models/MetricSample.php @@ -0,0 +1,31 @@ + 'integer', + 'mem' => 'integer', + 'disk' => 'integer', + 'load' => 'float', + 'sampled_at' => 'datetime', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } +} diff --git a/app/Services/MetricHistory.php b/app/Services/MetricHistory.php new file mode 100644 index 0000000..9fb48f2 --- /dev/null +++ b/app/Services/MetricHistory.php @@ -0,0 +1,64 @@ +} + */ + public function series(Server $server, int $windowSeconds, int $buckets = 80): array + { + $windowSeconds = max(60, $windowSeconds); + $buckets = max(1, $buckets); + $now = time(); + $from = $now - $windowSeconds; + $width = $windowSeconds / $buckets; + + $sum = array_fill(0, $buckets, ['cpu' => 0, 'mem' => 0, 'disk' => 0, 'load' => 0.0, 'n' => 0]); + + $rows = MetricSample::query() + ->where('server_id', $server->id) + ->where('sampled_at', '>=', now()->subSeconds($windowSeconds)) + ->orderBy('sampled_at') + ->get(['cpu', 'mem', 'disk', 'load', 'sampled_at']); + + foreach ($rows as $r) { + $idx = (int) floor(($r->sampled_at->getTimestamp() - $from) / $width); + if ($idx < 0) { + continue; + } + if ($idx >= $buckets) { + $idx = $buckets - 1; // a sample exactly on the right edge belongs to the last bucket + } + $sum[$idx]['cpu'] += (int) $r->cpu; + $sum[$idx]['mem'] += (int) $r->mem; + $sum[$idx]['disk'] += (int) $r->disk; + $sum[$idx]['load'] += (float) $r->load; + $sum[$idx]['n']++; + } + + $points = []; + for ($i = 0; $i < $buckets; $i++) { + $n = $sum[$i]['n']; + $points[] = [ + 't' => (int) round($from + ($i + 0.5) * $width), + 'cpu' => $n ? (int) round($sum[$i]['cpu'] / $n) : null, + 'mem' => $n ? (int) round($sum[$i]['mem'] / $n) : null, + 'disk' => $n ? (int) round($sum[$i]['disk'] / $n) : null, + 'load' => $n ? round($sum[$i]['load'] / $n, 2) : null, + ]; + } + + return ['window' => $windowSeconds, 'points' => $points]; + } +} diff --git a/database/migrations/2026_06_25_000001_create_metric_samples_table.php b/database/migrations/2026_06_25_000001_create_metric_samples_table.php new file mode 100644 index 0000000..7ae85ab --- /dev/null +++ b/database/migrations/2026_06_25_000001_create_metric_samples_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('cpu')->default(0); + $table->unsignedTinyInteger('mem')->default(0); + $table->unsignedTinyInteger('disk')->default(0); + $table->decimal('load', 6, 2)->default(0); + $table->timestamp('sampled_at')->index(); + $table->index(['server_id', 'sampled_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('metric_samples'); + } +}; diff --git a/lang/de/servers.php b/lang/de/servers.php index b155d7d..124e800 100644 --- a/lang/de/servers.php +++ b/lang/de/servers.php @@ -59,6 +59,9 @@ return [ // ── Show: specifications ────────────────────────────────────────────── 'specs_title' => 'Spezifikationen', 'specs_subtitle' => 'Hardware-Profil', + 'history_title' => 'Verlauf', + 'history_subtitle' => 'CPU · RAM · Disk über Zeit', + 'history_empty' => 'Noch keine Verlaufsdaten — wird ab jetzt jede Minute gesammelt.', 'spec_arch' => 'Architektur', 'spec_kernel' => 'Kernel', 'spec_virt' => 'Virtualisierung', diff --git a/lang/en/servers.php b/lang/en/servers.php index ac96528..1f732b8 100644 --- a/lang/en/servers.php +++ b/lang/en/servers.php @@ -59,6 +59,9 @@ return [ // ── Show: specifications ────────────────────────────────────────────── 'specs_title' => 'Specifications', 'specs_subtitle' => 'Hardware profile', + 'history_title' => 'History', + 'history_subtitle' => 'CPU · RAM · disk over time', + 'history_empty' => 'No history yet — collected every minute from now on.', 'spec_arch' => 'Architecture', 'spec_kernel' => 'Kernel', 'spec_virt' => 'Virtualization', diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 080dca8..fe3f856 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -162,6 +162,81 @@ @endforeach + {{-- Metric history graph (persisted samples, selectable range) --}} + @php + $pts = $history['points'] ?? []; + $np = count($pts); + // SVG path with gaps: start a new sub-path (M) after any null bucket so empty windows + // show a break instead of a fabricated straight line. + $histPath = function (string $key) use ($pts, $np) { + $d = ''; + $pen = false; + foreach ($pts as $i => $p) { + $v = $p[$key] ?? null; + if ($v === null) { + $pen = false; + + continue; + } + $x = $np > 1 ? round($i / ($np - 1) * 300, 2) : 0; + $y = round(100 - ($v / 100) * 100, 2); + $d .= ($pen ? ' L' : ' M').$x.','.$y; + $pen = true; + } + + return trim($d); + }; + $hasHistory = collect($pts)->contains(fn ($p) => ($p['cpu'] ?? null) !== null); + $histRanges = ['1h' => '1 h', '24h' => '24 h', '7d' => '7 d', '30d' => '30 d']; + @endphp + + + CPU + MEM + DISK + + +
+ @foreach ($histRanges as $key => $label) + + @endforeach +
+ + @if ($hasHistory) +
+
+ 100 + 75 + 50 + 25 + 0 +
+
+ +
+
+ -{{ $histRanges[$historyRange] }}{{ __('dashboard.axis_now') }} +
+
+ @else +

{{ __('servers.history_empty') }}

+ @endif +
+ @if ($ready) {{-- Spezifikationen + Sicherheit --}}
diff --git a/routes/console.php b/routes/console.php index 931fd2a..db580df 100644 --- a/routes/console.php +++ b/routes/console.php @@ -16,5 +16,9 @@ Schedule::command('clusev:prune-audit')->daily(); // Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale). Schedule::command('clusev:wg-sample')->everyMinute(); +// Persist a one-per-minute resource history sample per server (and prune old samples). Reads the +// live cached reading the 15s poller keeps fresh — the long history the Server-Details graph plots. +Schedule::command('clusev:sample-metrics')->everyMinute(); + // Refresh the cached latest-release tag so the sidebar update badge stays warm (cache TTL is 60 min). Schedule::command('clusev:check-update')->everyThirtyMinutes(); diff --git a/tests/Feature/MetricHistoryTest.php b/tests/Feature/MetricHistoryTest.php new file mode 100644 index 0000000..e1685aa --- /dev/null +++ b/tests/Feature/MetricHistoryTest.php @@ -0,0 +1,105 @@ + 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + } + + public function test_sample_metrics_persists_a_row_from_the_cached_reading(): void + { + $s = $this->server(); + Cache::put("metrics:latest:{$s->id}", ['cpu' => 40, 'mem' => 55, 'disk' => 12, 'load' => 0.7], now()->addMinutes(5)); + + $this->artisan('clusev:sample-metrics')->assertSuccessful(); + + $this->assertDatabaseHas('metric_samples', ['server_id' => $s->id, 'cpu' => 40, 'mem' => 55, 'disk' => 12]); + $this->assertSame(1, MetricSample::count()); + } + + public function test_sample_metrics_skips_a_server_with_no_cached_reading(): void + { + $this->server(); // no cached reading → no sample (honest gap) + + $this->artisan('clusev:sample-metrics')->assertSuccessful(); + + $this->assertSame(0, MetricSample::count()); + } + + public function test_sample_metrics_prunes_beyond_the_retention_window(): void + { + $s = $this->server(); + MetricSample::create(['server_id' => $s->id, 'cpu' => 1, 'mem' => 1, 'disk' => 1, 'load' => 0, 'sampled_at' => now()->subDays(40)]); + MetricSample::create(['server_id' => $s->id, 'cpu' => 2, 'mem' => 2, 'disk' => 2, 'load' => 0, 'sampled_at' => now()->subDays(5)]); + + $this->artisan('clusev:sample-metrics', ['--retention' => 30])->assertSuccessful(); + + $this->assertDatabaseMissing('metric_samples', ['cpu' => 1]); // 40d old → pruned + $this->assertDatabaseHas('metric_samples', ['cpu' => 2]); // 5d old → kept + } + + public function test_series_buckets_and_averages_samples_with_null_gaps(): void + { + $s = $this->server(); + MetricSample::create(['server_id' => $s->id, 'cpu' => 20, 'mem' => 40, 'disk' => 10, 'load' => 0.5, 'sampled_at' => now()->subSeconds(90)]); + MetricSample::create(['server_id' => $s->id, 'cpu' => 40, 'mem' => 60, 'disk' => 10, 'load' => 1.5, 'sampled_at' => now()->subSeconds(80)]); + + $series = app(MetricHistory::class)->series($s, 300, 10); + + $this->assertSame(300, $series['window']); + $this->assertCount(10, $series['points']); + + $cpu = array_column($series['points'], 'cpu'); + // the bucket holding both samples averages them (20 + 40) / 2 = 30 + $this->assertContains(30, $cpu); + // empty buckets stay null — gaps, not fabricated values + $this->assertContains(null, $cpu); + } + + public function test_show_passes_a_history_series_to_the_view(): void + { + $this->actingAs(User::factory()->create()); + $s = $this->server(); + MetricSample::create(['server_id' => $s->id, 'cpu' => 30, 'mem' => 40, 'disk' => 10, 'load' => 0.3, 'sampled_at' => now()->subMinutes(5)]); + + Livewire::test(Show::class, ['server' => $s]) + ->assertViewHas('history', fn ($h) => is_array($h) && isset($h['points']) && $h['window'] === 86400); + } + + public function test_set_range_clamps_to_allowed_keys(): void + { + $this->actingAs(User::factory()->create()); + $s = $this->server(); + + Livewire::test(Show::class, ['server' => $s]) + ->assertSet('historyRange', '24h') + ->call('setRange', '7d')->assertSet('historyRange', '7d') + ->call('setRange', 'bogus')->assertSet('historyRange', '7d'); // invalid → unchanged + } + + public function test_a_direct_invalid_range_property_is_clamped(): void + { + $this->actingAs(User::factory()->create()); + $s = $this->server(); + + // historyRange is a public prop — a client can set it directly, bypassing setRange(). + Livewire::test(Show::class, ['server' => $s]) + ->set('historyRange', 'evil') + ->assertSet('historyRange', '24h'); + } +}