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
parent
940c4c80fa
commit
6227505528
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue