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 { t } from '$lib/i18n';
|
||||||
import { statsInterval } from '$lib/stores/statsInterval';
|
import { statsInterval } from '$lib/stores/statsInterval';
|
||||||
import ResourceChart from './ResourceChart.svelte';
|
import ResourceChart from './ResourceChart.svelte';
|
||||||
|
import Sparkline from './Sparkline.svelte';
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
export type StatsSource =
|
export type StatsSource =
|
||||||
@@ -24,6 +25,11 @@
|
|||||||
|
|
||||||
let stats = $state<ContainerStats | null>(null);
|
let stats = $state<ContainerStats | null>(null);
|
||||||
let history = $state<ContainerStatsSample[]>([]);
|
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 error = $state(false);
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
|
|
||||||
@@ -34,17 +40,17 @@
|
|||||||
return api.fetchStaticSiteStats(source.siteId, signal);
|
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') {
|
if (source.kind === 'instance') {
|
||||||
return api.fetchInstanceStatsHistory(
|
return api.fetchInstanceStatsHistory(
|
||||||
source.projectId,
|
source.projectId,
|
||||||
source.stageId,
|
source.stageId,
|
||||||
source.instanceId,
|
source.instanceId,
|
||||||
historyWindow,
|
win,
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return api.fetchStaticSiteStatsHistory(source.siteId, historyWindow, signal);
|
return api.fetchStaticSiteStatsHistory(source.siteId, win, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -54,8 +60,15 @@
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
try {
|
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;
|
stats = result;
|
||||||
|
sparkSamples = spark;
|
||||||
error = false;
|
error = false;
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
history = await fetchHistory(controller.signal);
|
history = await fetchHistory(controller.signal);
|
||||||
@@ -91,6 +104,14 @@
|
|||||||
return 'bg-blue-500';
|
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>({
|
const historyOption = $derived<EChartsOption>({
|
||||||
animation: false,
|
animation: false,
|
||||||
grid: { top: 8, right: 10, bottom: 24, left: 40 },
|
grid: { top: 8, right: 10, bottom: 24, left: 40 },
|
||||||
@@ -139,7 +160,7 @@
|
|||||||
|
|
||||||
{#if stats}
|
{#if stats}
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
<!-- CPU bar -->
|
<!-- CPU bar + 30m sparkline -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.cpu')}</span>
|
<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)]">
|
<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)}%"
|
style="width: {Math.min(stats.cpu_percent, 100)}%"
|
||||||
></div>
|
></div>
|
||||||
</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)]">
|
<span class="w-10 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
|
||||||
{stats.cpu_percent.toFixed(1)}%
|
{stats.cpu_percent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Memory bar -->
|
<!-- Memory bar + 30m sparkline -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.mem')}</span>
|
<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)]">
|
<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)}%"
|
style="width: {Math.min(stats.memory_percent, 100)}%"
|
||||||
></div>
|
></div>
|
||||||
</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)]">
|
<span class="w-24 text-right text-[10px] tabular-nums text-[var(--text-tertiary)]">
|
||||||
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
|
{formatBytes(stats.memory_usage)} / {formatBytes(stats.memory_limit)}
|
||||||
</span>
|
</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