fix(metrics): div-grid tables, chart initial render, metrics skeleton, graph icon

This commit is contained in:
Aditya Tripathi
2026-09-16 08:39:14 +00:00
parent fbce977dbf
commit 9fced09bd1
6 changed files with 123 additions and 82 deletions
+5 -1
View File
@@ -46,6 +46,9 @@ class Metrics extends Component
/** @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 = [];
public ?string $lastUpdatedAt = null;
/** @var array<string, array{name: string, link: ?string}> */
@@ -164,9 +167,10 @@ class Metrics extends Component
$this->serverRows = $rows;
$this->kpis = FleetMetricsAggregator::fleetKpis($rows);
$this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric);
$this->chartData = $this->buildChartPayload($series);
$this->lastUpdatedAt = now()->toIso8601String();
$this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series));
$this->dispatch("refreshChartData-{$this->chartId}", $this->chartData);
}
/**
+8
View File
@@ -3183,6 +3183,14 @@ 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: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem;
}
.scheduled-executions-table-grid {
grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr);
}
+1 -1
View File
@@ -99,7 +99,7 @@
<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="cpu" class="menu-item-icon" />
<x-reicon name="graph" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Metrics</span>
</a>
</li>
@@ -1,3 +1,18 @@
<div class="flex flex-col gap-6">
<x-skeleton.tiles />
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-semibold text-black dark:text-fg">Metrics</h1>
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">Resource usage across your servers.</p>
</div>
<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']" />
<div class="grid gap-6 lg:grid-cols-2">
@foreach (['CPU', 'Memory used'] as $label)
<div class="rounded-lg bg-[var(--coollabs-elevated)] p-4 ring-1 ring-[var(--coollabs-hairline)]">
<span class="text-[13px] font-medium text-black dark:text-fg">{{ $label }}</span>
<x-skeleton class="mt-3 h-[220px] w-full rounded" />
</div>
@endforeach
</div>
</div>
@@ -19,12 +19,12 @@
<div class="flex rounded-md bg-[var(--coollabs-recessed)] p-0.5">
@foreach (['24h', '7d', '30d'] as $r)
<button type="button" wire:click="setRange('{{ $r }}')"
class="{{ $range === $r ? 'bg-[var(--coollabs-base)] text-black dark:text-fg' : 'text-neutral-500' }} rounded px-2.5 py-1 text-[12px] font-medium">{{ $r }}</button>
class="{{ $range === $r ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $r }}</button>
@endforeach
</div>
<button type="button" wire:click="refresh" title="Refresh"
class="flex size-8 items-center justify-center rounded-md bg-[var(--coollabs-recessed)] text-neutral-500 hover:text-black dark:hover:text-fg">
<x-reicon name="refresh" class="size-4" />
class="flex size-8 items-center justify-center rounded-md bg-[var(--coollabs-recessed)] text-neutral-500 transition-colors hover:text-black dark:hover:text-fg">
<x-reicon name="refresh" class="size-4" wire:loading.class="animate-spin" wire:target="refresh" />
</button>
</div>
@endif
@@ -33,7 +33,7 @@
@if ($servers->isEmpty())
<x-empty title="No server metrics yet" description="Enable metrics on a server to see fleet resource usage here.">
<x-slot:icon>
<x-reicon name="cpu" class="size-8" />
<x-reicon name="graph" class="size-8" />
</x-slot:icon>
</x-empty>
@else
@@ -43,13 +43,15 @@
<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 }}/{{ $kpis['serversTotal'] ?? 0 }}</span>
@if (($kpis['needsAttention'] ?? 0) > 0)
<span class="mt-auto pt-3 text-[11px] text-orange-500 dark:text-warning">{{ $kpis['needsAttention'] }} needs attention</span>
<span class="mt-auto pt-3 text-[11px] font-medium text-orange-500 dark:text-warning">{{ $kpis['needsAttention'] }} need attention</span>
@else
<span class="mt-auto pt-3 text-[11px] text-emerald-600 dark:text-emerald-400">All healthy</span>
@endif
</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 CPU @if ($kpis['cpuApproximate'] ?? false)<span title="Averaged across servers; not core-weighted">~</span>@endif</span>
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Fleet CPU @if ($kpis['cpuApproximate'] ?? false)<span class="cursor-help" 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>
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">@if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif</span>
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">@if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif</span>
</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>
@@ -64,20 +66,21 @@
<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="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">rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s</span>
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s</span>
</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>
<span class="mt-auto pt-3 text-[11px] text-neutral-500 dark:text-fg-dim">running</span>
</div>
</div>
{{-- Trend charts --}}
<div class="grid gap-6 lg:grid-cols-2">
@foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label)
@foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label)
<x-application.settings-section :title="$label">
<div wire:ignore>
<div id="fleet-chart-{{ $key }}" class="min-h-[240px]"></div>
<div id="fleet-chart-{{ $key }}" class="min-h-[240px] w-full"></div>
</div>
</x-application.settings-section>
@endforeach
@@ -85,31 +88,34 @@
{{-- Servers table --}}
<x-application.settings-section title="Servers" flush>
<table class="data-table">
<thead>
<tr class="data-table-header">
<th>Server</th><th>CPU</th><th>Memory</th><th>Disk</th><th>Load</th><th>Network</th><th>Containers</th>
</tr>
</thead>
<tbody>
@foreach (collect($serverRows)->sortByDesc('cpu') as $row)
<tr class="data-table-row">
<td>
<a href="{{ route('server.metrics', ['server_uuid' => $row['uuid']]) }}" class="hover:underline">{{ $row['name'] }}</a>
@unless ($row['online'])
<x-status-badge status="Offline" type="warning" />
@endunless
</td>
<td>@include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])</td>
<td>@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])</td>
<td>@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])</td>
<td class="tabular-nums">{{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}</td>
<td class="tabular-nums">{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}</td>
<td class="tabular-nums">{{ $row['containers'] ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
<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 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 --}}
@@ -118,32 +124,42 @@
<div class="flex rounded-md bg-[var(--coollabs-recessed)] p-0.5">
@foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel)
<button type="button" wire:click="$set('containerMetric', '{{ $m }}')"
class="{{ $containerMetric === $m ? 'bg-[var(--coollabs-base)] text-black dark:text-fg' : 'text-neutral-500' }} rounded px-2.5 py-1 text-[12px] font-medium">{{ $mlabel }}</button>
class="{{ $containerMetric === $m ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $mlabel }}</button>
@endforeach
</div>
</x-slot:actions>
@if (empty($topContainers))
<x-empty size="sm" title="No container metrics" description="Container metrics need a Sentinel build with the bulk endpoints." />
<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
<table class="data-table">
<thead><tr class="data-table-header"><th>Container</th><th>Server</th><th>{{ ucfirst($containerMetric) }}</th></tr></thead>
<tbody>
@foreach ($topContainers as $c)
<tr class="data-table-row">
<td>@if ($c['link'])<a href="{{ $c['link'] }}" class="hover:underline">{{ $c['name'] }}</a>@else{{ $c['name'] }}@endif</td>
<td class="text-neutral-500 dark:text-fg-dim">{{ $c['server'] }}</td>
<td class="tabular-nums">
@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
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="data-table">
<div class="data-table-header fleet-containers-table-grid">
<span>Container</span>
<span>Server</span>
<span class="text-right">{{ ucfirst($containerMetric) }}</span>
</div>
@foreach ($topContainers as $c)
<div class="data-table-row fleet-containers-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="min-w-0 truncate text-[13px] font-medium text-black dark:text-fg">
@if ($c['link'])
<a href="{{ $c['link'] }}" class="hover:underline" {{ wireNavigate() }}>{{ $c['name'] }}</a>
@else
{{ $c['name'] }}
@endif
</div>
<div class="min-w-0 truncate text-[12px] text-neutral-500 dark:text-fg-dim">{{ $c['server'] }}</div>
<div class="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>
</div>
@endforeach
</div>
@endif
</x-application.settings-section>
@endif
@@ -156,7 +172,7 @@
const axisColor = (typeof textColor !== 'undefined') ? textColor : 'rgba(128,128,128,0.7)';
const fmtPercent = v => `${Number(Number(v).toFixed(1))}%`;
const fmtNumber = v => Number(v).toLocaleString();
const fmtNumber = v => Number(Number(v).toFixed(2)).toLocaleString();
const fmtBytes = v => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = Number(v) || 0;
@@ -166,34 +182,37 @@
};
const fmtRate = v => `${fmtBytes(v)}/s`;
const baseOptions = (colors, formatter) => ({
chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent' },
series: [],
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: false },
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: { x: { format: 'yyyy-MM-dd HH:mm' } },
});
const specs = {
cpu: { colors: ['#3b82f6'], formatter: fmtPercent },
memory: { colors: ['#8b5cf6'], formatter: fmtBytes },
network: { colors: ['#10b981', '#f59e0b'], formatter: fmtRate },
load: { colors: ['#14b8a6'], formatter: fmtNumber },
disk: { colors: ['#ef4444'], formatter: fmtPercent },
};
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.colors, spec.formatter));
const chart = new ApexCharts(el, baseOptions(spec.series, spec.colors, spec.formatter));
chart.render();
charts[key] = chart;
}
@@ -202,15 +221,10 @@
if (typeof checkTheme === 'function') { checkTheme(); }
const data = Array.isArray(payload) ? payload[0] : payload;
if (! data) { return; }
charts.cpu?.updateSeries([{ name: 'CPU', data: data.cpu || [] }]);
charts.memory?.updateSeries([{ name: 'Memory', data: data.memory || [] }]);
charts.disk?.updateSeries([{ name: 'Disk', data: data.disk || [] }]);
charts.load?.updateSeries([{ name: 'Load', data: data.load || [] }]);
charts.network?.updateSeries([
{ name: 'RX', data: data.networkRx || [] },
{ name: 'TX', data: data.networkTx || [] },
]);
const next = build(data);
for (const [key, chart] of Object.entries(charts)) {
chart.updateSeries(next[key].series);
}
});
})();
</script>
@@ -130,7 +130,7 @@ 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('needs attention')
->assertSee('need attention')
->assertSee('Offline');
});