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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user