fix(ui): move chart legends to the card header with last values

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 12:49:23 +02:00
co-authored by Claude Opus 5.5
parent 0ad88d8c63
commit 6f8d51008e
4 changed files with 83 additions and 2 deletions
+24
View File
@@ -402,6 +402,30 @@
editorTheme = null
}
}
// Chart legend in a card header: one entry per series with its last known value.
// items: [{ name, color, data: [[timestamp, value], ...], formatter }]
function renderChartLegend(element, items) {
if (!element) return;
element.replaceChildren(...items.map(({ name, color, data, formatter }) => {
const last = (data || []).findLast(point => point?.[1] !== null && point?.[1] !== undefined);
const item = document.createElement('span');
item.className = 'inline-flex items-center gap-1.5';
const dot = document.createElement('span');
dot.className = 'size-2 shrink-0 rounded-full';
dot.style.backgroundColor = color;
const label = document.createElement('span');
label.className = 'text-neutral-500 dark:text-fg-dim';
label.textContent = name;
item.append(dot, label);
if (last) {
const value = document.createElement('span');
value.className = 'font-medium tabular-nums text-black dark:text-fg';
value.textContent = formatter(last[1]);
item.append(value);
}
return item;
}));
}
@auth
window.Pusher = Pusher;
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
@@ -231,6 +231,9 @@ $serverListboxOptions = array_merge(
<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" @class(['lg:col-span-2' => $key === 'cpu'])>
<x-slot:actions>
<div wire:ignore id="fleet-chart-{{ $key }}-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<div wire:ignore>
<div id="fleet-chart-{{ $key }}" class="min-h-[240px] w-full"></div>
</div>
@@ -421,7 +424,7 @@ $serverListboxOptions = array_merge(
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: series.length > 1, labels: { colors: axisColor } },
legend: { show: false },
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 } },
@@ -448,6 +451,15 @@ $serverListboxOptions = array_merge(
charts[key] = chart;
}
const renderLegends = specs => {
for (const [key, spec] of Object.entries(specs)) {
renderChartLegend(document.getElementById(`fleet-chart-${key}-legend`), spec.series.map((series, i) => ({
name: series.name, color: spec.colors[i], data: series.data, formatter: spec.formatter,
})));
}
};
renderLegends(specs);
Livewire.on('refreshChartData-{!! $chartId !!}', payload => {
if (typeof checkTheme === 'function') { checkTheme(); }
const data = Array.isArray(payload) ? payload[0] : payload;
@@ -456,6 +468,7 @@ $serverListboxOptions = array_merge(
for (const [key, chart] of Object.entries(charts)) {
chart.updateSeries(next[key].series);
}
renderLegends(next);
});
});
})();
@@ -63,6 +63,9 @@
<x-application.settings-section id="cpu-metrics-section" title="CPU usage"
helper="Percentage of available CPU capacity used by the application container.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-cpu-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-cpu" class="min-h-[240px] w-full"></div>
@script
@@ -206,6 +209,12 @@
},
},
});
renderChartLegend(document.getElementById('{!! $chartId !!}-cpu-legend'), [{
name: 'CPU',
color: cpuColor,
data: chartData[0].seriesData,
formatter: value => `${Number(value.toFixed(Math.abs(value) < 1 ? 2 : 1))}%`,
}]);
});
})();
</script>
@@ -214,6 +223,9 @@
<x-application.settings-section id="memory-metrics-section" title="Memory usage"
helper="Memory consumed by the application container, measured in megabytes.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-memory-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-memory" class="min-h-[240px] w-full"></div>
@script
@@ -349,6 +361,12 @@
},
},
});
renderChartLegend(document.getElementById('{!! $chartId !!}-memory-legend'), [{
name: 'Memory',
color: ramColor,
data: chartData[0].seriesData,
formatter: value => `${Math.round(value)} MB`,
}]);
});
})();
</script>
@@ -64,26 +64,41 @@
<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.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-cpu-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<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.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-memory-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-memory" 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.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-network-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<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.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-load-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<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.">
<x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-disk-legend" class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs"></div>
</x-slot:actions>
<div wire:ignore id="{!! $chartId !!}-disk" class="min-h-[240px] w-full"></div>
</x-application.settings-section>
</div>
@@ -235,7 +250,6 @@
{
...chartOptions('Network', '#10b981', 'Loading network metrics…', formatRate),
colors: ['#10b981', '#f59e0b'],
legend: { show: true, labels: { colors: textColor } },
},
);
const loadChart = new ApexCharts(
@@ -243,6 +257,9 @@
chartOptions('Load', '#14b8a6', 'Loading load metrics…', formatNumber),
);
const renderLegend = (key, items) => renderChartLegend(
document.getElementById(`{!! $chartId !!}-${key}-legend`), items);
// Defer to the next frame so the 2x2 grid cells are laid out
// before ApexCharts measures them (a zero-width cell renders blank).
requestAnimationFrame(() => {
@@ -331,6 +348,15 @@
{ name: 'TX', data: data.networkTxSeries || [] },
]);
loadChart.updateSeries([{ name: 'Load', data: data.loadSeries || [] }]);
renderLegend('cpu', [{ name: 'CPU', color: cpuColor, data: data.cpuSeries, formatter: formatPercent }]);
renderLegend('memory', [{ name: 'Memory', color: ramColor, data: data.memorySeries, formatter: formatPercent }]);
renderLegend('network', [
{ name: 'RX', color: '#10b981', data: data.networkRxSeries, formatter: formatRate },
{ name: 'TX', color: '#f59e0b', data: data.networkTxSeries, formatter: formatRate },
]);
renderLegend('load', [{ name: 'Load', color: '#14b8a6', data: data.loadSeries, formatter: formatNumber }]);
renderLegend('disk', [{ name: 'Disk', color: '#ef4444', data: data.diskSeries, formatter: formatPercent }]);
});
})();
</script>