refactor(metrics): interactive history chart — smooth, area, tooltip, instant ranges

Reworks the Server-Details history graph from a static Livewire-rendered SVG into a
self-contained Alpine island fed by a JSON endpoint, addressing the feedback:

- Smooth Catmull-Rom curves instead of angular segments.
- Continuous line with an area gradient fill; the line breaks ONLY on a real outage
  (delta-t > 2.5x the bucket), not on every empty bucket — no more dropouts.
- Hover crosshair + tooltip (time + CPU/MEM/DISK at the point).
- Instant client-side range switching (1h/24h/7d/30d) via
  GET /servers/{server}/history.json — no Livewire round-trip.
- X-axis labels moved below the plot (no longer overlapping the 0 gridline).

MetricHistory::series() now returns only non-empty buckets plus from/now/bucket. The
chart wrapper is wire:ignore so the parent's 10s poll never clobbers the Alpine state;
the island self-refreshes every 60s. The old Livewire range logic is removed.

Codex review: clean (auth-gated route, range clamped, numeric-only JSON, guarded hover
bindings, R3/R4-compliant). Browser-verified: smooth curves, area fill, working tooltip
+ crosshair, instant range switch, zero console errors. 446 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 01:51:24 +02:00
parent 6227505528
commit b2c6de64da
6 changed files with 214 additions and 144 deletions

View File

@ -8,7 +8,6 @@ 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;
@ -57,12 +56,6 @@ 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
@ -539,32 +532,8 @@ 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', ['history' => $this->historySeries()])->title($this->title());
return view('livewire.servers.show')->title($this->title());
}
}

View File

@ -6,25 +6,27 @@ 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.
* Turns persisted per-minute metric samples into a downsampled cpu/mem/disk (% average) + load
* series for the Server-Details history chart. Bucketing is done in PHP (DB-agnostic, like the
* WireGuard traffic series) each returned point is the AVERAGE of the samples in a bucket, and
* ONLY non-empty buckets are returned (the chart connects consecutive points and the client breaks
* the line only on a real time gap, so normal sampling renders a continuous curve, not dropouts).
*/
class MetricHistory
{
/**
* @return array{window:int, points:array<int,array{t:int,cpu:?int,mem:?int,disk:?int,load:?float}>}
* @return array{window:int, from:int, now:int, bucket: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
public function series(Server $server, int $windowSeconds): array
{
$windowSeconds = max(60, $windowSeconds);
$buckets = max(1, $buckets);
$windowSeconds = max(300, $windowSeconds);
// Bucket width is at least the 1/min sample interval, so a bucket holds ~one sample and the
// line stays continuous instead of zig-zagging between half-empty buckets.
$width = max(60, (int) floor($windowSeconds / 120));
$buckets = (int) ceil($windowSeconds / $width);
$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)
@ -32,33 +34,36 @@ class MetricHistory
->orderBy('sampled_at')
->get(['cpu', 'mem', 'disk', 'load', 'sampled_at']);
$sum = [];
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
$idx = $buckets - 1;
}
$sum[$idx] ??= ['cpu' => 0, 'mem' => 0, 'disk' => 0, 'load' => 0.0, 'n' => 0];
$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']++;
}
ksort($sum);
$points = [];
for ($i = 0; $i < $buckets; $i++) {
$n = $sum[$i]['n'];
foreach ($sum as $i => $b) {
$n = $b['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,
'cpu' => (int) round($b['cpu'] / $n),
'mem' => (int) round($b['mem'] / $n),
'disk' => (int) round($b['disk'] / $n),
'load' => round($b['load'] / $n, 2),
];
}
return ['window' => $windowSeconds, 'points' => $points];
return ['window' => $windowSeconds, 'from' => $from, 'now' => $now, 'bucket' => $width, 'points' => $points];
}
}

View File

@ -248,6 +248,88 @@ document.addEventListener('alpine:init', () => {
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
}));
// Persistent history chart (Server-Details). Self-contained island: fetches /servers/<uuid>/
// history.json per range, draws smooth (Catmull-Rom) lines + area fills, a hover crosshair +
// tooltip, and switches range client-side (no server round-trip). The line is continuous and
// breaks only on a REAL gap (Δt > 2.5× the bucket width = the server was actually down).
window.Alpine.data('metricChart', (uuid) => ({
uuid,
range: '24h',
loading: true,
data: { points: [], from: 0, now: 0, bucket: 60 },
hover: null,
W: 1000,
H: 260,
init() {
this.reload();
this._timer = setInterval(() => this.reload(true), 60000); // keep it fresh
},
destroy() { clearInterval(this._timer); },
setRange(r) { if (r !== this.range) { this.range = r; this.reload(); } },
reload(silent = false) {
if (! silent) this.loading = true;
fetch(`/servers/${this.uuid}/history.json?range=${this.range}`, { headers: { Accept: 'application/json' }, cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && Array.isArray(d.points)) this.data = d; this.loading = false; })
.catch(() => { this.loading = false; });
},
get hasData() { return (this.data.points || []).length > 0; },
_x(t) { const span = Math.max(1, this.data.now - this.data.from); return ((t - this.data.from) / span) * this.W; },
_y(v) { return this.H - (Math.max(0, Math.min(100, v)) / 100) * this.H; },
// Split points into continuous runs; a Δt larger than 2.5× the bucket = a real outage → break.
_runs(key) {
const pts = this.data.points || [], bucket = this.data.bucket || 60, runs = [];
let cur = [];
for (let i = 0; i < pts.length; i++) {
if (cur.length && (pts[i].t - pts[i - 1].t) > bucket * 2.5) { runs.push(cur); cur = []; }
cur.push({ x: this._x(pts[i].t), y: this._y(pts[i][key]) });
}
if (cur.length) runs.push(cur);
return runs;
},
// Catmull-Rom → cubic-bezier smoothing for an organic, non-angular curve.
_smooth(p) {
if (p.length < 2) return p.length ? `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}` : '';
let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`;
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
},
line(key) { return this._runs(key).map((r) => this._smooth(r)).join(' '); },
area(key) {
return this._runs(key).map((r) => (r.length
? `${this._smooth(r)} L${r[r.length - 1].x.toFixed(1)},${this.H} L${r[0].x.toFixed(1)},${this.H} Z`
: '')).join(' ');
},
onMove(ev) {
const pts = this.data.points || [];
if (! pts.length) return;
const rect = ev.currentTarget.getBoundingClientRect();
const tx = this.data.from + ((ev.clientX - rect.left) / Math.max(1, rect.width)) * (this.data.now - this.data.from);
let bi = 0, bd = Infinity;
for (let i = 0; i < pts.length; i++) { const dd = Math.abs(pts[i].t - tx); if (dd < bd) { bd = dd; bi = i; } }
this.hover = { i: bi, p: pts[bi], left: (this._x(pts[bi].t) / this.W) * 100 };
},
onLeave() { this.hover = null; },
fmtTime(t) {
return new Date(t * 1000).toLocaleString([], { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
},
}));
// ── Command palette + keyboard shortcuts ────────────────────────────
// Nav + action item labels are passed in from the Blade markup (already
// translated via __()), so the palette stays in sync with the active locale.

View File

@ -162,33 +162,8 @@
@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
{{-- Metric history graph self-contained Alpine island (fetches /servers/<uuid>/history.json) --}}
<div wire:ignore x-data="metricChart('{{ $server->uuid }}')">
<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>
@ -196,19 +171,22 @@
<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>
{{-- range selector (client-side, instant) --}}
<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
<template x-for="r in ['1h','24h','7d','30d']" :key="r">
<button type="button" x-on:click="setRange(r)"
class="min-h-8 rounded-md border px-3 py-1 font-mono text-[11px] transition-colors"
:class="range === r ? 'border-accent/40 bg-accent/10 text-accent-text' : 'border-line text-ink-3 hover:border-line-strong hover:text-ink'"
x-text="({'1h':'1 h','24h':'24 h','7d':'7 d','30d':'30 d'})[r]"></button>
</template>
</div>
@if ($hasHistory)
<div class="relative pl-7">
{{-- empty / loading --}}
<p x-show="!hasData && !loading" class="py-10 text-center font-mono text-[11px] text-ink-4">{{ __('servers.history_empty') }}</p>
<p x-show="!hasData && loading" class="py-10 text-center font-mono text-[11px] text-ink-4">{{ __('servers.history_subtitle') }} </p>
{{-- chart --}}
<div x-show="hasData" x-cloak 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>
@ -216,26 +194,59 @@
<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">
{{-- hover tooltip (positioned at the crosshair) --}}
<div x-show="hover" x-cloak class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-md border border-line bg-raised/95 px-2.5 py-1.5 shadow-pop backdrop-blur"
:style="hover ? ('left:' + Math.max(8, Math.min(92, hover.left)) + '%') : ''">
<p class="font-mono text-[10px] text-ink-4" x-text="hover ? fmtTime(hover.p.t) : ''"></p>
<p class="font-mono text-[11px] text-accent-text"><span x-text="hover ? hover.p.cpu : ''"></span>% CPU</p>
<p class="font-mono text-[11px] text-cyan-bright"><span x-text="hover ? hover.p.mem : ''"></span>% MEM</p>
<p class="font-mono text-[11px] text-online"><span x-text="hover ? hover.p.disk : ''"></span>% DISK</p>
</div>
<div class="h-56 w-full">
<svg viewBox="0 0 1000 260" preserveAspectRatio="none" class="h-full w-full"
x-on:mousemove="onMove($event)" x-on:mouseleave="onLeave()">
<defs>
<linearGradient id="mh-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.22" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<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" />
<line x1="0" x2="1000" y1="{{ 260 * (1 - $g / 100) }}" y2="{{ 260 * (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>
<g class="text-online">
<path :d="area('disk')" fill="url(#mh-area)" stroke="none" />
<path :d="line('disk')" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
</g>
<g class="text-cyan">
<path :d="area('mem')" fill="url(#mh-area)" stroke="none" />
<path :d="line('mem')" fill="none" stroke="currentColor" stroke-width="2.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
</g>
<g class="text-accent">
<path :d="area('cpu')" fill="url(#mh-area)" stroke="none" />
<path :d="line('cpu')" fill="none" stroke="currentColor" stroke-width="2.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
</g>
{{-- crosshair + per-series dots (guarded bindings; no <template> inside SVG) --}}
<g x-show="hover" x-cloak>
<line class="text-line-strong" :x1="(hover ? hover.left : 0) * 10" :x2="(hover ? hover.left : 0) * 10" y1="0" y2="260" stroke="currentColor" stroke-width="1" stroke-dasharray="4 4" vector-effect="non-scaling-stroke" />
<circle class="text-online" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.disk) : 0" r="4" fill="currentColor" />
<circle class="text-cyan" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.mem) : 0" r="4" fill="currentColor" />
<circle class="text-accent" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.cpu) : 0" r="4" fill="currentColor" />
</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 class="mt-2 flex justify-between font-mono text-[9px] text-ink-4">
<span x-text="'' + ({'1h':'1 h','24h':'24 h','7d':'7 d','30d':'30 d'})[range]"></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>
</div>
@if ($ready)
{{-- Spezifikationen + Sicherheit --}}

View File

@ -14,7 +14,9 @@ use App\Livewire\Settings;
use App\Livewire\System;
use App\Livewire\Versions;
use App\Livewire\Wireguard;
use App\Models\Server;
use App\Services\DeploymentService;
use App\Services\MetricHistory;
use App\Services\WgStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth as AuthFacade;
@ -117,6 +119,14 @@ Route::middleware('auth')->group(function () {
Route::get('/servers', Servers\Index::class)->name('servers.index');
Route::get('/servers/{server}', Servers\Show::class)->name('servers.show');
// History-graph data for the Server-Details chart (the Alpine island fetches this per range).
Route::get('/servers/{server}/history.json', function (Server $server, Request $request, MetricHistory $history) {
$ranges = ['1h' => 3600, '24h' => 86400, '7d' => 604800, '30d' => 2592000];
$window = $ranges[$request->query('range', '24h')] ?? 86400;
return response()->json($history->series($server, $window));
})->name('servers.history');
Route::get('/services', Services\Index::class)->name('services.index');
Route::get('/files', Files\Index::class)->name('files.index');
Route::get('/audit', Audit\Index::class)->name('audit.index');

View File

@ -2,14 +2,12 @@
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
@ -32,13 +30,16 @@ class MetricHistoryTest extends TestCase
$this->assertSame(1, MetricSample::count());
}
public function test_sample_metrics_skips_a_server_with_no_cached_reading(): void
public function test_sample_metrics_clamps_percentages_and_skips_unpolled_servers(): void
{
$this->server(); // no cached reading → no sample (honest gap)
$polled = $this->server();
$this->server(); // a second server with no cached reading → no sample
Cache::put("metrics:latest:{$polled->id}", ['cpu' => 250, 'mem' => 60, 'disk' => 10, 'load' => 0.2], now()->addMinutes(5));
$this->artisan('clusev:sample-metrics')->assertSuccessful();
$this->assertSame(0, MetricSample::count());
$this->assertSame(1, MetricSample::count());
$this->assertSame(100, MetricSample::first()->cpu); // 250 clamped to 100
}
public function test_sample_metrics_prunes_beyond_the_retention_window(): void
@ -53,53 +54,45 @@ class MetricHistoryTest extends TestCase
$this->assertDatabaseHas('metric_samples', ['cpu' => 2]); // 5d old → kept
}
public function test_series_buckets_and_averages_samples_with_null_gaps(): void
public function test_series_averages_samples_into_non_empty_buckets(): 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);
$series = app(MetricHistory::class)->series($s, 300);
$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);
foreach (['from', 'now', 'bucket'] as $k) {
$this->assertArrayHasKey($k, $series);
}
$this->assertNotEmpty($series['points']);
// both samples land in the same bucket → averaged (20 + 40) / 2 = 30
$this->assertSame(30, $series['points'][0]['cpu']);
// only non-empty buckets are returned — no null gaps (the client connects them continuously)
foreach ($series['points'] as $p) {
$this->assertIsInt($p['cpu']);
}
}
public function test_show_passes_a_history_series_to_the_view(): void
public function test_history_route_returns_the_series_json(): void
{
$this->actingAs(User::factory()->create());
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$s = $this->server();
MetricSample::create(['server_id' => $s->id, 'cpu' => 30, 'mem' => 40, 'disk' => 10, 'load' => 0.3, 'sampled_at' => now()->subMinutes(5)]);
MetricSample::create(['server_id' => $s->id, 'cpu' => 33, 'mem' => 44, 'disk' => 11, 'load' => 0.4, 'sampled_at' => now()->subMinutes(5)]);
Livewire::test(Show::class, ['server' => $s])
->assertViewHas('history', fn ($h) => is_array($h) && isset($h['points']) && $h['window'] === 86400);
$this->getJson(route('servers.history', ['server' => $s, 'range' => '24h']))
->assertOk()
->assertJsonPath('window', 86400)
->assertJsonStructure(['window', 'from', 'now', 'bucket', 'points' => [['t', 'cpu', 'mem', 'disk', 'load']]]);
}
public function test_set_range_clamps_to_allowed_keys(): void
public function test_history_route_clamps_an_unknown_range(): void
{
$this->actingAs(User::factory()->create());
$s = $this->server();
$this->actingAs(User::factory()->create(['must_change_password' => false]));
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');
$this->getJson(route('servers.history', ['server' => $this->server(), 'range' => 'evil']))
->assertOk()
->assertJsonPath('window', 86400); // unknown → default 24h
}
}