diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php index c660ed49d3..8ec43ee2b8 100644 --- a/app/Livewire/Concerns/BuildsMetricsChartPayload.php +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -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 */ 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 $categories + * @param array> $sparks + * @return array{0: array, 1: array>} + */ + 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 $a + * @param array $b + * @return array + */ + 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 ...$seriesList + * @return array + */ + 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 $series + * @param array $categories + * @return array + */ + 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); } } diff --git a/resources/css/app.css b/resources/css/app.css index a1d5a7fde1..a6b7fa4b17 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php index 55c233996a..4ef75c08be 100644 --- a/resources/views/livewire/fleet/_usage-cell.blade.php +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -7,6 +7,6 @@ {{ $percent === null ? '-' : round($percent).'%' }} diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index f65f4e4319..0ff339a199 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -22,7 +22,7 @@
@foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label) - + $label === 'CPU'])> @endforeach diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 6db9550bc5..b5b0856811 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -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(
+
@@ -57,7 +64,8 @@ $serverListboxOptions = array_merge( @endforeach
@@ -83,53 +91,128 @@ $serverListboxOptions = array_merge( @else {{-- Overview KPIs --}} + helper="{{ $scoped ? 'Current resource usage for the selected server.' : 'Current resource usage across the selected servers.' }}">
Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @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 +
+
+
+ @if ($sHealthy > 0)
@endif + @if ($sAttention > 0)
@endif + @if ($sOffline > 0)
@endif +
+
+ {{ $sHealthy }} healthy + @if ($sAttention > 0){{ $sAttention }} attention@endif + @if ($sOffline > 0){{ $sOffline }} offline@endif +
+
+
- Fleet CPU + {{ $scoped ? 'CPU' : 'Fleet CPU' }} @if ($kpis['cpuApproximate'] ?? false) ~ @endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null) - Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% - @endif +
+ @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', + ]) +
- Fleet memory + {{ $scoped ? 'Memory' : 'Fleet memory' }} {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
+ @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-memory', + 'initial' => $chartData['memSpark'] ?? [], + 'color' => '#8b5cf6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'memSpark', + 'label' => 'Memory used', + 'format' => 'bytes', + ]) +
- Fleet disk + {{ $scoped ? 'Disk' : 'Fleet disk' }} {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
+ @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', + ]) +
- Fleet network + {{ $scoped ? 'Network' : 'Fleet network' }} {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
+ @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-network', + 'initial' => $chartData['netSpark'] ?? [], + 'color' => '#10b981', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'netSpark', + 'label' => 'Network', + 'format' => 'bytes', + ]) +
Containers {{ compactNumber($kpis['containers'] ?? 0) }} +
+
+ @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 + {{ $hottest['name'] }} + hottest, {{ $hottestValue }} + @endif +
+
{{-- 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). --}}
@foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) - + $key === 'cpu'])>
@@ -151,7 +234,8 @@ $serverListboxOptions = array_merge( Containers
@foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) -
+
{{ $row['name'] }} @@ -172,7 +256,7 @@ $serverListboxOptions = array_merge( {{-- Hottest containers --}} + helper="{{ $scoped ? 'Busiest containers on this server, ranked by the selected metric.' : 'Busiest containers across the fleet, ranked by the selected metric.' }}">
@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
+ # Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
@foreach ($topContainers as $c) - @php $meta = $this->containerMeta($c['id']); @endphp -
+ @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 +
+
{{ $loop->iteration }}
@if ($meta['link']) {{ $meta['name'] }} @@ -215,13 +321,12 @@ $serverListboxOptions = array_merge( @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 +
+ + {{ $display }}
@endforeach diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 3592a1528d..edf970ffae 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,30 +59,34 @@ - -
-
+ {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
+ +
+
- -
-
+ +
+
- -
-
+ +
+
- -
-
+ +
+
- -
-
+ +
+
+
@script