mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(metrics): fleet-wide Metrics page + per-server disk/network/load charts (#11867)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user