feat(metrics): fleet-wide Metrics page + per-server disk/network/load charts (#11867)

This commit is contained in:
Andras Bacsai
2026-10-13 11:58:34 +02:00
committed by GitHub
27 changed files with 2515 additions and 23 deletions
+1
View File
@@ -4,6 +4,7 @@
- Reproduce the reported failure on the unchanged baseline before adding a fix.
- When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround.
- Test old reports against the current branch because later changes can make the report obsolete.
- Test serialized worker tasks with the real process driver. An in-process test does not prove that captured models or nested closures survive serialization.
- Use the same regression test before and after the production change so the result shows the behavior difference.
- Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code.
- The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change.
+9 -4
View File
@@ -736,10 +736,14 @@ Charts follow the application metrics implementation:
- downsample long time ranges before rendering.
Only add a metric if Sentinel exposes historical data for it. Current Sentinel
history endpoints store CPU and memory. Root filesystem usage is included in
the periodic push payload for threshold notifications, but it is not stored as
a historical Sentinel metric and has no history endpoint, so it cannot power a
disk-usage graph yet.
history endpoints store CPU, memory, disk, network throughput, and load average
(Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints
(`/api/summary`, `/api/containers/current`) return the latest host and
per-container values in one call for fleet-wide views.
The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across
every team server: KPI tiles, per-metric trend cards, a threshold-colored
servers table, and a fleet-wide hottest-containers list.
---
@@ -759,6 +763,7 @@ Use these as implementation references:
| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` |
| Standard table toolbar controls | `resources/views/components/table/*` |
| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` |
| Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` |
| Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` |
| Layer card | `resources/views/components/application/settings-section.blade.php` |
| Custom dropdown | `resources/views/components/forms/listbox.blade.php` |
@@ -0,0 +1,135 @@
<?php
namespace App\Livewire\Concerns;
use App\Services\FleetMetricsAggregator;
trait BuildsMetricsChartPayload
{
/**
* Sparklines read at a glance, not on close inspection, so they carry far fewer
* points than the full trend charts (industry guidance is ~10-50 per spark). All
* KPI-tile sparks share one downsampled axis so their shapes stay comparable.
*/
private const SPARK_MAX_POINTS = 40;
/**
* @param array{cpu: array, memory: array, disk: array, load: array, networkRx: array, networkTx: array} $seriesByServer
* @return array<string, mixed>
*/
protected function buildChartPayload(array $seriesByServer): array
{
$cpu = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg');
$memory = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum');
$disk = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg');
$load = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg');
$networkRx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum');
$networkTx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum');
// Throughput spark = rx + tx per bucket.
$network = $this->combineSeries($networkRx, $networkTx);
$categories = $this->sparkCategories($cpu, $memory, $disk, $network);
[$sparkCategories, $sparks] = $this->capSparks($categories, [
'cpuSpark' => $this->alignSpark($cpu, $categories),
'memSpark' => $this->alignSpark($memory, $categories),
'diskSpark' => $this->alignSpark($disk, $categories),
'netSpark' => $this->alignSpark($network, $categories),
], self::SPARK_MAX_POINTS);
return [
'range' => $this->range,
'cpu' => $cpu,
'memory' => $memory,
'disk' => $disk,
'load' => $load,
'networkRx' => $networkRx,
'networkTx' => $networkTx,
// KPI-tile sparklines: values-only series on one shared, capped time axis.
'sparkCategories' => $sparkCategories,
] + $sparks;
}
/**
* Thin every spark and the shared axis to at most $max points using the same
* evenly-spaced indices (first and last always kept), so the series stay aligned.
*
* @param array<int, int> $categories
* @param array<string, array<int, float|int|null>> $sparks
* @return array{0: array<int, int>, 1: array<string, array<int, float|int|null>>}
*/
private function capSparks(array $categories, array $sparks, int $max): array
{
$count = count($categories);
if ($count <= $max || $max < 2) {
return [$categories, $sparks];
}
$indices = [];
for ($i = 0; $i < $max; $i++) {
$indices[] = (int) round($i * ($count - 1) / ($max - 1));
}
$indices = array_values(array_unique($indices));
$pick = fn (array $values): array => array_values(array_map(fn (int $i) => $values[$i], $indices));
return [$pick($categories), array_map($pick, $sparks)];
}
/**
* Sum two [ts, value] series bucket-by-bucket into one sorted [ts, value] series.
*
* @param array<int, array{0: int, 1: float}> $a
* @param array<int, array{0: int, 1: float}> $b
* @return array<int, array{0: int, 1: float}>
*/
private function combineSeries(array $a, array $b): array
{
$map = [];
foreach ([$a, $b] as $series) {
foreach ($series as [$ts, $value]) {
$map[$ts] = ($map[$ts] ?? 0.0) + (float) $value;
}
}
ksort($map);
return array_map(fn ($ts, $value) => [(int) $ts, $value], array_keys($map), array_values($map));
}
/**
* Sorted union of bucket timestamps across every series (the shared spark x-axis).
*
* @param array<int, array{0: int, 1: float}> ...$seriesList
* @return array<int, int>
*/
private function sparkCategories(array ...$seriesList): array
{
$set = [];
foreach ($seriesList as $series) {
foreach ($series as [$ts]) {
$set[(int) $ts] = true;
}
}
$categories = array_keys($set);
sort($categories);
return $categories;
}
/**
* Project a [ts, value] series onto the shared category axis, leaving gaps as null.
*
* @param array<int, array{0: int, 1: float}> $series
* @param array<int, int> $categories
* @return array<int, float|int|null>
*/
private function alignSpark(array $series, array $categories): array
{
$map = [];
foreach ($series as [$ts, $value]) {
$map[(int) $ts] = $value;
}
return array_map(fn ($ts) => $map[$ts] ?? null, $categories);
}
}
+311
View File
@@ -0,0 +1,311 @@
<?php
namespace App\Livewire\Fleet;
use App\Livewire\Concerns\BuildsMetricsChartPayload;
use App\Models\Application;
use App\Models\Server;
use App\Services\FleetMetricsAggregator;
use App\Services\SentinelMetricsClient;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Concurrency;
use Livewire\Attributes\Lazy;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Lazy]
class Metrics extends Component
{
use AuthorizesRequests;
use BuildsMetricsChartPayload;
public string $chartId = 'fleet-metrics';
/** Metrics-enabled servers owned by the current team. */
public Collection $servers;
/** @var array<string, string> uuid => name */
public array $serverOptions = [];
#[Url(as: 'range')]
public string $range = '24h';
#[Url(as: 'server')]
public string $serverUuid = '';
#[Url(as: 'cmetric')]
public string $containerMetric = 'cpu';
private const CONTAINER_METRICS = ['cpu', 'memory', 'disk', 'network'];
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 = [];
/** @var array<string, mixed> Trend series for the charts, also embedded for first paint. */
public array $chartData = [];
/** @var array<string, array{name: string, link: ?string}> */
protected array $containerMetaCache = [];
public function mount(): void
{
$this->refreshServers();
$this->normalizeContainerMetric();
if ($this->servers->isNotEmpty()) {
$this->loadData();
}
}
public function hydrate(): void
{
$previous = array_column($this->serverRows, 'uuid');
$this->refreshServers();
$current = $this->targetServers()->pluck('uuid')->all();
sort($previous);
sort($current);
if ($previous !== $current) {
$this->loadData();
}
}
private function refreshServers(): void
{
abort_unless(currentTeam() !== null, 403);
$this->servers = Server::ownedByCurrentTeam()->get()
->filter(fn (Server $server) => $server->isMetricsEnabled())
->values();
foreach ($this->servers as $server) {
$this->authorize('view', $server);
}
$this->serverOptions = $this->servers
->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name])
->all();
if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) {
$this->serverUuid = '';
}
}
public function updatedServerUuid(): void
{
$this->loadData();
}
public function updatedContainerMetric(): void
{
$this->normalizeContainerMetric();
// Re-rank the already-fetched containers; no need to re-hit Sentinel.
$this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric);
}
private function normalizeContainerMetric(): void
{
if (! in_array($this->containerMetric, self::CONTAINER_METRICS, true)) {
$this->containerMetric = 'cpu';
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
$this->loadData();
}
public function isLivePollable(): bool
{
return $this->live && $this->range === '24h';
}
/**
* @return Collection<int, Server>
*/
protected function targetServers(): Collection
{
if ($this->serverUuid !== '') {
return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values();
}
return $this->servers;
}
public function loadData(): void
{
$this->refreshServers();
$this->normalizeContainerMetric();
$rows = [];
$containers = [];
$series = ['cpu' => [], 'memory' => [], 'disk' => [], 'load' => [], 'networkRx' => [], 'networkTx' => []];
$targets = $this->targetServers();
$clients = [];
$summaryTasks = [];
foreach ($targets as $server) {
$client = $clients[$server->uuid] = $this->metricsClient($server);
$summaryTasks[$server->uuid] = static fn () => $client->summary();
}
$summaries = $this->runMetricTasks($summaryTasks);
$tasks = [];
$range = $this->range;
foreach ($clients as $uuid => $client) {
$metrics = $summaries[$uuid] === null ? ['cpu', 'memory'] : ['cpu', 'memory', 'disk', 'load'];
foreach ($metrics as $metric) {
$tasks[$uuid.':'.$metric] = static fn () => $client->history($metric, $range);
}
if ($summaries[$uuid] !== null) {
$tasks[$uuid.':containers'] = static fn () => $client->containersCurrent();
$tasks[$uuid.':network'] = static fn () => $client->networkHistory($range);
}
}
$results = $this->runMetricTasks($tasks);
foreach ($targets as $server) {
$summary = $summaries[$server->uuid];
foreach (['cpu', 'memory', 'disk', 'load'] as $metric) {
$series[$metric][] = $results[$server->uuid.':'.$metric] ?? [];
}
if ($summary === null) {
$rows[] = $this->offlineRow($server);
continue;
}
$current = $results[$server->uuid.':containers'] ?? [];
$rows[] = array_merge(
['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)],
$summary
);
foreach ($current as $c) {
// Names/images are resolved lazily for the displayed rows only (containerMeta),
// so a server with many containers doesn't trigger a resolve storm here.
$containers[] = array_merge($c, ['server' => $server->name, 'serverUuid' => $server->uuid]);
}
$net = $results[$server->uuid.':network'] ?? ['rx' => [], 'tx' => []];
$series['networkRx'][] = $net['rx'];
$series['networkTx'][] = $net['tx'];
}
$this->serverRows = $rows;
$this->kpis = FleetMetricsAggregator::fleetKpis($rows);
$this->containersRaw = $containers;
$this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric);
$this->chartData = $this->buildChartPayload($series);
$this->dispatch("refreshChartData-{$this->chartId}", $this->chartData);
}
/**
* Bound the number of PHP workers and SSH connections for large fleets.
*
* @param array<string, \Closure> $tasks
* @return array<string, mixed>
*/
protected function runMetricTasks(array $tasks): array
{
foreach ($tasks as $key => $task) {
$tasks[$key] = static function () use ($task) {
try {
return $task();
} catch (\Throwable) {
return null;
}
};
}
$results = [];
foreach (array_chunk($tasks, 8, true) as $batch) {
$results += Concurrency::run($batch);
}
return $results;
}
/**
* @return array<string, mixed>
*/
protected function offlineRow(Server $server): array
{
return [
'uuid' => $server->uuid, 'name' => $server->name, 'online' => false,
'cpu' => null, 'memUsed' => null, 'memTotal' => null,
'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null,
'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null,
];
}
protected function metricsClient(Server $server): SentinelMetricsClient
{
$server->settings->ensureValidSentinelToken();
return app(SentinelMetricsClient::class, ['server' => $server]);
}
/**
* Resolve a Sentinel container id (the resource uuid Sentinel keys by) to a
* team-owned resource name, docker image, and metrics link, memoized. Shows the raw
* id and no image when no team-owned resource matches, so a Sentinel id never
* discloses another team's resource.
*
* @return array{name: string, image: ?string, link: ?string}
*/
public function containerMeta(string $id): array
{
if (isset($this->containerMetaCache[$id])) {
return $this->containerMetaCache[$id];
}
$teamId = currentTeam()?->id;
$resource = $teamId !== null ? getResourceByUuid($id, $teamId) : null;
$image = null;
$link = null;
if ($resource) {
$image = data_get($resource, 'docker_registry_image_name') ?: data_get($resource, 'image');
if ($resource instanceof Application && data_get($resource, 'environment.project.uuid')) {
$link = route('project.application.metrics', [
'project_uuid' => $resource->environment->project->uuid,
'environment_uuid' => $resource->environment->uuid,
'application_uuid' => $resource->uuid,
]);
}
}
return $this->containerMetaCache[$id] = [
'name' => $resource?->name ?? $id,
'image' => $image ?: null,
'link' => $link,
];
}
public function placeholder(): View
{
return view('livewire.fleet.metrics-placeholder');
}
public function render(): View
{
return view('livewire.fleet.metrics');
}
}
+22
View File
@@ -104,15 +104,37 @@ class Charts extends Component
try {
$cpuMetrics = $this->server->getCpuMetrics($this->interval);
$memoryMetrics = $this->server->getMemoryMetrics($this->interval);
// Disk/network/load arrived in a later Sentinel; fetch each optionally so an
// older Sentinel (which 404s them) still shows CPU and memory.
$diskMetrics = $this->optionalMetric(fn () => $this->server->getDiskMetrics($this->interval));
$loadMetrics = $this->optionalMetric(fn () => $this->server->getLoadMetrics($this->interval));
$networkMetrics = $this->optionalMetric(fn () => $this->server->getNetworkMetrics($this->interval)) ?? [];
$this->dispatch("refreshChartData-{$this->chartId}-metrics", [
'cpuSeries' => $cpuMetrics,
'memorySeries' => $memoryMetrics,
'diskSeries' => $diskMetrics,
'loadSeries' => $loadMetrics,
'networkRxSeries' => $networkMetrics['rx'] ?? [],
'networkTxSeries' => $networkMetrics['tx'] ?? [],
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Fetch a metric that may not exist on an older Sentinel; a failure yields null
* rather than breaking the whole chart refresh.
*/
private function optionalMetric(callable $fetch): mixed
{
try {
return $fetch();
} catch (\Throwable) {
return null;
}
}
/**
* Metrics settings belong to Sentinel, so they use the same event as the Sentinel page and API.
*
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Services;
use App\Support\Metrics\PressureLevel;
class FleetMetricsAggregator
{
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
public static function fleetKpis(array $rows): array
{
$online = array_values(array_filter($rows, fn ($r) => $r['online'] === true));
$cpuValues = self::present($online, 'cpu');
$cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1);
$busiest = null;
foreach ($online 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');
$needsAttention = 0;
foreach ($online 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';
if ($hot) {
$needsAttention++;
}
}
return [
'serversOnline' => count($online),
'serversTotal' => count($rows),
'needsAttention' => $needsAttention,
'cpuAvg' => $cpuAvg,
'cpuApproximate' => count($cpuValues) > 1,
'cpuBusiest' => $busiest,
'memUsed' => $memUsed,
'memTotal' => $memTotal,
'memPercent' => self::percent($memUsed, $memTotal),
'diskUsed' => $diskUsed,
'diskTotal' => $diskTotal,
'diskPercent' => self::percent($diskUsed, $diskTotal),
'netRx' => (float) self::sum($online, 'netRx'),
'netTx' => (float) self::sum($online, 'netTx'),
'loadBusiest' => $load1 === [] ? 0.0 : max($load1),
'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2),
'containers' => (int) self::sum($online, 'containers'),
];
}
/**
* @param array<int, array<int, array{0: int, 1: float}>> $seriesPerServer
* @return array<int, array{0: int, 1: float}>
*/
public static function sumSeriesByBucket(array $seriesPerServer, string $mode): array
{
$totals = [];
$counts = [];
foreach ($seriesPerServer as $series) {
foreach ($series as [$ts, $value]) {
$totals[$ts] = ($totals[$ts] ?? 0.0) + (float) $value;
$counts[$ts] = ($counts[$ts] ?? 0) + 1;
}
}
ksort($totals);
$out = [];
foreach ($totals as $ts => $sum) {
$out[] = [(int) $ts, $mode === 'avg' ? round($sum / $counts[$ts], 2) : round($sum, 2)];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $containers
* @return array<int, array<string, mixed>>
*/
public static function rankContainers(array $containers, string $metric, int $limit = 20): array
{
$key = match ($metric) {
'memory' => 'memUsed',
'disk' => 'diskBytes',
'network' => 'net',
default => 'cpu',
};
usort($containers, fn ($a, $b) => ($b[$key] ?? 0) <=> ($a[$key] ?? 0));
return array_slice($containers, 0, $limit);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, float>
*/
private static function present(array $rows, string $key): array
{
return array_values(array_map(
fn ($r) => (float) $r[$key],
array_filter($rows, fn ($r) => $r[$key] !== null)
));
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private static function sum(array $rows, string $key): float
{
return array_sum(array_map(fn ($r) => $r[$key] ?? 0, $rows));
}
private static function percent(int|float|null $used, int|float|null $total): float
{
if (! $total) {
return 0.0;
}
return round(($used / $total) * 100, 1);
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace App\Services;
use App\Models\Server;
use Illuminate\Support\Facades\Cache;
class SentinelMetricsClient
{
private string $base = 'http://localhost:8888/api';
public function __construct(protected Server $server) {}
/** Rounded to the minute so the history URL (and its cache key) is stable within a minute. */
public static function rangeFrom(string $range): string
{
$now = now()->startOfMinute();
$from = match ($range) {
'7d' => $now->subDays(7),
'30d' => $now->subDays(30),
default => $now->subDay(),
};
return $from->toIso8601ZuluString();
}
/** Bucket width per range, kept at roughly 300 points per series. */
public static function bucketMs(string $range): int
{
return match ($range) {
'7d' => 30 * 60_000,
'30d' => 2 * 60 * 60_000,
default => 5 * 60_000,
};
}
/**
* @return array<string, mixed>|null
*/
public function summary(): ?array
{
try {
$json = json_decode($this->raw($this->url('/summary')), true);
} catch (\Throwable) {
return null;
}
if (! is_array($json)) {
return null;
}
$disk = $this->rootMount($json['disk'] ?? []);
return [
'online' => true,
'cpu' => isset($json['cpu']['percent']) ? (float) $json['cpu']['percent'] : null,
'memUsed' => isset($json['memory']['used']) ? (int) $json['memory']['used'] : null,
'memTotal' => isset($json['memory']['total']) ? (int) $json['memory']['total'] : null,
'diskUsed' => $disk['used'],
'diskTotal' => $disk['total'],
'diskPercent' => $disk['percent'],
'load1' => isset($json['load']['load1']) ? (float) $json['load']['load1'] : null,
'netRx' => isset($json['network']['rxBytesPerSec']) ? (float) $json['network']['rxBytesPerSec'] : null,
'netTx' => isset($json['network']['txBytesPerSec']) ? (float) $json['network']['txBytesPerSec'] : null,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function containersCurrent(): array
{
try {
$rows = json_decode($this->raw($this->url('/containers/current')), true);
} catch (\Throwable) {
return [];
}
if (! is_array($rows)) {
return [];
}
return array_values(array_map(function ($c) {
$disk = ($c['disk']['writableLayer'] ?? 0) + ($c['disk']['volumesTotal'] ?? 0);
$net = ($c['network']['rxBytesPerSec'] ?? 0) + ($c['network']['txBytesPerSec'] ?? 0);
return [
'id' => (string) ($c['id'] ?? ''),
'cpu' => isset($c['cpu']['percent']) ? (float) $c['cpu']['percent'] : null,
'memUsed' => isset($c['memory']['used']) ? (int) $c['memory']['used'] : null,
'memPercent' => isset($c['memory']['usedPercent']) ? (float) $c['memory']['usedPercent'] : null,
'diskBytes' => isset($c['disk']) ? (int) $disk : null,
'net' => isset($c['network']) ? (float) $net : null,
];
}, $rows));
}
/**
* @return array<int, array{0: int, 1: float}>
*/
public function history(string $metric, string $range): array
{
[$path, $field] = match ($metric) {
'memory' => ['/memory/history', 'used'],
'disk' => ['/disk/history', 'usedPercent'],
'load' => ['/load/history', 'load1'],
default => ['/cpu/history', 'percent'],
};
return $this->seriesRows($this->url($path, ['from' => self::rangeFrom($range)]), $field, $range, $metric === 'disk');
}
/**
* @return array{rx: array<int, array{0: int, 1: float}>, tx: array<int, array{0: int, 1: float}>}
*/
public function networkHistory(string $range): array
{
$url = $this->url('/network/history', ['from' => self::rangeFrom($range)]);
return [
'rx' => $this->seriesRows($url, 'rxBytesPerSec', $range),
'tx' => $this->seriesRows($url, 'txBytesPerSec', $range),
];
}
/**
* @return array<int, array{0: int, 1: float}>
*/
private function seriesRows(string $url, string $field, string $range, bool $rootOnly = false): array
{
try {
$rows = json_decode($this->raw($url), true);
} catch (\Throwable) {
return [];
}
if (! is_array($rows)) {
return [];
}
// Disk history interleaves every mount; keep the root filesystem only.
if ($rootOnly && array_any($rows, fn ($row) => array_key_exists('mount', $row))) {
$rows = array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/');
}
// Average raw samples into epoch-aligned buckets so every server shares the same
// timestamps and the fleet aggregator can sum them point by point.
$bucketMs = self::bucketMs($range);
$sums = [];
$counts = [];
foreach ($rows as $r) {
$bucket = intdiv((int) ($r['time'] ?? 0), $bucketMs) * $bucketMs;
$sums[$bucket] = ($sums[$bucket] ?? 0.0) + (float) ($r[$field] ?? 0);
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
}
ksort($sums);
return array_map(fn ($bucket) => [$bucket, round($sums[$bucket] / $counts[$bucket], 2)], array_keys($sums));
}
/**
* @param array<int, array<string, mixed>> $mounts
* @return array{used: ?int, total: ?int, percent: ?float}
*/
private function rootMount(array $mounts): array
{
$root = collect($mounts)->firstWhere('mount', '/');
if ($root === null && ! array_any($mounts, fn ($mount) => array_key_exists('mount', $mount))) {
$root = $mounts[0] ?? null;
}
if ($root === null) {
return ['used' => null, 'total' => null, 'percent' => null];
}
return [
'used' => (int) ($root['used'] ?? 0),
'total' => (int) ($root['total'] ?? 0),
'percent' => (float) ($root['usedPercent'] ?? 0),
];
}
/**
* @param array<string, mixed> $query
*/
private function url(string $path, array $query = []): string
{
$qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query));
return $this->base.$path.$qs;
}
private function cacheKey(string $url): string
{
return 'metrics:'.$this->server->uuid.':'.md5($url);
}
protected function raw(string $url): string
{
return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url)));
}
protected function remoteFetch(string $url): string
{
$token = $this->server->settings->ensureValidSentinelToken();
return instant_remote_process(
["docker exec coolify-sentinel sh -c 'curl -s --max-time 10 -H \"Authorization: Bearer {$token}\" \"{$url}\"'"],
$this->server,
false,
timeout: 15
);
}
private function guard(string $response): string
{
$payload = json_decode($response, true);
if ($payload === null) {
throw new \RuntimeException('Metrics returned an invalid response.');
}
if (is_array($payload) && array_key_exists('error', $payload)) {
throw new \RuntimeException('Metrics request failed.');
}
return $response;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Support\Metrics;
class PressureLevel
{
public static function for(?float $percent, string $kind = 'default'): string
{
if ($percent === null) {
return 'ok';
}
[$warn, $error] = $kind === 'disk' ? [80.0, 90.0] : [70.0, 90.0];
return match (true) {
$percent >= $error => 'error',
$percent >= $warn => 'warning',
default => 'ok',
};
}
public static function textClass(string $level): string
{
return match ($level) {
'error' => 'text-red-500 dark:text-red-400',
'warning' => 'text-orange-500 dark:text-warning',
default => 'text-black dark:text-fg',
};
}
public static function barClass(string $level): string
{
return match ($level) {
'error' => 'bg-red-500 dark:bg-red-400',
'warning' => 'bg-orange-500 dark:bg-warning',
default => 'bg-neutral-400 dark:bg-white/30',
};
}
}
+71 -1
View File
@@ -27,7 +27,63 @@ trait HasMetrics
return convertContainerMemoryBytesToMegabytes($metrics);
}
/**
* Root filesystem usage percentage over time. Disk history interleaves every
* mount, so we keep only the root mount before mapping.
*/
public function getDiskMetrics(int $mins = 5): ?array
{
$rows = $this->fetchMetricRows('disk', $mins);
if ($rows === null) {
return null;
}
if (array_any($rows, fn ($row) => array_key_exists('mount', $row))) {
$rows = array_values(array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/'));
}
return $this->mapRows($rows, 'usedPercent', $mins);
}
/**
* Host load average (1 minute) over time.
*/
public function getLoadMetrics(int $mins = 5): ?array
{
return $this->getMetrics('load', $mins, 'load1');
}
/**
* Network throughput over time, as separate receive/transmit byte-rate series.
*
* @return array{rx: ?array, tx: ?array}|null
*/
public function getNetworkMetrics(int $mins = 5): ?array
{
$rows = $this->fetchMetricRows('network', $mins);
if ($rows === null) {
return null;
}
return [
'rx' => $this->mapRows($rows, 'rxBytesPerSec', $mins),
'tx' => $this->mapRows($rows, 'txBytesPerSec', $mins),
];
}
private function getMetrics(string $type, int $mins, string $valueField): ?array
{
return $this->mapRows($this->fetchMetricRows($type, $mins), $valueField, $mins);
}
/**
* Fetch and decode the raw Sentinel history rows for a metric type, or null when
* metrics are disabled. Throws on a Sentinel error response (caller decides whether
* to treat that as fatal or optional).
*
* @return array<int, array<string, mixed>>|null
*/
protected function fetchMetricRows(string $type, int $mins): ?array
{
$server = $this->getMetricsServer();
if (! $server->isMetricsEnabled()) {
@@ -63,7 +119,21 @@ trait HasMetrics
throw new \Exception($error);
}
$metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) {
return json_decode($response, true) ?: [];
}
/**
* Map raw rows to [timestampMs, value] pairs, downsampling long ranges.
*
* @param array<int, array<string, mixed>>|null $rows
*/
private function mapRows(?array $rows, string $valueField, int $mins): ?array
{
if ($rows === null) {
return null;
}
$metrics = collect($rows)->map(function ($metric) use ($valueField) {
return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)];
})->toArray();
+2
View File
@@ -11,6 +11,7 @@ use Illuminate\Auth\Passwords\PasswordResetServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
use Illuminate\Bus\BusServiceProvider;
use Illuminate\Cache\CacheServiceProvider;
use Illuminate\Concurrency\ConcurrencyServiceProvider;
use Illuminate\Cookie\CookieServiceProvider;
use Illuminate\Database\DatabaseServiceProvider;
use Illuminate\Encryption\EncryptionServiceProvider;
@@ -217,6 +218,7 @@ return [
BroadcastServiceProvider::class,
BusServiceProvider::class,
CacheServiceProvider::class,
ConcurrencyServiceProvider::class,
ConsoleSupportServiceProvider::class,
CookieServiceProvider::class,
DatabaseServiceProvider::class,
+20
View File
@@ -3155,6 +3155,26 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem;
}
.fleet-servers-table-grid {
grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem;
}
.fleet-containers-table-grid {
grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr);
}
.scheduled-executions-table-grid {
grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr);
}
.scheduler-runs-table-grid {
grid-template-columns: 9rem minmax(12rem, 1.5fr) 6rem 6rem 6rem;
}
.skipped-jobs-table-grid {
grid-template-columns: 9rem 7rem minmax(10rem, 1fr) minmax(12rem, 1.4fr);
}
.server-resources-managed-table-grid {
grid-template-columns: minmax(10rem, 1.3fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 8rem 9.5rem;
}
@@ -101,6 +101,14 @@
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Analytics</span>
</a>
</li>
<li>
<a title="Metrics" {{ wireNavigate() }}
class="{{ request()->is('metrics') ? 'menu-item menu-item-active' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('metrics') }}">
<x-reicon name="graph" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Metrics</span>
</a>
</li>
@can('canAccessTerminal')
<li>
<a title="Terminal"
@@ -0,0 +1,12 @@
@php
$level = \App\Support\Metrics\PressureLevel::for($percent, $kind);
$width = $percent === null ? 0 : min(100, round($percent, 1));
@endphp
<div class="flex items-center gap-2">
<span class="tabular-nums {{ \App\Support\Metrics\PressureLevel::textClass($level) }}">
{{ $percent === null ? '-' : round($percent).'%' }}
</span>
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
<div class="h-full rounded-full transition-[width] duration-700 ease-out {{ \App\Support\Metrics\PressureLevel::barClass($level) }}" style="width: {{ $width }}%;"></div>
</div>
</div>
@@ -0,0 +1,38 @@
<div class="flex w-full min-w-0 flex-col gap-6">
{{-- Header (real chrome; only the data below is a skeleton) --}}
<div class="flex flex-col gap-4">
<div class="min-w-0">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Metrics</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Resource usage across every server, reported by Sentinel.
</p>
</div>
{{-- Filter bar --}}
<div class="flex flex-wrap items-center gap-2">
<x-skeleton class="h-9 w-full rounded-lg sm:w-52" />
<x-skeleton class="h-9 w-52 rounded-lg sm:ml-auto" />
</div>
</div>
<x-application.settings-section id="metrics-overview-section" title="Overview">
<x-skeleton.tiles :count="6" grid="grid-cols-2 sm:grid-cols-3 lg:grid-cols-6"
:labels="['Servers online', 'Fleet CPU', 'Fleet memory', 'Fleet disk', 'Fleet network', 'Containers']" />
</x-application.settings-section>
<div class="grid gap-6 lg:grid-cols-2">
@foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label)
<x-application.settings-section :title="$label" @class(['lg:col-span-2' => $label === 'CPU'])>
<x-skeleton class="h-[240px] w-full rounded-lg" />
</x-application.settings-section>
@endforeach
</div>
<x-application.settings-section title="Servers" flush>
<x-skeleton.table :rows="4" flush />
</x-application.settings-section>
<x-application.settings-section title="Hottest containers" flush>
<x-skeleton.table :rows="6" flush />
</x-application.settings-section>
</div>
@@ -0,0 +1,431 @@
<?php
$tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
// Tiles read "Fleet …" across all servers, but just "CPU / Memory / …" when scoped to one.
$scoped = $serverUuid !== '';
$serverListboxOptions = array_merge(
[['value' => '', 'label' => 'All servers']],
collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(),
);
?>
<div class="flex w-full min-w-0 flex-col gap-6">
<x-slot:title>
Metrics | Coolify
</x-slot>
{{-- Header --}}
<div class="flex flex-col gap-4">
<div class="min-w-0">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Metrics</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Resource usage across every server, reported by Sentinel.
</p>
</div>
@if ($servers->isNotEmpty())
<div class="flex flex-wrap items-center gap-2">
<div class="relative w-full transition-opacity sm:w-52"
wire:loading.class="pointer-events-none opacity-60" wire:target="serverUuid">
<x-forms.listbox id="serverUuid" live :options="$serverListboxOptions" placeholder="All servers"
canGate="view" :canResource="$servers->first()" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="serverUuid">
<x-loading compact aria-label="Loading metrics" />
</div>
</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 }}')"
wire:loading.attr="disabled" wire:target="setRange"
@class([$tabButtonBase, $range === $value ? $tabButtonActive : $tabButtonInactive])>
<span wire:loading.class="invisible" wire:target="setRange('{{ $value }}')">{{ $label }}</span>
<x-loading compact class="absolute" wire:loading wire:target="setRange('{{ $value }}')" aria-label="Loading metrics" />
</button>
@endforeach
</div>
</div>
</div>
@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."
icon-name="graph">
<x-slot:contents>
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
</x-slot:contents>
</x-empty>
@else
{{-- Overview KPIs --}}
<x-application.settings-section id="metrics-overview-section" title="Overview"
helper="{{ $scoped ? 'Current resource usage for the selected server.' : 'Current resource usage across the selected servers.' }}">
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-6 dark:bg-white/[0.07]">
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Servers online</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ $kpis['serversOnline'] ?? 0 }}<span class="text-neutral-400 dark:text-fg-faint">/{{ $kpis['serversTotal'] ?? 0 }}</span></span>
@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);
$sOffline = max(0, $sTotal - $sOnline);
@endphp
<div class="mt-auto pt-3">
<div class="flex h-9 flex-col justify-center gap-1.5">
<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 ($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 ($sOffline > 0)<span class="text-red-500 dark:text-red-400">{{ $sOffline }} offline</span>@endif
</div>
</div>
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
{{ $scoped ? 'CPU' : 'Fleet CPU' }}
@if ($kpis['cpuApproximate'] ?? false)
<span class="ml-1.5 cursor-help text-neutral-400 dark:text-fg-faint" title="Averaged across servers; not core-weighted">~</span>
@endif
</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ round($kpis['cpuAvg'] ?? 0) }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-cpu',
'initial' => $chartData['cpuSpark'] ?? [],
'color' => '#3b82f6',
'categories' => $chartData['sparkCategories'] ?? [],
'event' => 'refreshChartData-'.$chartId,
'key' => 'cpuSpark',
'label' => $scoped ? 'CPU' : 'Fleet CPU',
'format' => 'percent',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">{{ $scoped ? 'Memory' : 'Fleet memory' }}</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ round($kpis['memPercent'] ?? 0) }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-memory',
'initial' => $chartData['memSpark'] ?? [],
'color' => '#8b5cf6',
'categories' => $chartData['sparkCategories'] ?? [],
'event' => 'refreshChartData-'.$chartId,
'key' => 'memSpark',
'label' => 'Memory used',
'format' => 'bytes',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">{{ $scoped ? 'Disk' : 'Fleet disk' }}</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ round($kpis['diskPercent'] ?? 0) }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-disk',
'initial' => $chartData['diskSpark'] ?? [],
'color' => '#ef4444',
'categories' => $chartData['sparkCategories'] ?? [],
'event' => 'refreshChartData-'.$chartId,
'key' => 'diskSpark',
'label' => $scoped ? 'Disk' : 'Fleet disk',
'format' => 'percent',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">{{ $scoped ? 'Network' : 'Fleet network' }}</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-network',
'initial' => $chartData['netSpark'] ?? [],
'color' => '#10b981',
'categories' => $chartData['sparkCategories'] ?? [],
'event' => 'refreshChartData-'.$chartId,
'key' => 'netSpark',
'label' => 'Network',
'format' => 'bytes',
])
</div>
</div>
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Containers</span>
<span class="mt-1 text-xl font-semibold tabular-nums text-black dark:text-fg">{{ compactNumber($kpis['containers'] ?? 0) }}</span>
<div class="mt-auto pt-3">
<div class="flex h-9 flex-col justify-center">
@if (! empty($topContainers))
@php
$hottest = $this->containerMeta($topContainers[0]['id']);
$hottestValue = match ($containerMetric) {
'memory' => formatBytes($topContainers[0]['memUsed'] ?? 0),
'disk' => formatBytes($topContainers[0]['diskBytes'] ?? 0),
'network' => formatBytes($topContainers[0]['net'] ?? 0).'/s',
default => round($topContainers[0]['cpu'] ?? 0).'%',
};
@endphp
<span class="truncate text-[13px] font-medium text-black dark:text-fg">{{ $hottest['name'] }}</span>
<span class="text-[10px] text-neutral-500 dark:text-fg-dim">hottest, {{ $hottestValue }}</span>
@endif
</div>
</div>
</div>
</div>
</x-application.settings-section>
{{-- Trend charts --}}
{{-- CPU is the primary triage signal, so it spans full width; the other four
fleet series fall into a clean 2x2 below (no orphaned cell). --}}
<div class="grid gap-6 lg:grid-cols-2">
@foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label)
<x-application.settings-section :title="$label" @class(['lg:col-span-2' => $key === 'cpu'])>
<div wire:ignore>
<div id="fleet-chart-{{ $key }}" class="min-h-[240px] w-full"></div>
</div>
</x-application.settings-section>
@endforeach
</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>
</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>
<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
</div>
</x-application.settings-section>
{{-- Hottest containers --}}
<x-application.settings-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">
<x-loading compact class="text-neutral-400 dark:text-fg-faint"
wire:loading.flex wire:target="containerMetric" />
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
@foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel)
<button type="button" wire:click="$set('containerMetric', '{{ $m }}')"
wire:loading.attr="disabled" wire:target="containerMetric"
@class([$tabButtonBase, $containerMetric === $m ? $tabButtonActive : $tabButtonInactive])>
{{ $mlabel }}
</button>
@endforeach
</div>
</div>
</x-slot:actions>
@if (empty($topContainers))
<div class="p-4">
<x-empty size="sm" title="No container metrics"
description="Container metrics need a Sentinel build with the bulk endpoints." />
</div>
@else
@php
$metricOf = fn ($c) => match ($containerMetric) {
'memory' => $c['memUsed'] ?? 0,
'disk' => $c['diskBytes'] ?? 0,
'network' => $c['net'] ?? 0,
default => $c['cpu'] ?? 0,
};
// Bars are scaled to the leader so the row length reads as "share of the hottest".
$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">
<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>
</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>
<span class="w-20 shrink-0 whitespace-nowrap text-right text-[13px] tabular-nums text-black dark:text-fg">{{ $display }}</span>
</div>
</div>
@endforeach
</div>
@endif
</x-application.settings-section>
@endif
@script
<script>
(() => {
// Defer to the next frame so the wire:ignore chart containers are laid out
// before ApexCharts measures them (a zero-size container renders blank).
requestAnimationFrame(() => {
if (typeof ApexCharts === 'undefined') { return; }
if (typeof checkTheme === 'function') { checkTheme(); }
const axisColor = (typeof textColor !== 'undefined') ? textColor : 'rgba(128,128,128,0.7)';
const fmtPercent = v => `${Number(Number(v).toFixed(1))}%`;
const fmtNumber = v => Number(Number(v).toFixed(2)).toLocaleString();
const fmtBytes = v => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = Number(v) || 0;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit++; }
return `${Number(amount.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`;
};
const fmtRate = v => `${fmtBytes(v)}/s`;
const fmtLocalTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZoneName: 'short' });
const fmtUtcTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZone: 'UTC', timeZoneName: 'short' });
// The app strips the default ApexCharts tooltip background (utilities.css), so
// every chart must render its own `apexcharts-tooltip-custom` markup.
const customTooltip = formatter => ({ series, dataPointIndex, w }) => {
const timestamp = w.globals.seriesX[0]?.[dataPointIndex];
let rows = '';
series.forEach((s, i) => {
const value = s[dataPointIndex];
if (value === null || value === undefined) { return; }
rows += `<div class="apexcharts-tooltip-custom-value">${w.globals.seriesNames[i]}: <span class="apexcharts-tooltip-value-bold">${formatter(value)}</span></div>`;
});
return `<div class="apexcharts-tooltip-custom">${rows}
<div class="apexcharts-tooltip-custom-title">Your time: ${fmtLocalTime(timestamp)}</div>
<div class="apexcharts-tooltip-custom-title">UTC: ${fmtUtcTime(timestamp)}</div>
</div>`;
};
const baseOptions = (series, colors, formatter) => ({
chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } },
series,
colors,
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.28, opacityTo: 0.02, stops: [0, 90, 100] } },
dataLabels: { enabled: false },
grid: { borderColor: 'rgba(128, 128, 128, 0.14)', strokeDashArray: 4 },
legend: { show: series.length > 1, labels: { colors: axisColor } },
xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } },
yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } },
noData: { text: 'No data for this range', style: { color: axisColor } },
tooltip: { shared: true, intersect: false, marker: { show: false }, custom: customTooltip(formatter) },
});
const initial = @js($chartData);
const build = data => ({
cpu: { series: [{ name: 'CPU %', data: data.cpu || [] }], colors: ['#3b82f6'], formatter: fmtPercent },
memory: { series: [{ name: 'Memory', data: data.memory || [] }], colors: ['#8b5cf6'], formatter: fmtBytes },
network: { series: [{ name: 'RX', data: data.networkRx || [] }, { name: 'TX', data: data.networkTx || [] }], colors: ['#10b981', '#f59e0b'], formatter: fmtRate },
load: { series: [{ name: 'Load', data: data.load || [] }], colors: ['#14b8a6'], formatter: fmtNumber },
disk: { series: [{ name: 'Disk %', data: data.disk || [] }], colors: ['#ef4444'], formatter: fmtPercent },
});
const charts = {};
const specs = build(initial || {});
for (const [key, spec] of Object.entries(specs)) {
const el = document.getElementById(`fleet-chart-${key}`);
if (! el) { continue; }
const chart = new ApexCharts(el, baseOptions(spec.series, spec.colors, spec.formatter));
chart.render();
charts[key] = chart;
}
Livewire.on('refreshChartData-{!! $chartId !!}', payload => {
if (typeof checkTheme === 'function') { checkTheme(); }
const data = Array.isArray(payload) ? payload[0] : payload;
if (! data) { return; }
const next = build(data);
for (const [key, chart] of Object.entries(charts)) {
chart.updateSeries(next[key].series);
}
});
});
})();
</script>
@endscript
</div>
@@ -59,15 +59,34 @@
</x-application.settings-section>
</form>
<x-application.settings-section id="server-cpu-metrics-section" title="CPU usage"
helper="Percentage of available CPU capacity used by this server.">
<div wire:ignore id="{!! $chartId !!}-cpu" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
{{-- CPU is the primary signal, so it spans full width; the other four
charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}}
<div class="grid gap-6 lg:grid-cols-2">
<x-application.settings-section id="server-cpu-metrics-section" title="CPU usage" class="lg:col-span-2"
helper="Percentage of available CPU capacity used by this server.">
<div wire:ignore id="{!! $chartId !!}-cpu" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-memory-metrics-section" title="Memory usage"
helper="Percentage of physical memory currently used by this server.">
<div wire:ignore id="{!! $chartId !!}-memory" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-memory-metrics-section" title="Memory usage"
helper="Percentage of physical memory currently used by this server.">
<div wire:ignore id="{!! $chartId !!}-memory" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-network-metrics-section" title="Network throughput"
helper="Receive and transmit rate across the server's network interfaces.">
<div wire:ignore id="{!! $chartId !!}-network" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-load-metrics-section" title="Load average"
helper="One minute load average reported by the server.">
<div wire:ignore id="{!! $chartId !!}-load" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
<x-application.settings-section id="server-disk-metrics-section" title="Disk usage"
helper="Percentage of the root filesystem used by this server.">
<div wire:ignore id="{!! $chartId !!}-disk" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
</div>
@script
<script>
@@ -81,6 +100,16 @@
return `${Number(number.toFixed(precision))}%`;
};
const formatNumber = value => Number(Number(value).toFixed(2)).toLocaleString();
const formatBytes = value => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = Number(value) || 0;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit++; }
return `${Number(amount.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`;
};
const formatRate = value => `${formatBytes(value)}/s`;
const formatLocalTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
hour12: false,
timeZoneName: 'short',
@@ -91,7 +120,7 @@
timeZoneName: 'short',
});
const chartOptions = (name, color, loadingText) => ({
const chartOptions = (name, color, loadingText, formatter = formatPercent) => ({
chart: {
height: 240,
type: 'area',
@@ -151,7 +180,7 @@
style: {
colors: textColor,
},
formatter: formatPercent,
formatter,
},
},
noData: {
@@ -180,7 +209,7 @@
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
return `<div class="apexcharts-tooltip-custom">
<div class="apexcharts-tooltip-custom-value">${name}: <span class="apexcharts-tooltip-value-bold">${formatPercent(value)}</span></div>
<div class="apexcharts-tooltip-custom-value">${w.globals.seriesNames?.[seriesIndex] ?? name}: <span class="apexcharts-tooltip-value-bold">${formatter(value)}</span></div>
<div class="apexcharts-tooltip-custom-title">Your time: ${formatLocalTimestamp(timestamp)}</div>
<div class="apexcharts-tooltip-custom-title">UTC: ${formatUtcTimestamp(timestamp)}</div>
</div>`;
@@ -197,8 +226,32 @@
chartOptions('Memory', ramColor, 'Loading memory metrics…'),
);
cpuChart.render();
memoryChart.render();
const diskChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-disk'),
chartOptions('Disk', '#ef4444', 'Loading disk metrics…', formatPercent),
);
const networkChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-network'),
{
...chartOptions('Network', '#10b981', 'Loading network metrics…', formatRate),
colors: ['#10b981', '#f59e0b'],
legend: { show: true, labels: { colors: textColor } },
},
);
const loadChart = new ApexCharts(
document.getElementById('{!! $chartId !!}-load'),
chartOptions('Load', '#14b8a6', 'Loading load metrics…', formatNumber),
);
// Defer to the next frame so the 2x2 grid cells are laid out
// before ApexCharts measures them (a zero-width cell renders blank).
requestAnimationFrame(() => {
cpuChart.render();
memoryChart.render();
diskChart.render();
networkChart.render();
loadChart.render();
});
Livewire.on('refreshChartData-{!! $chartId !!}-metrics', chartData => {
checkTheme();
@@ -272,6 +325,12 @@
},
},
});
diskChart.updateSeries([{ name: 'Disk', data: data.diskSeries || [] }]);
networkChart.updateSeries([
{ name: 'RX', data: data.networkRxSeries || [] },
{ name: 'TX', data: data.networkTxSeries || [] },
]);
loadChart.updateSeries([{ name: 'Load', data: data.loadSeries || [] }]);
});
})();
</script>
@@ -16,14 +16,16 @@
@param string $colorVar CSS custom property for the line color (e.g. --chart-status-3xx)
@param ?string $event Livewire event to listen on for updates (optional)
@param ?string $key payload key holding the numeric array (required with $event)
@param ?array $categories initial x-axis timestamps (defaults to $series buckets)
--}}
@php
$initial = $initial ?? [];
$colorVar = $colorVar ?? '--chart-status-3xx';
$color = $color ?? '';
$event = $event ?? '';
$key = $key ?? '';
$initialCategories = array_column($series ?? [], 'bucket');
$label = match ($key) {
$initialCategories = $categories ?? array_column($series ?? [], 'bucket');
$label = $label ?? match ($key) {
'requestsSpark' => 'Requests',
'uniquesSpark' => 'Visitors',
'bandwidthSpark' => 'Bandwidth',
@@ -31,12 +33,17 @@
'latencySpark' => 'p95 latency',
default => 'Value',
};
$format = $format ?? match ($key) {
'bandwidthSpark' => 'bytes',
'latencySpark' => 'ms',
default => 'number',
};
@endphp
<div wire:ignore id="{!! $id !!}" class="h-9 w-full [&_.apexcharts-svg]:overflow-visible!"
x-data="{
chart: null,
refreshCleanup: null,
accent() { return getComputedStyle(document.documentElement).getPropertyValue('{{ $colorVar }}').trim() || '#3b82f6'; },
accent() { const c = @js($color); if (c) { return c; } return getComputedStyle(document.documentElement).getPropertyValue('{{ $colorVar }}').trim() || '#3b82f6'; },
muted() { return getComputedStyle(document.documentElement).getPropertyValue('--chart-geo-empty').trim() || 'rgba(128,128,128,0.45)'; },
isFlat(a) { return !Array.isArray(a) || a.length === 0 || a.every(v => !Number(v)); },
points(values, categories) {
@@ -68,14 +75,17 @@
timeZoneName: 'short',
});
const formatValue = value => {
if (@js($key) === 'bandwidthSpark') {
if (value === null || value === undefined) { return '—'; }
const fmt = @js($format);
if (fmt === 'bytes') {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = Number(value) || 0;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit++; }
return `${Number(amount.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`;
}
if (@js($key) === 'latencySpark') { return `${Number(value).toLocaleString()} ms`; }
if (fmt === 'ms') { return `${Number(value).toLocaleString()} ms`; }
if (fmt === 'percent') { return `${Number(Number(value).toFixed(1)).toLocaleString()}%`; }
return Number(value).toLocaleString();
};
this.chart = new ApexCharts($el, {
+2
View File
@@ -13,6 +13,7 @@ use App\Livewire\Destination\Index as DestinationIndex;
use App\Livewire\Destination\Resources as DestinationResources;
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Dev\LivewireRequestFailurePreview;
use App\Livewire\Fleet\Metrics as FleetMetrics;
use App\Livewire\ForcePasswordReset;
use App\Livewire\Notifications\Discord as NotificationDiscord;
use App\Livewire\Notifications\Email as NotificationEmail;
@@ -167,6 +168,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/analytics', Analytics::class)->name('analytics');
Route::get('/metrics', FleetMetrics::class)->name('metrics');
Route::get('/admin', AdminIndex::class)->name('admin.index');
Route::get('/onboarding', BoardingIndex::class)->name('onboarding');
@@ -0,0 +1,130 @@
<?php
use App\Livewire\Fleet\Metrics;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Concurrency;
use Livewire\Livewire;
uses(RefreshDatabase::class);
class ChartStubClient extends SentinelMetricsClient
{
public function summary(): ?array
{
return ['online' => true, 'cpu' => 10.0, 'memUsed' => 1, 'memTotal' => 2,
'diskUsed' => 1, 'diskTotal' => 2, 'diskPercent' => 50.0, 'load1' => 0.1, 'netRx' => 0.0, 'netTx' => 0.0];
}
public function containersCurrent(): array
{
return [];
}
public function history(string $metric, string $range): array
{
return [[1000, 10.0], [2000, 20.0]];
}
public function networkHistory(string $range): array
{
return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]];
}
}
class ChartTestableMetrics extends Metrics
{
protected function metricsClient(Server $server): SentinelMetricsClient
{
return new ChartStubClient($server);
}
}
class DenseChartStubClient extends ChartStubClient
{
public function history(string $metric, string $range): array
{
// 300 points, like a downsampled 24h host series.
return array_map(fn ($i) => [$i * 1000, (float) $i], range(1, 300));
}
}
class DenseTestableMetrics extends Metrics
{
protected function metricsClient(Server $server): SentinelMetricsClient
{
return new DenseChartStubClient($server);
}
}
beforeEach(function () {
Concurrency::setDefaultInstance('sync');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->team = $team;
});
it('dispatches a chart payload with every trend series', function () {
$s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$s->settings->update(['is_metrics_enabled' => true]);
Livewire::test(ChartTestableMetrics::class)
->call('loadData')
->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) {
$payload = $params[0];
// Livewire JSON-serializes event params, so whole floats arrive as ints;
// compare loosely (10 == 10.0).
return $payload['cpu'] == [[1000, 10], [2000, 20]]
&& $payload['networkRx'] == [[1000, 5]]
&& array_key_exists('load', $payload);
});
});
it('includes KPI-tile spark series aligned to one shared time axis', function () {
$s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$s->settings->update(['is_metrics_enabled' => true]);
Livewire::test(ChartTestableMetrics::class)
->call('loadData')
->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) {
$payload = $params[0];
// Union of every series' buckets, sorted; each spark projects onto it,
// leaving gaps as null (network only has the 1000 bucket → tail is null).
return $payload['sparkCategories'] == [1000, 2000]
&& $payload['cpuSpark'] == [10, 20]
&& $payload['memSpark'] == [10, 20]
&& $payload['diskSpark'] == [10, 20]
&& $payload['netSpark'] === [6, null];
});
});
it('caps KPI-tile sparks to a readable point count on a shared axis', function () {
$s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$s->settings->update(['is_metrics_enabled' => true]);
Livewire::test(DenseTestableMetrics::class)
->call('loadData')
->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) {
$payload = $params[0];
// Dense trend charts keep their detail; sparks are thinned to <= 40 points,
// and every spark shares the same length as the axis (grouped-comparable).
return count($payload['cpu']) === 300
&& count($payload['sparkCategories']) <= 40
&& count($payload['cpuSpark']) === count($payload['sparkCategories'])
&& count($payload['memSpark']) === count($payload['sparkCategories'])
&& count($payload['netSpark']) === count($payload['sparkCategories']);
});
});
@@ -0,0 +1,382 @@
<?php
use App\Livewire\Fleet\Metrics;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Team;
use App\Models\User;
use App\Services\SentinelMetricsClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Concurrency;
use Livewire\Livewire;
use Tests\Support\ConcurrentMetricsClient;
uses(RefreshDatabase::class);
class StubMetricsClient extends SentinelMetricsClient
{
/** @var array<string, ?array> serverUuid => summary */
public static array $summaries = [];
/** @var array<string, array> serverUuid => containers */
public static array $containers = [];
public static array $histories = [];
public static array $failingMetrics = [];
public function summary(): ?array
{
return self::$summaries[$this->server->uuid] ?? null;
}
public function containersCurrent(): array
{
return self::$containers[$this->server->uuid] ?? [];
}
public function history(string $metric, string $range): array
{
if (in_array($metric, self::$failingMetrics, true)) {
throw new RuntimeException('metric unavailable');
}
return self::$histories[$this->server->uuid][$metric] ?? [];
}
public function networkHistory(string $range): array
{
return ['rx' => [], 'tx' => []];
}
}
class TestableMetrics extends Metrics
{
protected function metricsClient(Server $server): SentinelMetricsClient
{
return new StubMetricsClient($server);
}
}
class ProcessTestableMetrics extends Metrics
{
protected function metricsClient(Server $server): SentinelMetricsClient
{
return new ConcurrentMetricsClient($server);
}
}
beforeEach(function () {
Concurrency::setDefaultInstance('sync');
StubMetricsClient::$summaries = [];
StubMetricsClient::$containers = [];
StubMetricsClient::$histories = [];
StubMetricsClient::$failingMetrics = [];
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->team = $team;
});
function metricsServer($team, $privateKey): Server
{
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$server->settings->update(['is_metrics_enabled' => true]);
return $server;
}
function onlineSummary(array $overrides = []): array
{
return array_merge([
'online' => true, 'cpu' => 30.0, 'memUsed' => 100, 'memTotal' => 200,
'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0,
'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0,
], $overrides);
}
it('keeps an unreachable server in the table but excludes it from KPIs', function () {
$ok = metricsServer($this->team, $this->privateKey);
$dead = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [
$ok->uuid => onlineSummary(['cpu' => 30.0]),
$dead->uuid => null,
];
$component = Livewire::test(TestableMetrics::class);
expect($component->get('serverRows'))->toHaveCount(2);
$deadRow = collect($component->get('serverRows'))->firstWhere('uuid', $dead->uuid);
expect($deadRow['online'])->toBeFalse();
expect($deadRow['cpu'])->toBeNull();
expect($component->get('kpis')['serversOnline'])->toBe(1);
expect($component->get('kpis')['cpuAvg'])->toBe(30.0);
});
it('ranks containers across servers by the selected metric', function () {
$s = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$s->uuid => onlineSummary(['diskPercent' => 50.0])];
StubMetricsClient::$containers = [$s->uuid => [
['id' => 'low', 'cpu' => 5.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0],
['id' => 'high', 'cpu' => 88.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0],
]];
$component = Livewire::test(TestableMetrics::class);
expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high');
});
it('shows the KPI tiles, a stale badge for offline servers, and the container toggle', function () {
$ok = metricsServer($this->team, $this->privateKey);
$ok->update(['name' => 'web-1']);
$dead = metricsServer($this->team, $this->privateKey);
$dead->update(['name' => 'web-2']);
StubMetricsClient::$summaries = [
$ok->uuid => onlineSummary(['cpu' => 95.0, 'diskPercent' => 95.0]),
$dead->uuid => null,
];
Livewire::test(TestableMetrics::class)
->assertSee('web-1')
->assertSee('web-2')
->assertSee('Fleet CPU')
->assertSee('healthy')
->assertSee('attention')
->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
// PrivateKey::create collides on fingerprint. Only the server's team_id matters here.
$server = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $this->privateKey->id]);
$server->settings->update(['is_metrics_enabled' => true]);
$component = Livewire::test(TestableMetrics::class);
expect($component->get('serverRows'))->toHaveCount(0);
});
it('drops the "Fleet" tile prefix when scoped to a single server', function () {
$s = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
Livewire::test(TestableMetrics::class)
->assertSee('Fleet CPU')
->set('serverUuid', $s->uuid)
->assertDontSee('Fleet')
->assertSee('for the selected server');
});
it('renders container metric values without leaking a blade directive', 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' => 2048.0],
]];
Livewire::test(TestableMetrics::class)
->set('containerMetric', 'network')
->assertDontSee('@break')
->assertSee('/s');
});
it('falls back to cpu for an unknown container metric', 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],
]];
Livewire::withQueryParams(['cmetric' => 'foo'])
->test(TestableMetrics::class)
->assertOk()
->assertSet('containerMetric', 'cpu')
->set('containerMetric', 'bar')
->assertSet('containerMetric', 'cpu');
});
it('keys container rows by server so shared container ids do not collide', function () {
$one = metricsServer($this->team, $this->privateKey);
$two = metricsServer($this->team, $this->privateKey);
$proxy = ['id' => 'coolify-proxy', 'cpu' => 1.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0];
StubMetricsClient::$summaries = [$one->uuid => onlineSummary(), $two->uuid => onlineSummary()];
StubMetricsClient::$containers = [$one->uuid => [$proxy], $two->uuid => [$proxy]];
Livewire::test(TestableMetrics::class)
->assertSeeHtml('wire:key="fleet-container-'.$one->uuid.'-coolify-proxy"')
->assertSeeHtml('wire:key="fleet-container-'.$two->uuid.'-coolify-proxy"');
});
it('passes the spark time axis to the KPI sparklines on first paint', function () {
$s = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$s->uuid => onlineSummary()];
$component = Livewire::test(TestableMetrics::class);
$component->set('chartData', ['sparkCategories' => [1700000100000], 'cpuSpark' => [12.5]]);
$component->assertSeeHtml('1700000100000');
});
it('removes a transferred server on refresh', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
$component = Livewire::test(TestableMetrics::class);
expect($component->get('serverRows'))->toHaveCount(1);
$server->update(['team_id' => Team::factory()->create()->id]);
$component->call('loadData');
expect($component->get('serverRows'))->toBe([]);
expect($component->get('containersRaw'))->toBe([]);
expect($component->get('kpis')['serversTotal'])->toBe(0);
});
it('permits team members to read metrics', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
$this->actingAs($member);
expect(Livewire::test(TestableMetrics::class)->get('kpis')['serversOnline'])->toBe(1);
});
it('keeps host trends when an older Sentinel has no summary endpoint', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$histories = [$server->uuid => ['cpu' => [[1000, 25.0]], 'memory' => [[1000, 100.0]]]];
$component = Livewire::test(TestableMetrics::class);
expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]);
expect($component->get('chartData')['memory'])->toBe([[1000, 100.0]]);
expect($component->get('topContainers'))->toBe([]);
});
it('keeps other metrics when one independent operation fails', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
StubMetricsClient::$histories = [$server->uuid => ['cpu' => [[1000, 25.0]]]];
StubMetricsClient::$failingMetrics = ['disk'];
$component = Livewire::test(TestableMetrics::class);
expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]);
expect($component->get('chartData')['disk'])->toBe([]);
expect($component->get('kpis')['serversOnline'])->toBe(1);
});
it('resolves container names for the instance team with id zero', function () {
$rootTeam = Team::factory()->create(['id' => 0]);
auth()->user()->teams()->attach($rootTeam, ['role' => 'owner']);
auth()->user()->unsetRelation('teams');
session(['currentTeam' => $rootTeam]);
$project = Project::factory()->create(['team_id' => 0]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$meta = Livewire::test(TestableMetrics::class)->instance()->containerMeta($application->uuid);
expect($meta['name'])->toBe($application->name);
expect($meta['link'])->toContain($application->uuid);
});
it('denies metrics refresh after team membership is removed', function () {
$this->withoutVite();
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
$component = Livewire::test(TestableMetrics::class);
auth()->user()->teams()->detach($this->team);
auth()->user()->unsetRelation('teams');
$component->call('loadData')->assertForbidden();
});
it('clears stale container rankings after a server transfer', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
StubMetricsClient::$containers = [$server->uuid => [['id' => 'private-container', 'cpu' => 10]]];
$component = Livewire::test(TestableMetrics::class);
$server->update(['team_id' => Team::factory()->create()->id]);
$component->set('containerMetric', 'memory');
expect($component->get('topContainers'))->toBe([]);
expect($component->get('serverRows'))->toBe([]);
});
it('merges metrics from real concurrent workers and isolates endpoint failures', function () {
Concurrency::setDefaultInstance('process');
metricsServer($this->team, $this->privateKey);
metricsServer($this->team, $this->privateKey);
$component = Livewire::test(ProcessTestableMetrics::class);
expect($component->get('kpis')['serversOnline'])->toBe(2);
expect($component->get('topContainers'))->toHaveCount(2);
expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]);
expect($component->get('chartData')['memory'])->toBe([[1000, 200.0]]);
expect($component->get('chartData')['disk'])->toBe([]);
expect($component->get('chartData')['networkRx'])->toBe([[1000, 10.0]]);
});
it('clears stale metrics after the last server is deleted', function () {
$server = metricsServer($this->team, $this->privateKey);
StubMetricsClient::$summaries = [$server->uuid => onlineSummary()];
StubMetricsClient::$containers = [$server->uuid => [['id' => 'deleted-container', 'cpu' => 10]]];
$component = Livewire::test(TestableMetrics::class);
$server->delete();
$component->set('containerMetric', 'memory');
expect($component->get('topContainers'))->toBe([]);
expect($component->get('serverRows'))->toBe([]);
});
it('repairs the Sentinel token before creating worker clients', function () {
$server = metricsServer($this->team, $this->privateKey);
$server->settings->updateQuietly(['sentinel_token' => 'invalid token']);
app()->bind(SentinelMetricsClient::class, fn ($app, $parameters) => new ConcurrentMetricsClient($parameters['server']));
loadLazy(Livewire::test(Metrics::class));
$token = $server->settings->fresh()->sentinel_token;
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($token)->not->toBe('invalid token');
});
@@ -0,0 +1,33 @@
<?php
use App\Livewire\Fleet\Metrics;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$this->team = $team;
});
it('renders the metrics page with an empty state when no server has metrics enabled', function () {
$component = loadLazy(Livewire::test(Metrics::class));
$component
->assertOk()
->assertSee('Metrics')
->assertSee('Enable metrics on a server');
});
it('registers the metrics route', function () {
expect(route('metrics'))->toEndWith('/metrics');
});
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Support;
use App\Services\SentinelMetricsClient;
class ConcurrentMetricsClient extends SentinelMetricsClient
{
public function summary(): ?array
{
return [
'online' => true, 'cpu' => 25.0, 'memUsed' => 100, 'memTotal' => 200,
'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0,
'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0,
];
}
public function containersCurrent(): array
{
return [['id' => 'container-'.$this->server->uuid, 'cpu' => 25.0]];
}
public function history(string $metric, string $range): array
{
if ($metric === 'disk') {
throw new \RuntimeException('Disk endpoint unavailable.');
}
return [[1000, $metric === 'cpu' ? 25.0 : 100.0]];
}
public function networkHistory(string $range): array
{
return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]];
}
}
@@ -0,0 +1,75 @@
<?php
use App\Traits\HasMetrics;
/**
* Test double exposing the new metric mappers without SSH: overrides the fetch step
* to return canned Sentinel rows.
*/
class MetricsMappingDouble
{
use HasMetrics;
/** @var array<string, ?array> type => rows */
public array $rowsByType = [];
protected function fetchMetricRows(string $type, int $mins): ?array
{
return $this->rowsByType[$type] ?? null;
}
}
it('keeps only the root mount for disk metrics', function () {
$d = new MetricsMappingDouble;
$d->rowsByType['disk'] = [
['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90.0],
['time' => 1000, 'mount' => '/', 'usedPercent' => 40.0],
['time' => 2000, 'mount' => '/', 'usedPercent' => 42.0],
];
expect($d->getDiskMetrics(5))->toBe([[1000, 40.0], [2000, 42.0]]);
});
it('splits network metrics into rx and tx series', function () {
$d = new MetricsMappingDouble;
$d->rowsByType['network'] = [
['time' => 1000, 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 10.0],
['time' => 2000, 'rxBytesPerSec' => 200.0, 'txBytesPerSec' => 20.0],
];
expect($d->getNetworkMetrics(5))->toBe([
'rx' => [[1000, 100.0], [2000, 200.0]],
'tx' => [[1000, 10.0], [2000, 20.0]],
]);
});
it('maps the one minute load average', function () {
$d = new MetricsMappingDouble;
$d->rowsByType['load'] = [
['time' => 1000, 'load1' => 0.5, 'load5' => 1.0, 'load15' => 1.5],
];
expect($d->getLoadMetrics(5))->toBe([[1000, 0.5]]);
});
it('returns null for a metric when metrics are unavailable', function () {
$d = new MetricsMappingDouble; // rowsByType empty -> fetch returns null
expect($d->getDiskMetrics(5))->toBeNull();
expect($d->getNetworkMetrics(5))->toBeNull();
expect($d->getLoadMetrics(5))->toBeNull();
});
it('does not use non-root mounts when root disk samples are missing', function () {
$double = new MetricsMappingDouble;
$double->rowsByType['disk'] = [['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90]];
expect($double->getDiskMetrics(5))->toBe([]);
});
it('supports older disk rows without a mount field', function () {
$double = new MetricsMappingDouble;
$double->rowsByType['disk'] = [['time' => 1000, 'usedPercent' => 40]];
expect($double->getDiskMetrics(5))->toBe([[1000, 40.0]]);
});
+22
View File
@@ -0,0 +1,22 @@
<?php
use App\Support\Metrics\PressureLevel;
it('classifies cpu/memory pressure at 70/90 cutoffs', function () {
expect(PressureLevel::for(10, 'cpu'))->toBe('ok');
expect(PressureLevel::for(70, 'cpu'))->toBe('warning');
expect(PressureLevel::for(89.9, 'memory'))->toBe('warning');
expect(PressureLevel::for(90, 'memory'))->toBe('error');
});
it('classifies disk pressure at 80/90 cutoffs', function () {
expect(PressureLevel::for(75, 'disk'))->toBe('ok');
expect(PressureLevel::for(80, 'disk'))->toBe('warning');
expect(PressureLevel::for(95, 'disk'))->toBe('error');
});
it('treats a null percent as ok and maps levels to classes', function () {
expect(PressureLevel::for(null, 'cpu'))->toBe('ok');
expect(PressureLevel::textClass('error'))->toContain('text-red-500');
expect(PressureLevel::barClass('warning'))->toContain('bg-orange-500');
});
@@ -0,0 +1,33 @@
<?php
use App\Services\FleetMetricsAggregator;
it('sums series by bucket timestamp and sorts ascending', function () {
$merged = FleetMetricsAggregator::sumSeriesByBucket([
[[2000, 5.0], [1000, 1.0]],
[[1000, 2.0], [3000, 9.0]],
], 'sum');
expect($merged)->toBe([[1000, 3.0], [2000, 5.0], [3000, 9.0]]);
});
it('averages series across servers per bucket', function () {
$merged = FleetMetricsAggregator::sumSeriesByBucket([
[[1000, 20.0]],
[[1000, 40.0]],
], 'avg');
expect($merged)->toBe([[1000, 30.0]]);
});
it('ranks containers by the selected metric and caps the list', function () {
$containers = [
['id' => 'a', 'cpu' => 10.0, 'memUsed' => 500, 'diskBytes' => 5, 'net' => 1.0],
['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'diskBytes' => 9, 'net' => 2.0],
['id' => 'c', 'cpu' => 50.0, 'memUsed' => 900, 'diskBytes' => 1, 'net' => 3.0],
];
expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'cpu', 2), 'id'))->toBe(['b', 'c']);
expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'memory'), 'id'))->toBe(['c', 'a', 'b']);
expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'network'), 'id'))->toBe(['c', 'b', 'a']);
});
@@ -0,0 +1,69 @@
<?php
use App\Services\FleetMetricsAggregator;
function row(array $overrides = []): array
{
return array_merge([
'uuid' => 'u', 'name' => 'srv', 'online' => 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,
], $overrides);
}
it('weights memory and disk by totals, not mean of percents', function () {
$rows = [
row(['memUsed' => 100, 'memTotal' => 100, 'diskUsed' => 100, 'diskTotal' => 100, 'diskPercent' => 100.0]),
row(['memUsed' => 10, 'memTotal' => 1_000, 'diskUsed' => 10, 'diskTotal' => 1_000, 'diskPercent' => 1.0]),
];
$kpis = FleetMetricsAggregator::fleetKpis($rows);
// Σused/Σtotal = 110/1100 = 10%, not the (100+1)/2 = 50.5% a naive mean gives.
expect(round($kpis['memPercent'], 1))->toBe(10.0);
expect(round($kpis['diskPercent'], 1))->toBe(10.0);
});
it('means cpu, flags approximate for multiple servers, and finds the busiest', function () {
$kpis = FleetMetricsAggregator::fleetKpis([
row(['name' => 'a', 'cpu' => 20.0]),
row(['name' => 'b', 'cpu' => 80.0]),
]);
expect($kpis['cpuAvg'])->toBe(50.0);
expect($kpis['cpuApproximate'])->toBeTrue();
expect($kpis['cpuBusiest'])->toBe(['name' => 'b', 'percent' => 80.0]);
});
it('sums network rates and reports busiest and average load', function () {
$kpis = FleetMetricsAggregator::fleetKpis([
row(['netRx' => 100.0, 'netTx' => 10.0, 'load1' => 0.5]),
row(['netRx' => 200.0, 'netTx' => 20.0, 'load1' => 2.5]),
]);
expect($kpis['netRx'])->toBe(300.0);
expect($kpis['netTx'])->toBe(30.0);
expect($kpis['loadBusiest'])->toBe(2.5);
expect($kpis['loadAvg'])->toBe(1.5);
});
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]),
]);
expect($kpis['serversOnline'])->toBe(1);
expect($kpis['serversTotal'])->toBe(2);
expect($kpis['needsAttention'])->toBe(1);
expect($kpis['cpuAvg'])->toBe(95.0); // offline row excluded from the mean
});
it('returns zeroed kpis for no rows', function () {
$kpis = FleetMetricsAggregator::fleetKpis([]);
expect($kpis['serversOnline'])->toBe(0);
expect($kpis['cpuAvg'])->toBe(0.0);
expect($kpis['cpuBusiest'])->toBeNull();
});
@@ -0,0 +1,181 @@
<?php
use App\Models\Server;
use App\Services\SentinelMetricsClient;
use Illuminate\Support\Carbon;
class FakeMetricsClient extends SentinelMetricsClient
{
/** @var array<string, string> substring => body */
public array $bodies = [];
/** @var array<int, string> */
public array $failing = [];
protected function raw(string $url): string
{
foreach ($this->failing as $needle) {
if (str_contains($url, $needle)) {
throw new RuntimeException('boom');
}
}
foreach ($this->bodies as $needle => $body) {
if (str_contains($url, $needle)) {
return $body;
}
}
return '[]';
}
}
function fakeMetricsClient(): FakeMetricsClient
{
$server = Server::factory()->make(['uuid' => 'srv-uuid']);
return new FakeMetricsClient($server);
}
it('normalizes the host summary, choosing the root disk mount', function () {
$client = fakeMetricsClient();
$client->bodies = [
'/summary' => json_encode([
'cpu' => ['time' => 't', 'percent' => 42.5],
'memory' => ['time' => 't', 'total' => 1000, 'used' => 250, 'usedPercent' => 25.0],
'disk' => [
['time' => 't', 'mount' => '/boot', 'total' => 10, 'used' => 9, 'usedPercent' => 90.0],
['time' => 't', 'mount' => '/', 'total' => 100, 'used' => 40, 'usedPercent' => 40.0],
],
'network' => ['time' => 't', 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 20.0],
'load' => ['time' => 't', 'load1' => 1.5, 'load5' => 1.0, 'load15' => 0.5],
]),
];
$summary = $client->summary();
expect($summary['cpu'])->toBe(42.5);
expect($summary['memUsed'])->toBe(250);
expect($summary['diskPercent'])->toBe(40.0); // root mount, not /boot
expect($summary['netRx'])->toBe(100.0);
expect($summary['load1'])->toBe(1.5);
});
it('returns null summary when the endpoint fails', function () {
$client = fakeMetricsClient();
$client->failing = ['/summary'];
expect($client->summary())->toBeNull();
});
it('normalizes containers, parsing the stringified cpu percent', function () {
$client = fakeMetricsClient();
$client->bodies = [
'/containers/current' => json_encode([
['id' => 'app-a', 'cpu' => ['time' => 't', 'percent' => '12.5'],
'memory' => ['used' => 500, 'usedPercent' => 5.0],
'disk' => ['writableLayer' => 3, 'volumesTotal' => 7],
'network' => ['rxBytesPerSec' => 4.0, 'txBytesPerSec' => 1.0]],
]),
];
$rows = $client->containersCurrent();
expect($rows[0]['id'])->toBe('app-a');
expect($rows[0]['cpu'])->toBe(12.5);
expect($rows[0]['memUsed'])->toBe(500);
expect($rows[0]['diskBytes'])->toBe(10); // writableLayer + volumesTotal
expect($rows[0]['net'])->toBe(5.0); // rx + tx
});
it('returns an empty container list when the endpoint 404s', function () {
$client = fakeMetricsClient();
$client->failing = ['/containers/current'];
expect($client->containersCurrent())->toBe([]);
});
it('averages cpu history into range-aligned buckets', function () {
$client = fakeMetricsClient();
$client->bodies = [
'/cpu/history' => json_encode([
['time' => '1700000000000', 'percent' => '10'],
['time' => '1700000060000', 'percent' => '20'],
['time' => '1700000300000', 'percent' => '40'],
]),
];
// 24h range → 5-minute buckets floored to absolute epoch boundaries.
expect($client->history('cpu', '24h'))->toBe([[1699999800000, 15.0], [1700000100000, 40.0]]);
});
it('aligns buckets across servers whose samples have different timestamps', function () {
$a = fakeMetricsClient();
$a->bodies = ['/cpu/history' => json_encode([['time' => 1700000001234, 'percent' => '10']])];
$b = fakeMetricsClient();
$b->bodies = ['/cpu/history' => json_encode([['time' => 1700000047890, 'percent' => '30']])];
expect(array_column($a->history('cpu', '24h'), 0))->toBe(array_column($b->history('cpu', '24h'), 0));
});
it('keeps long history series small', function () {
$rows = [];
// 24h of raw 20-second samples.
for ($i = 0; $i < 4320; $i++) {
$rows[] = ['time' => 1700000000000 + $i * 20_000, 'percent' => (string) ($i % 100)];
}
$client = fakeMetricsClient();
$client->bodies = ['/cpu/history' => json_encode($rows)];
expect(count($client->history('cpu', '24h')))->toBeLessThanOrEqual(300);
});
it('keeps only the root mount in disk history', function () {
$client = fakeMetricsClient();
$client->bodies = [
'/disk/history' => json_encode([
['time' => 1700000000000, 'mount' => '/', 'usedPercent' => 85],
['time' => 1700000000000, 'mount' => '/boot', 'usedPercent' => 20],
]),
];
expect($client->history('disk', '24h'))->toBe([[1699999800000, 85.0]]);
});
it('rounds the range start to the minute so history responses are cacheable', function () {
Carbon::setTestNow('2026-01-01 12:30:05');
$first = SentinelMetricsClient::rangeFrom('24h');
Carbon::setTestNow('2026-01-01 12:30:45');
expect(SentinelMetricsClient::rangeFrom('24h'))->toBe($first)->toBe('2025-12-31T12:30:00Z');
Carbon::setTestNow();
});
it('does not substitute another mount when root disk history is absent', function () {
$client = fakeMetricsClient();
$client->bodies = ['/disk/history' => json_encode([
['time' => 1700000000000, 'mount' => '/boot', 'usedPercent' => 90],
])];
expect($client->history('disk', '24h'))->toBe([]);
});
it('supports disk history without mount fields', function () {
$client = fakeMetricsClient();
$client->bodies = ['/disk/history' => json_encode([
['time' => 1700000000000, 'usedPercent' => 40],
])];
expect($client->history('disk', '24h'))->toBe([[1699999800000, 40.0]]);
});
it('does not substitute another disk mount in the summary', function () {
$client = fakeMetricsClient();
$client->bodies = ['/summary' => json_encode(['disk' => [
['mount' => '/boot', 'used' => 90, 'total' => 100, 'usedPercent' => 90],
]])];
expect($client->summary()['diskPercent'])->toBeNull();
});