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 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 01:31:27 +02:00
parent 940c4c80fa
commit 6227505528
10 changed files with 396 additions and 1 deletions

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\MetricSample;
use App\Models\Server;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Persist a once-a-minute resource sample per server from the live (cache-backed) reading the
* 15s poller keeps fresh the long history the Server-Details graph plots. No extra SSH: a server
* with no fresh cached reading (not polled / offline) simply gets no sample (an honest gap). Prunes
* beyond the retention window. Scheduled everyMinute in routes/console.php.
*/
class SampleMetrics extends Command
{
protected $signature = 'clusev:sample-metrics {--retention=30 : Days of history to keep}';
protected $description = 'Persist a one-per-minute metric history sample per server (and prune old samples)';
public function handle(): int
{
$now = now();
$rows = [];
foreach (Server::query()->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;
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One persisted point of a server's resource history (cpu/mem/disk %, load), sampled once a minute
* by clusev:sample-metrics. The live 15s view stays cache-backed; this table is the LONG history
* the Server-Details graph plots over selectable ranges. Pruned to a retention window.
*/
class MetricSample extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'cpu' => 'integer',
'mem' => 'integer',
'disk' => 'integer',
'load' => 'float',
'sampled_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Services;
use App\Models\MetricSample;
use App\Models\Server;
/**
* Turns persisted per-minute metric samples into a bucketed cpu/mem/disk (% average) + load series
* for the Server-Details history chart. Each bucket is the AVERAGE of the samples that fall in it
* (gauges, not counters); a bucket with no samples is null so the chart shows a gap instead of
* fabricating a value. Mirrors the WireGuard traffic-series shape.
*/
class MetricHistory
{
/**
* @return array{window:int, points:array<int,array{t:int,cpu:?int,mem:?int,disk:?int,load:?float}>}
*/
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];
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('metric_samples', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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',

View File

@ -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',

View File

@ -162,6 +162,81 @@
@endforeach
</div>
{{-- 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
<x-panel :title="__('servers.history_title')" :subtitle="__('servers.history_subtitle')">
<x-slot:actions>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-accent"></span><span class="text-ink-2">CPU</span></span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-cyan"></span><span class="text-ink-2">MEM</span></span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-online"></span><span class="text-ink-2">DISK</span></span>
</x-slot:actions>
<div class="mb-3 flex gap-1.5">
@foreach ($histRanges as $key => $label)
<button type="button" wire:click="setRange('{{ $key }}')"
@class([
'min-h-8 rounded-md border px-3 py-1 font-mono text-[11px] transition-colors',
'border-accent/40 bg-accent/10 text-accent-text' => $historyRange === $key,
'border-line text-ink-3 hover:border-line-strong hover:text-ink' => $historyRange !== $key,
])>{{ $label }}</button>
@endforeach
</div>
@if ($hasHistory)
<div class="relative pl-7">
<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-1/4 -translate-y-1/2">75</span>
<span class="absolute right-0 top-1/2 -translate-y-1/2">50</span>
<span class="absolute right-0 top-3/4 -translate-y-1/2">25</span>
<span class="absolute right-0 top-full -translate-y-1/2">0</span>
</div>
<div class="h-48 w-full">
<svg viewBox="0 0 300 100" preserveAspectRatio="none" class="h-full w-full" aria-hidden="true">
<g class="text-line-soft">
@foreach ([0, 25, 50, 75, 100] as $g)
<line x1="0" x2="300" y1="{{ 100 * (1 - $g / 100) }}" y2="{{ 100 * (1 - $g / 100) }}" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
@endforeach
</g>
<g class="text-online"><path d="{{ $histPath('disk') }}" fill="none" stroke="currentColor" stroke-width="1.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" /></g>
<g class="text-cyan"><path d="{{ $histPath('mem') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" /></g>
<g class="text-accent"><path d="{{ $histPath('cpu') }}" fill="none" stroke="currentColor" stroke-width="1.75" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" /></g>
</svg>
</div>
<div class="mt-1.5 flex justify-between font-mono text-[9px] text-ink-4">
<span>-{{ $histRanges[$historyRange] }}</span><span>{{ __('dashboard.axis_now') }}</span>
</div>
</div>
@else
<p class="py-10 text-center font-mono text-[11px] text-ink-4">{{ __('servers.history_empty') }}</p>
@endif
</x-panel>
@if ($ready)
{{-- Spezifikationen + Sicherheit --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">

View File

@ -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();

View File

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Livewire\Servers\Show;
use App\Models\MetricSample;
use App\Models\Server;
use App\Models\User;
use App\Services\MetricHistory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
class MetricHistoryTest extends TestCase
{
use RefreshDatabase;
private function server(): Server
{
return Server::create(['name' => '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');
}
}