fix(metrics): keep reachable servers online when Sentinel has no summary

Closes #12289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-23 19:05:48 +02:00
co-authored by Claude Opus 5.5
parent ade4bae265
commit 9a8b4eb53c
5 changed files with 65 additions and 24 deletions
+8 -5
View File
@@ -251,15 +251,15 @@ class Metrics extends Component
}
if ($summary === null) {
$rows[] = $this->offlineRow($server);
$rows[] = $this->rowWithoutMetrics($server);
continue;
}
$current = $results[$server->uuid.':containers'] ?? [];
$rows[] = array_merge(
['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)],
$summary
$summary,
['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'reporting' => true, 'containers' => count($current)]
);
foreach ($current as $c) {
@@ -308,12 +308,15 @@ class Metrics extends Component
}
/**
* A server whose Sentinel returned no current metrics. It is online when Coolify can reach it,
* because a failed metrics request does not mean that the server is down.
*
* @return array<string, mixed>
*/
protected function offlineRow(Server $server): array
protected function rowWithoutMetrics(Server $server): array
{
return [
'uuid' => $server->uuid, 'name' => $server->name, 'online' => false,
'uuid' => $server->uuid, 'name' => $server->name, 'online' => (bool) $server->isFunctional(), 'reporting' => false,
'cpu' => null, 'memUsed' => null, 'memTotal' => null,
'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null,
'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null,
+14 -11
View File
@@ -13,25 +13,27 @@ class FleetMetricsAggregator
public static function fleetKpis(array $rows): array
{
$online = array_values(array_filter($rows, fn ($r) => $r['online'] === true));
// Only servers whose Sentinel returned current metrics give values.
$reporting = array_values(array_filter($online, fn ($r) => $r['reporting'] === true));
$cpuValues = self::present($online, 'cpu');
$cpuValues = self::present($reporting, 'cpu');
$cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1);
$busiest = null;
foreach ($online as $r) {
foreach ($reporting as $r) {
if ($r['cpu'] !== null && ($busiest === null || $r['cpu'] > $busiest['percent'])) {
$busiest = ['name' => $r['name'], 'percent' => (float) $r['cpu']];
}
}
$memUsed = (int) self::sum($online, 'memUsed');
$memTotal = (int) self::sum($online, 'memTotal');
$diskUsed = (int) self::sum($online, 'diskUsed');
$diskTotal = (int) self::sum($online, 'diskTotal');
$load1 = self::present($online, 'load1');
$memUsed = (int) self::sum($reporting, 'memUsed');
$memTotal = (int) self::sum($reporting, 'memTotal');
$diskUsed = (int) self::sum($reporting, 'diskUsed');
$diskTotal = (int) self::sum($reporting, 'diskTotal');
$load1 = self::present($reporting, 'load1');
$needsAttention = 0;
foreach ($online as $r) {
foreach ($reporting as $r) {
$hot = PressureLevel::for($r['cpu'], 'cpu') !== 'ok'
|| PressureLevel::for(self::percent($r['memUsed'], $r['memTotal']), 'memory') !== 'ok'
|| PressureLevel::for($r['diskPercent'], 'disk') !== 'ok';
@@ -42,6 +44,7 @@ class FleetMetricsAggregator
return [
'serversOnline' => count($online),
'serversReporting' => count($reporting),
'serversTotal' => count($rows),
'needsAttention' => $needsAttention,
'cpuAvg' => $cpuAvg,
@@ -53,11 +56,11 @@ class FleetMetricsAggregator
'diskUsed' => $diskUsed,
'diskTotal' => $diskTotal,
'diskPercent' => self::percent($diskUsed, $diskTotal),
'netRx' => (float) self::sum($online, 'netRx'),
'netTx' => (float) self::sum($online, 'netTx'),
'netRx' => (float) self::sum($reporting, 'netRx'),
'netTx' => (float) self::sum($reporting, 'netTx'),
'loadBusiest' => $load1 === [] ? 0.0 : max($load1),
'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2),
'containers' => (int) self::sum($online, 'containers'),
'containers' => (int) self::sum($reporting, 'containers'),
];
}
@@ -109,8 +109,10 @@ $serverListboxOptions = array_merge(
@php
$sTotal = max(1, (int) ($kpis['serversTotal'] ?? 0));
$sOnline = (int) ($kpis['serversOnline'] ?? 0);
$sAttention = min($sOnline, (int) ($kpis['needsAttention'] ?? 0));
$sHealthy = max(0, $sOnline - $sAttention);
$sReporting = min($sOnline, (int) ($kpis['serversReporting'] ?? 0));
$sAttention = min($sReporting, (int) ($kpis['needsAttention'] ?? 0));
$sHealthy = max(0, $sReporting - $sAttention);
$sNoMetrics = $sOnline - $sReporting;
$sOffline = max(0, $sTotal - $sOnline);
@endphp
<div class="mt-auto pt-3">
@@ -118,11 +120,13 @@ $serverListboxOptions = array_merge(
<div class="flex h-2 overflow-hidden rounded-full bg-neutral-200 dark:bg-white/[0.08] [&>div]:transition-[width] [&>div]:duration-700 [&>div]:ease-out">
@if ($sHealthy > 0)<div class="bg-emerald-500" style="width: {{ $sHealthy / $sTotal * 100 }}%"></div>@endif
@if ($sAttention > 0)<div class="bg-orange-400 dark:bg-warning" style="width: {{ $sAttention / $sTotal * 100 }}%"></div>@endif
@if ($sNoMetrics > 0)<div class="bg-neutral-400 dark:bg-white/30" style="width: {{ $sNoMetrics / $sTotal * 100 }}%"></div>@endif
@if ($sOffline > 0)<div class="bg-red-500 dark:bg-red-400" style="width: {{ $sOffline / $sTotal * 100 }}%"></div>@endif
</div>
<div class="flex flex-wrap items-center gap-x-2 text-[10px] text-neutral-500 dark:text-fg-dim">
<span>{{ $sHealthy }} healthy</span>
@if ($sAttention > 0)<span class="text-orange-500 dark:text-warning">{{ $sAttention }} attention</span>@endif
@if ($sNoMetrics > 0)<span title="Coolify can reach these servers, but Sentinel did not return current metrics.">{{ $sNoMetrics }} no metrics</span>@endif
@if ($sOffline > 0)<span class="text-red-500 dark:text-red-400">{{ $sOffline }} offline</span>@endif
</div>
</div>
@@ -259,16 +263,18 @@ $serverListboxOptions = array_merge(
'containers' => $row['containers'],
];
@endphp
{{-- Offline servers have no values, so they stay last in each sort. --}}
<div wire:key="fleet-server-{{ $row['uuid'] }}" data-row="{{ json_encode($sortValues) }}" @unless ($row['online']) data-pinned @endunless
{{-- Servers without metrics have no values, so they stay last in each sort. --}}
<div wire:key="fleet-server-{{ $row['uuid'] }}" data-row="{{ json_encode($sortValues) }}" @unless ($row['reporting']) data-pinned @endunless
x-show="shown($el)"
class="data-table-row fleet-servers-table-grid border-t border-neutral-200 dark:border-white/[0.07]">
<div class="flex min-w-0 items-center gap-2">
<a href="{{ route('server.metrics', ['server_uuid' => $row['uuid']]) }}"
class="min-w-0 truncate text-[13px] font-medium text-black hover:underline dark:text-fg" {{ wireNavigate() }}>{{ $row['name'] }}</a>
@unless ($row['online'])
@if (! $row['online'])
<span class="table-badge table-badge-warning shrink-0">Offline</span>
@endunless
@elseif (! $row['reporting'])
<span class="table-badge shrink-0" title="Coolify can reach this server, but Sentinel did not return current metrics.">No metrics</span>
@endif
</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $memPercent, 'kind' => 'memory'])</div>
@@ -131,6 +131,23 @@ it('keeps an unreachable server in the table but excludes it from KPIs', functio
expect($component->get('kpis')['cpuAvg'])->toBe(30.0);
});
it('counts a reachable server as online when Sentinel returns no summary', function () {
$server = metricsServer($this->team, $this->privateKey);
$server->update(['name' => 'web-1']);
$server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly();
StubMetricsClient::$summaries = [$server->uuid => null];
$component = Livewire::test(TestableMetrics::class)
->assertSee('No metrics')
->assertDontSee('Offline');
$row = collect($component->get('serverRows'))->firstWhere('uuid', $server->uuid);
expect($row['online'])->toBeTrue();
expect($row['reporting'])->toBeFalse();
expect($component->get('kpis')['serversOnline'])->toBe(1);
expect($component->get('kpis')['serversReporting'])->toBe(0);
});
it('ranks containers across servers by the selected metric', function () {
$s = metricsServer($this->team, $this->privateKey);
@@ -5,7 +5,7 @@ use App\Services\FleetMetricsAggregator;
function row(array $overrides = []): array
{
return array_merge([
'uuid' => 'u', 'name' => 'srv', 'online' => true,
'uuid' => 'u', 'name' => 'srv', 'online' => true, 'reporting' => true,
'cpu' => 10.0, 'memUsed' => 1_000, 'memTotal' => 10_000,
'diskUsed' => 1_000, 'diskTotal' => 10_000, 'diskPercent' => 10.0,
'load1' => 0.5, 'netRx' => 100.0, 'netTx' => 50.0, 'containers' => 2,
@@ -51,7 +51,7 @@ it('sums network rates and reports busiest and average load', function () {
it('ignores offline rows and counts servers needing attention', function () {
$kpis = FleetMetricsAggregator::fleetKpis([
row(['cpu' => 95.0]), // over cpu error cutoff
row(['online' => false, 'cpu' => 99.0]),
row(['online' => false, 'reporting' => false, 'cpu' => 99.0]),
]);
expect($kpis['serversOnline'])->toBe(1);
@@ -60,6 +60,18 @@ it('ignores offline rows and counts servers needing attention', function () {
expect($kpis['cpuAvg'])->toBe(95.0); // offline row excluded from the mean
});
it('counts an online server without metrics as online but leaves it out of the values', function () {
$kpis = FleetMetricsAggregator::fleetKpis([
row(['cpu' => 20.0]),
row(['reporting' => false, 'cpu' => 99.0]),
]);
expect($kpis['serversOnline'])->toBe(2);
expect($kpis['serversReporting'])->toBe(1);
expect($kpis['needsAttention'])->toBe(0);
expect($kpis['cpuAvg'])->toBe(20.0);
});
it('returns zeroed kpis for no rows', function () {
$kpis = FleetMetricsAggregator::fleetKpis([]);