diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index c47d282033..e29f8314ae 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,9 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + /** @var array Trend series for the charts, also embedded for first paint. */ + public array $chartData = []; + public ?string $lastUpdatedAt = null; /** @var array */ @@ -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); } /** diff --git a/resources/css/app.css b/resources/css/app.css index a8eef8e2d7..a1d5a7fde1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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); } diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 0722bdd076..2861a4650d 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -99,7 +99,7 @@ - + Metrics diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index aee0488f17..f67be9c225 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -1,3 +1,18 @@
- +
+

Metrics

+

Resource usage across your servers.

+
+ + + +
+ @foreach (['CPU', 'Memory used'] as $label) +
+ {{ $label }} + +
+ @endforeach +
diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index f3a5857126..514ff66d4e 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -19,12 +19,12 @@
@foreach (['24h', '7d', '30d'] as $r) + 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 }} @endforeach
@endif @@ -33,7 +33,7 @@ @if ($servers->isEmpty()) - + @else @@ -43,13 +43,15 @@ Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} needs attention + {{ $kpis['needsAttention'] }} need attention + @else + All healthy @endif
- Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif + @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif
Fleet memory @@ -64,20 +66,21 @@
Fleet network {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s ยท tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s
Containers {{ compactNumber($kpis['containers'] ?? 0) }} + running
{{-- Trend charts --}}
- @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)
-
+
@endforeach @@ -85,31 +88,34 @@ {{-- Servers table --}} - - - - - - - - @foreach (collect($serverRows)->sortByDesc('cpu') as $row) - - - - - - - - - - @endforeach - -
ServerCPUMemoryDiskLoadNetworkContainers
- {{ $row['name'] }} - @unless ($row['online']) - - @endunless - @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
+
+
+ Server + CPU + Memory + Disk + Load + Network + Containers +
+ @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) +
+
+ {{ $row['name'] }} + @unless ($row['online']) + Offline + @endunless +
+
@include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])
+
@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])
+
@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])
+
{{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}
+
{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}
+
{{ $row['containers'] ?? '-' }}
+
+ @endforeach +
{{-- Hottest containers --}} @@ -118,32 +124,42 @@
@foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + 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 }} @endforeach
@if (empty($topContainers)) - +
+ +
@else - - - - @foreach ($topContainers as $c) - - - - - - @endforeach - -
ContainerServer{{ ucfirst($containerMetric) }}
@if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} - @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 -
+
+
+ Container + Server + {{ ucfirst($containerMetric) }} +
+ @foreach ($topContainers as $c) +
+
+ @if ($c['link']) + {{ $c['name'] }} + @else + {{ $c['name'] }} + @endif +
+
{{ $c['server'] }}
+
+ @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 +
+
+ @endforeach +
@endif @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); + } }); })(); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index ee10acade2..789694fb12 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -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'); });