mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(metrics): polish fleet + server metrics UI/UX
Refine the /metrics fleet page and align the per-server metrics page with it. Fleet overview: - add live sparklines to the CPU/Memory/Disk/Network KPI tiles (shared axis, capped to ~40 points per sparkline) - fill the Servers online + Containers tiles (fleet-health bar, hottest container) so all six tiles read as one system - scope-aware labels: drop the "Fleet" prefix and helper copy when a single server is selected Trend charts: - hero layout: CPU spans full width, the other four form a 2x2 grid - apply the same hero layout + skeleton to the per-server page for consistency Hottest containers table: - add a rank column and a per-row magnitude bar scaled to the busiest container - fix a leaked Blade @break directive that rendered literally and forced horizontal scroll on the Network tab - keep the value column a fixed width so bars align across rows Live refresh: - add analytics-style loading feedback to the range tabs and server filter - stable wire:key on table rows + width transitions on bars so data glides on live refresh instead of snapping Generalize the shared _sparkline partial (optional color/label/format) so it serves non-traffic metrics without changing analytics behaviour.
This commit is contained in:
@@ -6,20 +6,130 @@ 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' => 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'),
|
||||
];
|
||||
'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>> $sparks
|
||||
* @return array{0: array<int, int>, 1: array<string, array<int, float|int>>}
|
||||
*/
|
||||
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, filling gaps with 0.
|
||||
*
|
||||
* @param array<int, array{0: int, 1: float}> $series
|
||||
* @param array<int, int> $categories
|
||||
* @return array<int, float|int>
|
||||
*/
|
||||
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] ?? 0, $categories);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3188,7 +3188,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.fleet-containers-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem;
|
||||
grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr);
|
||||
}
|
||||
|
||||
.scheduled-executions-table-grid {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
{{ $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 {{ \App\Support\Metrics\PressureLevel::barClass($level) }}" style="width: {{ $width }}%;"></div>
|
||||
<div class="h-full rounded-full transition-[width] duration-700 ease-out {{ \App\Support\Metrics\PressureLevel::barClass($level) }}" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<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">
|
||||
<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
|
||||
|
||||
@@ -3,6 +3,9 @@ $tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-m
|
||||
$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(),
|
||||
@@ -27,6 +30,10 @@ $serverListboxOptions = array_merge(
|
||||
<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" />
|
||||
<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">
|
||||
@@ -57,7 +64,8 @@ $serverListboxOptions = array_merge(
|
||||
<button type="button" wire:click="setRange('{{ $value }}')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === $value ? $tabButtonActive : $tabButtonInactive])>
|
||||
{{ $label }}
|
||||
<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>
|
||||
@@ -83,53 +91,128 @@ $serverListboxOptions = array_merge(
|
||||
@else
|
||||
{{-- Overview KPIs --}}
|
||||
<x-application.settings-section id="metrics-overview-section" title="Overview"
|
||||
helper="Current resource usage across the selected servers.">
|
||||
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>
|
||||
@if (($kpis['needsAttention'] ?? 0) > 0)
|
||||
<span class="mt-auto pt-3 text-[11px] font-medium text-orange-500 dark:text-warning">{{ $kpis['needsAttention'] }} need attention</span>
|
||||
@endif
|
||||
@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">
|
||||
Fleet CPU
|
||||
{{ $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>
|
||||
@if ($kpis['cpuBusiest'] ?? null)
|
||||
<span class="mt-auto truncate pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%</span>
|
||||
@endif
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-cpu',
|
||||
'initial' => $chartData['cpuSpark'] ?? [],
|
||||
'color' => '#3b82f6',
|
||||
'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">Fleet memory</span>
|
||||
<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>
|
||||
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-memory',
|
||||
'initial' => $chartData['memSpark'] ?? [],
|
||||
'color' => '#8b5cf6',
|
||||
'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">Fleet disk</span>
|
||||
<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>
|
||||
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-disk',
|
||||
'initial' => $chartData['diskSpark'] ?? [],
|
||||
'color' => '#ef4444',
|
||||
'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">Fleet network</span>
|
||||
<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>
|
||||
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-network',
|
||||
'initial' => $chartData['netSpark'] ?? [],
|
||||
'color' => '#10b981',
|
||||
'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">
|
||||
<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>
|
||||
@@ -151,7 +234,8 @@ $serverListboxOptions = array_merge(
|
||||
<span class="text-right">Containers</span>
|
||||
</div>
|
||||
@foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row)
|
||||
<div class="data-table-row fleet-servers-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<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>
|
||||
@@ -172,7 +256,7 @@ $serverListboxOptions = array_merge(
|
||||
|
||||
{{-- Hottest containers --}}
|
||||
<x-application.settings-section title="Hottest containers" flush
|
||||
helper="Top containers across the fleet by the selected metric.">
|
||||
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"
|
||||
@@ -194,16 +278,38 @@ $serverListboxOptions = array_merge(
|
||||
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']); @endphp
|
||||
<div class="data-table-row fleet-containers-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
@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['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>
|
||||
@@ -215,13 +321,12 @@ $serverListboxOptions = array_merge(
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 self-center truncate text-[12px] text-neutral-500 dark:text-fg-dim">{{ $c['server'] }}</div>
|
||||
<div class="self-center text-right text-[13px] tabular-nums text-black dark:text-fg">
|
||||
@switch($containerMetric)
|
||||
@case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break
|
||||
@case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break
|
||||
@case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break
|
||||
@default{{ round($c['cpu'] ?? 0) }}%
|
||||
@endswitch
|
||||
<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
|
||||
|
||||
@@ -59,30 +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-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>
|
||||
<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-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-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>
|
||||
@@ -239,11 +243,15 @@
|
||||
chartOptions('Load', '#14b8a6', 'Loading load metrics…', formatNumber),
|
||||
);
|
||||
|
||||
cpuChart.render();
|
||||
memoryChart.render();
|
||||
diskChart.render();
|
||||
networkChart.render();
|
||||
loadChart.render();
|
||||
// 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();
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
@php
|
||||
$initial = $initial ?? [];
|
||||
$colorVar = $colorVar ?? '--chart-status-3xx';
|
||||
$color = $color ?? '';
|
||||
$event = $event ?? '';
|
||||
$key = $key ?? '';
|
||||
$initialCategories = array_column($series ?? [], 'bucket');
|
||||
$label = match ($key) {
|
||||
$label = $label ?? match ($key) {
|
||||
'requestsSpark' => 'Requests',
|
||||
'uniquesSpark' => 'Visitors',
|
||||
'bandwidthSpark' => 'Bandwidth',
|
||||
@@ -31,12 +32,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 +74,16 @@
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
const formatValue = value => {
|
||||
if (@js($key) === 'bandwidthSpark') {
|
||||
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, {
|
||||
|
||||
@@ -44,6 +44,23 @@ class ChartTestableMetrics extends Metrics
|
||||
}
|
||||
}
|
||||
|
||||
class DenseChartStubClient extends ChartStubClient
|
||||
{
|
||||
public function history(string $metric, string $from): 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 () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$user = User::factory()->create();
|
||||
@@ -71,3 +88,41 @@ it('dispatches a chart payload with every trend series', function () {
|
||||
&& 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,
|
||||
// filling gaps with 0 (network only has the 1000 bucket → tail is 0).
|
||||
return $payload['sparkCategories'] == [1000, 2000]
|
||||
&& $payload['cpuSpark'] == [10, 20]
|
||||
&& $payload['memSpark'] == [10, 20]
|
||||
&& $payload['diskSpark'] == [10, 20]
|
||||
&& $payload['netSpark'] == [6, 0];
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,8 @@ it('shows the KPI tiles, a stale badge for offline servers, and the container to
|
||||
->assertSee('web-1')
|
||||
->assertSee('web-2')
|
||||
->assertSee('Fleet CPU')
|
||||
->assertSee('need attention')
|
||||
->assertSee('healthy')
|
||||
->assertSee('attention')
|
||||
->assertSee('Offline');
|
||||
});
|
||||
|
||||
@@ -164,3 +165,27 @@ it('cannot see another team’s servers', function () {
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user