feat(metrics): sortable tables and one-click enable on the Metrics page (#12162)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
Aditya Tripathi
2026-10-15 15:30:58 +02:00
committed by GitHub
co-authored by Andras Bacsai
parent cf6350e665
commit 7e077d9501
8 changed files with 386 additions and 77 deletions
+2
View File
@@ -16,6 +16,8 @@
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
- Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree.
- Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`.
- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`.
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely.
+67 -1
View File
@@ -29,6 +29,9 @@ class Metrics extends Component
/** @var array<string, string> uuid => name */
public array $serverOptions = [];
/** @var array<int, string> Names of the servers where the user can turn metrics on; filled only when no server has metrics. */
public array $enableableServers = [];
#[Url(as: 'range')]
public string $range = '24h';
@@ -87,9 +90,13 @@ class Metrics extends Component
private function refreshServers(): void
{
abort_unless(currentTeam() !== null, 403);
$this->servers = Server::ownedByCurrentTeam()->get()
$teamServers = Server::ownedByCurrentTeam()->get();
$this->servers = $teamServers
->filter(fn (Server $server) => $server->isMetricsEnabled())
->values();
$this->enableableServers = $this->servers->isEmpty()
? $this->serversWithoutMetrics($teamServers)->pluck('name')->all()
: [];
foreach ($this->servers as $server) {
$this->authorize('view', $server);
@@ -104,6 +111,65 @@ class Metrics extends Component
}
}
/**
* Servers that can report metrics, have them off, are reachable, and that the user can change.
* An unreachable server cannot start Sentinel, so it stays out.
*
* @param Collection<int, Server> $teamServers
* @return Collection<int, Server>
*/
private function serversWithoutMetrics(Collection $teamServers): Collection
{
return $teamServers
->filter(fn (Server $server) => ! $server->isMetricsEnabled()
&& $server->isSentinelEnabled()
&& auth()->user()->can('update', $server)
&& $server->isFunctional())
->values();
}
public function enableMetrics(): void
{
try {
abort_unless(currentTeam() !== null, 403);
$servers = $this->serversWithoutMetrics(Server::ownedByCurrentTeam()->get());
if ($servers->isEmpty()) {
return;
}
// One failed server must not stop the others, so each server has its own try.
$failed = [];
foreach ($servers as $server) {
try {
$server->settings->is_metrics_enabled = true;
// Saved quietly: Sentinel is started below, so the settings hook must not start it a second time.
$server->settings->saveQuietly();
auditLog('ui.server.sentinel.updated', [
'team_id' => $server->team_id,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => ['is_metrics_enabled'],
]);
$server->restartSentinel();
} catch (\Throwable $e) {
report($e);
$failed[] = $server->name;
}
}
$this->loadData();
$enabled = $servers->count() - count($failed);
if ($enabled > 0) {
$this->dispatch('success', 'Metrics enabled on '.$enabled.' '.str('server')->plural($enabled).'. Sentinel is starting, data shows in about one minute.');
}
if ($failed !== []) {
$this->dispatch('error', 'Could not enable metrics on '.implode(', ', $failed).'. Check the server settings and try again.');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function updatedServerUuid(): void
{
$this->loadData();
+7 -5
View File
@@ -75,14 +75,16 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
values = {};
}
return { el, index, pinned: 'pinned' in el.dataset ? 1 : 0, value: values[this.key] ?? '' };
const value = values[this.key] ?? null;
return { el, index, pinned: 'pinned' in el.dataset ? 1 : 0, missing: value === null ? 1 : 0, value };
});
// Pinned rows (the "Other" remainder) stay last in each sort.
rows.sort((a, b) => a.pinned - b.pinned || compareValues(a.value, b.value) * sign || a.index - b.index);
// Pinned rows (the "Other" remainder) stay last in each sort, then rows without a value.
rows.sort((a, b) => a.pinned - b.pinned || a.missing - b.missing || compareValues(a.value, b.value) * sign || a.index - b.index);
rows.forEach((row, rank) => ranks.set(row.el, rank));
// All rows share one parent. Move them only when the order changed, so the
// mutation that the move causes finds them sorted and stops.
// All rows share one parent. Move them only when the order changed: the mutation
// that the move causes then finds them sorted, and a focused link is not blurred.
if (rows.some((row, rank) => row.index !== rank)) {
rows.forEach((row) => row.el.parentElement.appendChild(row.el));
observer?.takeRecords();
@@ -81,12 +81,21 @@ $serverListboxOptions = array_merge(
@if ($servers->isEmpty())
<x-empty size="sm" title="No server metrics yet"
description="Enable metrics on a server to see fleet resource usage here."
description="{{ empty($enableableServers) ? 'Enable metrics on a server to see fleet resource usage here.' : 'Enable metrics to collect CPU, memory, disk, and network history. This starts Sentinel; your applications keep running.' }}"
icon-name="graph">
<x-slot:contents>
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
<div class="flex flex-wrap items-center justify-center gap-3">
@if (! empty($enableableServers))
<x-forms.button isHighlighted wire:click="enableMetrics">
{{ count($enableableServers) === 1
? 'Enable metrics on '.\Illuminate\Support\Str::limit($enableableServers[0], 24)
: 'Enable metrics on '.count($enableableServers).' servers' }}
</x-forms.button>
@endif
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
</div>
</x-slot:contents>
</x-empty>
@else
@@ -226,41 +235,55 @@ $serverListboxOptions = array_merge(
</div>
{{-- Servers table --}}
<x-application.settings-section title="Servers" flush
helper="Per-server resource pressure. Sorted by CPU, highest first.">
<div class="data-table">
<div class="data-table-header fleet-servers-table-grid">
<span>Server</span>
<span>CPU</span>
<span>Memory</span>
<span>Disk</span>
<span>Load</span>
<span>Network</span>
<span class="text-right">Containers</span>
<x-application.settings-section id="metrics-servers-section" title="Servers" flush
helper="Per-server resource pressure. Select a column header to sort.">
<div class="data-table" x-data="trafficTable({ per: Number.MAX_SAFE_INTEGER, key: 'cpu' })">
<div class="data-table-header fleet-servers-table-grid border-b-0!">
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Server', 'text' => true, 'class' => 'justify-self-start'])
@foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'load' => 'Load', 'network' => 'Network'] as $column => $label)
@include('livewire.traffic._sort-header', ['column' => $column, 'label' => $label, 'text' => true, 'dir' => 'desc', 'class' => 'justify-self-start'])
@endforeach
@include('livewire.traffic._sort-header', ['column' => 'containers', 'label' => 'Containers', 'class' => 'justify-self-end'])
</div>
@foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row)
<div wire:key="fleet-server-{{ $row['uuid'] }}"
class="data-table-row fleet-servers-table-grid border-b border-neutral-200 last:border-b-0 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'])
<span class="table-badge table-badge-warning shrink-0">Offline</span>
@endunless
<div class="flex flex-col">
@foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row)
@php
$memPercent = ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null;
$sortValues = [
'name' => $row['name'],
'cpu' => $row['cpu'],
'memory' => $memPercent,
'disk' => $row['diskPercent'],
'load' => $row['load1'],
'network' => $row['netRx'] === null ? null : $row['netRx'] + $row['netTx'],
'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
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'])
<span class="table-badge table-badge-warning shrink-0">Offline</span>
@endunless
</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $memPercent, 'kind' => 'memory'])</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])</div>
<div class="text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}</div>
<div class="text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}</div>
<div class="text-right text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['containers'] ?? '-' }}</div>
</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])</div>
<div>@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])</div>
<div class="text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}</div>
<div class="text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}</div>
<div class="text-right text-[13px] tabular-nums text-neutral-600 dark:text-fg-dim">{{ $row['containers'] ?? '-' }}</div>
</div>
@endforeach
@endforeach
</div>
</div>
</x-application.settings-section>
{{-- Hottest containers --}}
<x-application.settings-section title="Hottest containers" flush
<x-application.settings-section id="metrics-containers-section" title="Hottest containers" flush
helper="{{ $scoped ? 'Busiest containers on this server, ranked by the selected metric.' : 'Busiest containers across the fleet, ranked by the selected metric.' }}">
<x-slot:actions>
<div class="flex items-center gap-2">
@@ -294,47 +317,51 @@ $serverListboxOptions = array_merge(
$maxMetric = max(1, collect($topContainers)->map($metricOf)->max());
@endphp
<div class="data-table transition-opacity" wire:loading.class="pointer-events-none opacity-50"
wire:target="containerMetric">
<div class="data-table-header fleet-containers-table-grid">
wire:target="containerMetric" x-data="trafficTable({ per: Number.MAX_SAFE_INTEGER, key: 'value' })">
<div class="data-table-header fleet-containers-table-grid border-b-0!">
<span class="text-center">#</span>
<span>Container</span>
<span>Server</span>
<span class="text-right">{{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}</span>
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Container', 'text' => true, 'class' => 'justify-self-start'])
@include('livewire.traffic._sort-header', ['column' => 'server', 'label' => 'Server', 'text' => true, 'class' => 'justify-self-start'])
@include('livewire.traffic._sort-header', ['column' => 'value', 'label' => ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric], 'class' => 'justify-self-end'])
</div>
@foreach ($topContainers as $c)
@php
$meta = $this->containerMeta($c['id']);
$pct = min(100, round($metricOf($c) / $maxMetric * 100, 1));
$display = match ($containerMetric) {
'memory' => formatBytes($c['memUsed'] ?? 0),
'disk' => formatBytes($c['diskBytes'] ?? 0),
'network' => formatBytes($c['net'] ?? 0).'/s',
default => round($c['cpu'] ?? 0).'%',
};
@endphp
<div wire:key="fleet-container-{{ $c['serverUuid'] ?? $c['server'] }}-{{ $c['id'] }}"
class="data-table-row fleet-containers-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="self-center text-center text-[12px] tabular-nums text-neutral-400 dark:text-fg-faint">{{ $loop->iteration }}</div>
<div class="flex min-w-0 flex-col">
@if ($meta['link'])
<a href="{{ $meta['link'] }}" class="truncate text-[13px] font-medium text-black hover:underline dark:text-fg" {{ wireNavigate() }}>{{ $meta['name'] }}</a>
@else
<span class="truncate text-[13px] font-medium text-black dark:text-fg">{{ $meta['name'] }}</span>
@endif
@if ($meta['image'])
<span class="truncate font-mono text-[11px] text-neutral-400 dark:text-fg-faint">{{ $meta['image'] }}</span>
@endif
</div>
<div class="min-w-0 self-center truncate text-[12px] text-neutral-500 dark:text-fg-dim">{{ $c['server'] }}</div>
<div class="flex items-center justify-end gap-2.5 self-center">
<div class="hidden h-1.5 w-full max-w-[6rem] overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]"
title="{{ $pct }}% of the busiest">
<div class="h-full rounded-full bg-[var(--chart-status-3xx)] transition-[width] duration-700 ease-out" style="width: {{ $pct }}%"></div>
<div class="flex flex-col">
@foreach ($topContainers as $c)
@php
$meta = $this->containerMeta($c['id']);
$pct = min(100, round($metricOf($c) / $maxMetric * 100, 1));
$display = match ($containerMetric) {
'memory' => formatBytes($c['memUsed'] ?? 0),
'disk' => formatBytes($c['diskBytes'] ?? 0),
'network' => formatBytes($c['net'] ?? 0).'/s',
default => round($c['cpu'] ?? 0).'%',
};
@endphp
<div wire:key="fleet-container-{{ $c['serverUuid'] ?? $c['server'] }}-{{ $c['id'] }}"
data-row="{{ json_encode(['name' => $meta['name'], 'server' => $c['server'], 'value' => $metricOf($c)]) }}"
x-show="shown($el)"
class="data-table-row fleet-containers-table-grid border-t border-neutral-200 dark:border-white/[0.07]">
<div class="self-center text-center text-[12px] tabular-nums text-neutral-400 dark:text-fg-faint">{{ $loop->iteration }}</div>
<div class="flex min-w-0 flex-col">
@if ($meta['link'])
<a href="{{ $meta['link'] }}" class="truncate text-[13px] font-medium text-black hover:underline dark:text-fg" {{ wireNavigate() }}>{{ $meta['name'] }}</a>
@else
<span class="truncate text-[13px] font-medium text-black dark:text-fg">{{ $meta['name'] }}</span>
@endif
@if ($meta['image'])
<span class="truncate font-mono text-[11px] text-neutral-400 dark:text-fg-faint">{{ $meta['image'] }}</span>
@endif
</div>
<div class="min-w-0 self-center truncate text-[12px] text-neutral-500 dark:text-fg-dim">{{ $c['server'] }}</div>
<div class="flex items-center justify-end gap-2.5 self-center">
<div class="hidden h-1.5 w-full max-w-[6rem] overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]"
title="{{ $pct }}% of the busiest">
<div class="h-full rounded-full bg-[var(--chart-status-3xx)] transition-[width] duration-700 ease-out" style="width: {{ $pct }}%"></div>
</div>
<span class="w-20 shrink-0 whitespace-nowrap text-right text-[13px] tabular-nums text-black dark:text-fg">{{ $display }}</span>
</div>
<span class="w-20 shrink-0 whitespace-nowrap text-right text-[13px] tabular-nums text-black dark:text-fg">{{ $display }}</span>
</div>
</div>
@endforeach
@endforeach
</div>
</div>
@endif
</x-application.settings-section>
@@ -8,10 +8,11 @@
hides on small screens passes `hidden <breakpoint>:inline-flex`
@param ?string $title tooltip
@param ?bool $text true for a text column: left-aligned, first sort ascending
@param ?string $dir first sort direction, when it differs from the default of the alignment
--}}
@php
$text = $text ?? false;
$firstDir = $text ? 'asc' : 'desc';
$firstDir = $dir ?? ($text ? 'asc' : 'desc');
$class = $class ?? '';
// `hidden` and `inline-flex` on one element conflict, so add the display only when the caller sets none.
$display = str_contains($class, 'hidden') ? '' : 'inline-flex';
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -63,6 +64,8 @@ class DenseTestableMetrics extends Metrics
}
beforeEach(function () {
// The settings hook starts Sentinel when metrics turn on. A sync queue would run it and make a real SSH call.
Queue::fake();
Concurrency::setDefaultInstance('sync');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$user = User::factory()->create();
@@ -1,5 +1,6 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Livewire\Fleet\Metrics;
use App\Models\Application;
use App\Models\Environment;
@@ -13,6 +14,8 @@ use App\Models\User;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Support\ConcurrentMetricsClient;
@@ -72,6 +75,8 @@ class ProcessTestableMetrics extends Metrics
}
beforeEach(function () {
// The settings hook starts Sentinel when metrics turn on. A sync queue would run it and make a real SSH call.
Queue::fake();
Concurrency::setDefaultInstance('sync');
StubMetricsClient::$summaries = [];
StubMetricsClient::$containers = [];
@@ -380,3 +385,65 @@ it('repairs the Sentinel token before creating worker clients', function () {
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($token)->not->toBe('invalid token');
});
/** A server that Coolify can reach, with metrics off. */
function reachableServerWithoutMetrics(int $teamId, PrivateKey $privateKey): Server
{
$server = Server::factory()->create(['team_id' => $teamId, 'private_key_id' => $privateKey->id]);
$server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly();
return $server;
}
it('enables metrics on each eligible server of the team from the empty state', function () {
$first = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
$second = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
$build = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
$build->settings->update(['server_role' => 'build', 'is_build_server' => true]);
$unreachable = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$foreign = reachableServerWithoutMetrics(Team::factory()->create()->id, $this->privateKey);
$component = Livewire::test(TestableMetrics::class)
->assertSee('Enable metrics on 2 servers')
->call('enableMetrics');
expect($first->settings->fresh()->is_metrics_enabled)->toBeTruthy();
expect($second->settings->fresh()->is_metrics_enabled)->toBeTruthy();
expect($build->settings->fresh()->is_metrics_enabled)->toBeFalsy();
expect($unreachable->settings->fresh()->is_metrics_enabled)->toBeFalsy();
expect($foreign->settings->fresh()->is_metrics_enabled)->toBeFalsy();
StartSentinel::assertPushed(2);
expect(array_column($component->get('serverRows'), 'uuid'))->toEqualCanonicalizing([$first->uuid, $second->uuid]);
$component->assertDontSee('No server metrics yet');
});
it('keeps enabling metrics on the other servers when one server fails', function () {
$broken = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
$healthy = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
DB::statement("CREATE TRIGGER fail_settings_update BEFORE UPDATE ON server_settings WHEN NEW.server_id = {$broken->id} BEGIN SELECT RAISE(ABORT, 'write failed'); END");
Livewire::test(TestableMetrics::class)
->call('enableMetrics')
->assertDispatched('success', fn (string $event, array $params) => str_contains($params[0], 'Metrics enabled on 1 server.'))
->assertDispatched('error', fn (string $event, array $params) => str_contains($params[0], $broken->name));
expect($broken->settings->fresh()->is_metrics_enabled)->toBeFalsy();
expect($healthy->settings->fresh()->is_metrics_enabled)->toBeTruthy();
StartSentinel::assertPushed(1);
});
it('does not let a team member enable metrics', function () {
$server = reachableServerWithoutMetrics($this->team->id, $this->privateKey);
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(TestableMetrics::class)
->assertSee('No server metrics yet')
->assertDontSee('Enable metrics on '.$server->name)
->call('enableMetrics');
expect($server->settings->fresh()->is_metrics_enabled)->toBeFalsy();
StartSentinel::assertNotPushed();
});
@@ -0,0 +1,141 @@
<?php
use App\Models\Server;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
class FakeBrowserFleetMetricsClient extends SentinelMetricsClient
{
public function summary(): ?array
{
$usage = [
'alpha' => ['cpu' => 10.0, 'memUsed' => 80],
// bravo runs an older Sentinel that does not send the load.
'bravo' => ['cpu' => 90.0, 'memUsed' => 20, 'load1' => null],
][$this->server->name] ?? null;
// charlie does not answer.
if ($usage === null) {
return null;
}
return array_merge([
'online' => true, 'memTotal' => 100,
'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0,
'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0,
], $usage);
}
public function containersCurrent(): array
{
return match ($this->server->name) {
'alpha' => [['id' => 'zeta', 'cpu' => 70.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0]],
'bravo' => [['id' => 'beta', 'cpu' => 5.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0]],
default => [],
};
}
public function history(string $metric, string $range): array
{
return [];
}
public function networkHistory(string $range): array
{
return ['rx' => [], 'tx' => []];
}
}
beforeEach(function () {
$this->stack = seedBrowserResourceStack(['serverName' => 'alpha']);
Concurrency::setDefaultInstance('sync');
// The dashboard chart reads metrics over SSH for each server with metrics on.
Process::fake();
$servers = [$this->stack['server']];
foreach (['bravo', 'charlie'] as $name) {
$servers[] = Server::factory()->create([
'name' => $name, 'team_id' => 0, 'private_key_id' => $this->stack['privateKey']->id,
]);
}
foreach ($servers as $server) {
// Saved quietly, so the settings hook does not start Sentinel.
$server->settings->forceFill(['is_metrics_enabled' => true, 'is_reachable' => true, 'is_usable' => true])->saveQuietly();
}
app()->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new FakeBrowserFleetMetricsClient($parameters['server']));
});
/** The visible row names of one metrics table, in DOM order (the order focus and screen readers follow). */
function visibleFleetMetricsRowNames(mixed $page, string $section): array
{
return $page->script(<<<JS
[...document.querySelectorAll('#{$section} [data-row]')]
.filter((row) => getComputedStyle(row).display !== 'none')
.map((row) => JSON.parse(row.dataset.row).name)
JS);
}
it('sorts the servers and containers tables when a column header is selected', function () {
loginAndSkipBoarding();
$page = visit('/metrics');
// Default: CPU, largest first. The offline server is last.
$page->assertSee('charlie');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['bravo', 'alpha', 'charlie']);
$page->click('#metrics-servers-section button[title="Sort by server"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['alpha', 'bravo', 'charlie']);
$page->click('#metrics-servers-section button[title="Sort by memory"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['alpha', 'bravo', 'charlie']);
// A second click reverses the direction. The offline server stays last.
$page->click('#metrics-servers-section button[title="Sort by memory"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['bravo', 'alpha', 'charlie']);
// A server without a value stays after the servers with one, in both directions.
$page->click('#metrics-servers-section button[title="Sort by load"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['alpha', 'bravo', 'charlie']);
$page->click('#metrics-servers-section button[title="Sort by load"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['alpha', 'bravo', 'charlie']);
$page->click('#metrics-servers-section button[title="Sort by memory"]');
$page->click('#metrics-servers-section button[title="Sort by memory"]');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['bravo', 'alpha', 'charlie']);
// A data refresh keeps the sort.
$page->click('button:visible:has-text("7 days")')
->assertQueryStringHas('range', '7d');
expect(visibleFleetMetricsRowNames($page, 'metrics-servers-section'))->toBe(['bravo', 'alpha', 'charlie']);
expect(visibleFleetMetricsRowNames($page, 'metrics-containers-section'))->toBe(['zeta', 'beta']);
// The notification banner at the bottom of the page covers this header, so a pointer click cannot reach it.
$page->script('document.querySelector(\'#metrics-containers-section button[title="Sort by container"]\').click()');
expect(visibleFleetMetricsRowNames($page, 'metrics-containers-section'))->toBe(['beta', 'zeta']);
$page->assertNoJavaScriptErrors()
->screenshot(filename: 'fleet-metrics-tables-sorted');
});
it('enables metrics from the empty state', function () {
Queue::fake();
Server::query()->each(fn (Server $server) => $server->settings->forceFill(['is_metrics_enabled' => false])->saveQuietly());
loginAndSkipBoarding();
$page = visit('/metrics');
$page->assertSee('No server metrics yet')
->click('button:visible:has-text("Enable metrics on 3 servers")')
->assertSee('Hottest containers')
->assertDontSee('No server metrics yet')
->assertNoJavaScriptErrors()
->screenshot(filename: 'fleet-metrics-enabled-from-empty-state');
});