From b2c6de64da3af57f435eaa2901d40fbeb511ca9e Mon Sep 17 00:00:00 2001 From: boban Date: Thu, 25 Jun 2026 01:51:24 +0200 Subject: [PATCH] =?UTF-8?q?refactor(metrics):=20interactive=20history=20ch?= =?UTF-8?q?art=20=E2=80=94=20smooth,=20area,=20tooltip,=20instant=20ranges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Servers/Show.php | 33 +---- app/Services/MetricHistory.php | 43 +++--- resources/js/app.js | 82 ++++++++++++ .../views/livewire/servers/show.blade.php | 123 ++++++++++-------- routes/web.php | 10 ++ tests/Feature/MetricHistoryTest.php | 67 +++++----- 6 files changed, 214 insertions(+), 144 deletions(-) diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 9a4d659..ea43646 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -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()); } } diff --git a/app/Services/MetricHistory.php b/app/Services/MetricHistory.php index 9fb48f2..3be7368 100644 --- a/app/Services/MetricHistory.php +++ b/app/Services/MetricHistory.php @@ -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} + * @return array{window:int, from:int, now:int, bucket:int, + * points:array} */ - 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]; } } diff --git a/resources/js/app.js b/resources/js/app.js index a378144..df3b42b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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// + // 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. diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index fe3f856..c224484 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -162,53 +162,31 @@ @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; + {{-- Metric history graph — self-contained Alpine island (fetches /servers//history.json) --}} +
+ + + CPU + MEM + DISK + - 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; - } + {{-- range selector (client-side, instant) --}} +
+ +
- 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 - + {{-- empty / loading --}} +

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

+

{{ __('servers.history_subtitle') }} …

-
- @foreach ($histRanges as $key => $label) - - @endforeach -
- - @if ($hasHistory) -
+ {{-- chart --}} +
100 75 @@ -216,26 +194,59 @@ 25 0
-
-
+

+

% CPU

+

% MEM

+

% DISK

+
+ +
+ + + + + + + @foreach ([0, 25, 50, 75, 100] as $g) - + @endforeach - - - + + + + + + + + + + + + + {{-- crosshair + per-series dots (guarded bindings; no
-
- -{{ $histRanges[$historyRange] }}{{ __('dashboard.axis_now') }} + +
+ + {{ __('dashboard.axis_now') }}
- @else -

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

- @endif - + +
@if ($ready) {{-- Spezifikationen + Sicherheit --}} diff --git a/routes/web.php b/routes/web.php index fb5484e..36a9c02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/MetricHistoryTest.php b/tests/Feature/MetricHistoryTest.php index e1685aa..63a978b 100644 --- a/tests/Feature/MetricHistoryTest.php +++ b/tests/Feature/MetricHistoryTest.php @@ -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 } }