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