fix(metrics): downsample history, re-rank containers without refetch, add live refresh

This commit is contained in:
Aditya Tripathi
2026-09-16 09:18:05 +00:00
parent 2aa656bece
commit 6b0c97211a
5 changed files with 82 additions and 2 deletions
+13 -1
View File
@@ -35,12 +35,17 @@ class Metrics extends Component
#[Url(as: 'cmetric')]
public string $containerMetric = 'cpu';
public bool $live = false;
/** @var array<string, mixed> */
public array $kpis = [];
/** @var array<int, array<string, mixed>> */
public array $serverRows = [];
/** @var array<int, array<string, mixed>> All fetched containers, kept so the metric toggle re-ranks without re-fetching. */
public array $containersRaw = [];
/** @var array<int, array<string, mixed>> */
public array $topContainers = [];
@@ -78,7 +83,8 @@ class Metrics extends Component
public function updatedContainerMetric(): void
{
$this->loadData();
// Re-rank the already-fetched containers; no need to re-hit Sentinel.
$this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric);
}
public function setRange(string $range): void
@@ -87,6 +93,11 @@ class Metrics extends Component
$this->loadData();
}
public function isLivePollable(): bool
{
return $this->live && $this->range === '24h';
}
/**
* @return Collection<int, Server>
*/
@@ -147,6 +158,7 @@ class Metrics extends Component
$this->serverRows = $rows;
$this->kpis = FleetMetricsAggregator::fleetKpis($rows);
$this->containersRaw = $containers;
$this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric);
$this->chartData = $this->buildChartPayload($series);
+12 -1
View File
@@ -9,6 +9,9 @@ class SentinelMetricsClient
{
private string $base = 'http://localhost:8888/api';
/** Cap on points per trend series after downsampling raw Sentinel samples. */
private const MAX_SERIES_POINTS = 300;
public function __construct(protected Server $server) {}
public static function rangeFrom(string $range): string
@@ -126,10 +129,18 @@ class SentinelMetricsClient
return [];
}
return array_values(array_map(
$series = array_values(array_map(
fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)],
$rows
));
// Sentinel returns raw samples (thousands of points over 24h). Cap the series so
// the chart payload stays small enough to embed and morph cheaply.
if (count($series) > self::MAX_SERIES_POINTS) {
$series = downsampleLTTB($series, self::MAX_SERIES_POINTS);
}
return $series;
}
/**
@@ -30,6 +30,28 @@ $serverListboxOptions = array_merge(
</div>
<div class="flex items-center gap-2 sm:ml-auto">
@if ($range === '24h')
<div x-data="{ live: $wire.entangle('live').live }"
x-init="
const stored = localStorage.getItem('metrics-live');
if (stored === '0') { live = false; }
if (stored === '1') { live = true; }
$watch('live', value => localStorage.setItem('metrics-live', value ? '1' : '0'));
">
<button type="button" @click="live = !live" :aria-pressed="live ? 'true' : 'false'"
title="Toggle realtime refresh (updates every 60s)"
class="inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium ring-1 transition-colors"
:class="live
? 'bg-white text-black shadow-sm ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 ring-neutral-200 hover:text-black dark:text-fg-faint dark:ring-white/[0.08] dark:hover:text-fg'">
<span class="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
<span x-show="live" class="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75 motion-safe:animate-ping"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" :class="live ? 'bg-emerald-500' : 'bg-neutral-400 dark:bg-white/40'"></span>
</span>
<span>Live Refresh</span>
</button>
</div>
@endif
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
@foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label)
<button type="button" wire:click="setRange('{{ $value }}')"
@@ -44,6 +66,10 @@ $serverListboxOptions = array_merge(
@endif
</div>
@if ($this->isLivePollable())
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
@if ($servers->isEmpty())
<x-empty size="sm" title="No server metrics yet"
description="Enable metrics on a server to see fleet resource usage here."
@@ -134,6 +134,25 @@ it('shows the KPI tiles, a stale badge for offline servers, and the container to
->assertSee('Offline');
});
it('re-ranks containers on metric switch without re-fetching', function () {
$s = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
StubMetricsClient::$containers = [$s->uuid => [
['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 0.0],
['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'memPercent' => 1.0, 'diskBytes' => 9, 'net' => 0.0],
]];
$component = Livewire::test(TestableMetrics::class);
expect(array_column($component->get('topContainers'), 'id')[0])->toBe('b'); // cpu
// Empty the stub so a re-fetch would change nothing/ break; the re-rank must use cached rows.
StubMetricsClient::$containers = [];
$component->set('containerMetric', 'memory');
expect(array_column($component->get('topContainers'), 'id')[0])->toBe('a'); // memory, still ranked
});
it('cannot see another team’s servers', function () {
$otherTeam = Team::factory()->create();
// Reuse the existing key id: the factory's private key is hardcoded, so a second
@@ -104,3 +104,15 @@ it('maps cpu history to [time, value] pairs', function () {
expect($client->history('cpu', 'from'))->toBe([[1000, 10.0], [2000, 20.0]]);
});
it('downsamples long history series so the chart payload stays small', function () {
$rows = [];
for ($i = 0; $i < 5000; $i++) {
$rows[] = ['time' => $i, 'percent' => (string) ($i % 100)];
}
$client = fakeMetricsClient();
$client->bodies = ['/cpu/history' => json_encode($rows)];
expect(count($client->history('cpu', 'from')))->toBe(300);
});