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:
Aditya Tripathi
2026-09-17 06:30:59 +00:00
parent 701c81b28f
commit ab436942c9
9 changed files with 378 additions and 67 deletions
@@ -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);
}
}
+1 -1
View File
@@ -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
+132 -27
View File
@@ -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">&darr;{{ formatBytes($kpis['netRx'] ?? 0) }} &uarr;{{ 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');
});