feat(stats): inline 30-min sparklines on container CPU + memory bars

Always-visible trend lines next to the existing bars on every running
instance and static-site card so the user can spot a slow drift or recent
spike at a glance, without expanding the full history chart. Implemented
as a tiny pure-SVG component (no ECharts on hot list paths) — values are
fetched once per 30s alongside the current snapshot via a 30m history
query that the collector already serves.

- Sparkline.svelte: pure-SVG polyline + optional area fill, normalises
  values to [0,100] and clamps out-of-range points
- ContainerStats: parallel fetch of stats + 30m history, two new
  $derived arrays for CPU% and memory%, sparklines slotted between the
  bar and the numeric readout
This commit is contained in:
2026-05-07 02:20:06 +03:00
parent 2c109913bd
commit 793570f4a1
2 changed files with 104 additions and 6 deletions
+33 -6
View File
@@ -9,6 +9,7 @@
import { t } from '$lib/i18n';
import { statsInterval } from '$lib/stores/statsInterval';
import ResourceChart from './ResourceChart.svelte';
import Sparkline from './Sparkline.svelte';
import type { EChartsOption } from 'echarts';
export type StatsSource =
@@ -24,6 +25,11 @@
let stats = $state<ContainerStats | null>(null);
let history = $state<ContainerStatsSample[]>([]);
// Short rolling window for the always-visible sparklines. Kept separate
// from `history` (which is sized to `historyWindow` and only loaded on
// expand) so the inline trend doesn't change zoom every time the user
// switches the chart range.
let sparkSamples = $state<ContainerStatsSample[]>([]);
let error = $state(false);
let expanded = $state(false);
@@ -34,17 +40,17 @@
return api.fetchStaticSiteStats(source.siteId, signal);
}
async function fetchHistory(signal: AbortSignal): Promise<ContainerStatsSample[]> {
async function fetchHistory(signal: AbortSignal, win: string = historyWindow): Promise<ContainerStatsSample[]> {
if (source.kind === 'instance') {
return api.fetchInstanceStatsHistory(
source.projectId,
source.stageId,
source.instanceId,
historyWindow,
win,
signal
);
}
return api.fetchStaticSiteStatsHistory(source.siteId, historyWindow, signal);
return api.fetchStaticSiteStatsHistory(source.siteId, win, signal);
}
$effect(() => {
@@ -54,8 +60,15 @@
controller.abort();
controller = new AbortController();
try {
const result = await fetchStats(controller.signal);
const [result, spark] = await Promise.all([
fetchStats(controller.signal),
// Always pull a 30-minute rolling window for the inline
// sparkline. Cheap query and gives the user a trend hint
// without forcing them to expand the full history chart.
fetchHistory(controller.signal, '30m').catch(() => [] as ContainerStatsSample[])
]);
stats = result;
sparkSamples = spark;
error = false;
if (expanded) {
history = await fetchHistory(controller.signal);
@@ -91,6 +104,14 @@
return 'bg-blue-500';
});
// CPU samples normalize to absolute percent (already 0-100 from the
// collector). Memory samples need percent-of-limit so the sparkline
// matches the bar's denominator.
const cpuSpark = $derived(sparkSamples.map((s) => s.cpu_percent));
const memSpark = $derived(
sparkSamples.map((s) => (s.memory_limit > 0 ? (s.memory_usage / s.memory_limit) * 100 : 0))
);
const historyOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 10, bottom: 24, left: 40 },
@@ -139,7 +160,7 @@
{#if stats}
<div class="mt-2 space-y-1">
<!-- CPU bar -->
<!-- CPU bar + 30m sparkline -->
<div class="flex items-center gap-2">
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.cpu')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
@@ -148,11 +169,14 @@
style="width: {Math.min(stats.cpu_percent, 100)}%"
></div>
</div>
<span class="text-emerald-500 dark:text-emerald-400" aria-hidden="true">
<Sparkline values={cpuSpark} width={64} height={14} color="currentColor" fill="rgba(16,185,129,0.18)" ariaLabel={$t('resources.cpuSeries')} />
</span>
<span class="w-10 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
{stats.cpu_percent.toFixed(1)}%
</span>
</div>
<!-- Memory bar -->
<!-- Memory bar + 30m sparkline -->
<div class="flex items-center gap-2">
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.mem')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
@@ -161,6 +185,9 @@
style="width: {Math.min(stats.memory_percent, 100)}%"
></div>
</div>
<span class="text-blue-500 dark:text-blue-400" aria-hidden="true">
<Sparkline values={memSpark} width={64} height={14} color="currentColor" fill="rgba(59,130,246,0.18)" ariaLabel={$t('resources.memorySeries')} />
</span>
<span class="w-24 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
</span>
+71
View File
@@ -0,0 +1,71 @@
<!--
Tiny inline sparkline. Pure SVG so we don't pull ECharts into hot
list-view paths just to draw a 12px-tall trend line. Values are expected
pre-normalized to [0, 100]; out-of-range points clamp.
-->
<script lang="ts">
interface Props {
values: number[];
width?: number;
height?: number;
color?: string;
fill?: string;
ariaLabel?: string;
}
const {
values,
width = 80,
height = 14,
color = 'currentColor',
fill = 'transparent',
ariaLabel = ''
}: Props = $props();
const points = $derived.by(() => {
const n = values.length;
if (n === 0) return '';
if (n === 1) {
const y = height - (clamp(values[0]) / 100) * height;
return `0,${y.toFixed(2)} ${width},${y.toFixed(2)}`;
}
const step = width / (n - 1);
return values
.map((v, i) => {
const x = (i * step).toFixed(2);
const y = (height - (clamp(v) / 100) * height).toFixed(2);
return `${x},${y}`;
})
.join(' ');
});
const fillPath = $derived.by(() => {
if (!values.length || fill === 'transparent') return '';
return `M0,${height} L${points.replace(/,/g, ' ').replace(/ /g, ',')} L${width},${height} Z`
.replace(/L,/, 'L');
});
function clamp(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 100) return 100;
return v;
}
</script>
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
preserveAspectRatio="none"
role="img"
aria-label={ariaLabel}
class="block"
>
{#if values.length > 0}
{#if fill !== 'transparent'}
<polygon points="0,{height} {points} {width},{height}" fill={fill} stroke="none" />
{/if}
<polyline points={points} fill="none" stroke={color} stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" />
{/if}
</svg>