start: live server stats over SSE (1 s): CPU, RAM, net, disk IO with 60 s sparklines, per-container CPU/mem/state; one shared netdata poller that runs only while /start is open (owner-only /start/live)
All checks were successful
Deploy production / deploy (push) Successful in 1m29s

This commit is contained in:
2026-10-25 18:49:00 +00:00
parent ce8b545f60
commit c897ae3d14
8 changed files with 683 additions and 26 deletions

View File

@@ -1,10 +1,20 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js';
import { Tween } from 'svelte/motion';
import {
ago,
bytes,
duration,
timerStatus,
type Container,
type LiveFrame,
type LiveHistory
} from '#lib/home.js';
import { linkIcon } from '#lib/link-icons.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Sparkline from '#lib/components/Sparkline.svelte';
import { visibleTrackers } from '#lib/projects.js';
let { data, form } = $props();
@@ -39,9 +49,92 @@
];
const host = $derived(data.host);
const stale = $derived(!host || now - host.at > 3 * 60_000);
const containers = $derived(
(host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
// Live stats: a Server-Sent Events stream (/start/live, owner only) sends a frame every
// second while this tab is visible; the minute-old snapshot stays the fallback. Hidden tab
// = stream closed, so the server stops polling when nobody is looking.
let live = $state<LiveFrame | null>(null);
let hist = $state<LiveHistory>([]);
let liveState = $state<'off' | 'connecting' | 'live' | 'down'>('off');
$effect(() => {
let es: EventSource | null = null;
let retry: ReturnType<typeof setTimeout> | undefined;
let fails = 0;
const open = () => {
clearTimeout(retry);
if (es || document.visibilityState !== 'visible') return;
liveState = 'connecting';
const src = new EventSource('/start/live');
es = src;
src.addEventListener('init', (e) => {
const d = JSON.parse(e.data) as { history: LiveHistory; last: LiveFrame | null };
hist = d.history;
if (d.last) live = d.last;
fails = 0;
liveState = 'live';
});
src.addEventListener('frame', (e) => {
const f = JSON.parse(e.data) as LiveFrame;
live = f;
const { t, cpu, mem, net, io } = f;
hist = [...hist.slice(-59), { t, cpu, mem, net, io }];
liveState = 'live';
});
src.addEventListener('fail', () => (liveState = 'down'));
src.onerror = () => {
// The browser retries by itself unless the stream was refused (e.g. logged out).
if (src.readyState !== EventSource.CLOSED) return void (liveState = 'connecting');
es = null;
liveState = 'down';
retry = setTimeout(open, Math.min(60_000, 3_000 * 2 ** fails++));
};
};
const close = () => {
clearTimeout(retry);
es?.close();
es = null;
liveState = 'off';
};
const onVis = () => (document.visibilityState === 'visible' ? open() : close());
document.addEventListener('visibilitychange', onVis);
open();
return () => {
document.removeEventListener('visibilitychange', onVis);
close();
};
});
const isLive = $derived(liveState === 'live' && !!live);
const stale = $derived(!isLive && (!host || now - host.at > 3 * 60_000));
const cpuNow = $derived(isLive ? live!.cpu : (host?.cpuPct ?? 0));
const memUsed = $derived(isLive ? live!.mem.used : (host?.mem.used ?? 0));
const memTotal = $derived(isLive ? live!.mem.total : (host?.mem.total ?? 1));
const load = $derived(isLive ? live!.load : (host?.load ?? []));
// Numbers glide to the new value instead of jumping.
const ease = { duration: 450 };
const cpuT = Tween.of(() => cpuNow, ease);
const memT = Tween.of(() => memUsed, ease);
const rxT = Tween.of(() => live?.net.rx ?? 0, ease);
const txT = Tween.of(() => live?.net.tx ?? 0, ease);
const rdT = Tween.of(() => live?.io.read ?? 0, ease);
const wrT = Tween.of(() => live?.io.write ?? 0, ease);
type Row = Container & { cpuN: number | null; memN: number | null };
const containers = $derived<Row[]>(
(host?.containers ?? [])
.filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
.map((c) => {
const l = isLive ? live!.containers[c.name] : undefined;
const snap = { cpuN: c.cpu ? parseFloat(c.cpu) || 0 : null, memN: memBytes(c.mem) || null };
if (!l) return { ...c, ...snap };
const state = l.state ?? c.state;
return {
...c,
state,
health: l.state ? l.health : c.health,
cpuN: state === 'running' ? (l.cpu ?? snap.cpuN) : null,
memN: state === 'running' ? (l.mem ?? snap.memN) : null
};
})
);
const containersUp = $derived(containers.filter((c) => c.state === 'running').length);
// Containers grouped by Docker Compose project (com.docker.compose.project label);
@@ -66,7 +159,7 @@
}
const DOT_RANK: Record<string, number> = { off: 0, ok: 1, warn: 2, bad: 3 };
const containerGroups = $derived.by(() => {
const byProject: Record<string, Container[]> = {};
const byProject: Record<string, Row[]> = {};
for (const c of containers) (byProject[c.project || ''] ??= []).push(c);
return Object.entries(byProject)
.map(([key, items]) => {
@@ -82,8 +175,8 @@
dot: running.length
? running.reduce((a, b) => (DOT_RANK[b] > DOT_RANK[a] ? b : a), 'ok')
: 'off',
cpu: items.reduce((n, c) => n + (parseFloat(c.cpu ?? '') || 0), 0),
mem: items.reduce((n, c) => n + memBytes(c.mem), 0)
cpu: items.reduce((n, c) => n + (c.cpuN ?? 0), 0),
mem: items.reduce((n, c) => n + (c.memN ?? 0), 0)
};
})
.sort((a, b) => Number(!a.key) - Number(!b.key) || a.name.localeCompare(b.name));
@@ -110,7 +203,26 @@
);
const timerProblems = $derived(timers.filter((t) => t.status === 'fail' || t.status === 'late'));
const disk = $derived(host?.disks[0]);
const memPct = $derived(host ? (100 * host.mem.used) / host.mem.total : 0);
const memPct = $derived(host || isLive ? (100 * memUsed) / memTotal : 0);
const pct = (n: number | null) =>
n == null ? '' : `${n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : Math.round(n)}%`;
const rate = (bitsPerS: number) => {
const u = ['b/s', 'Kb/s', 'Mb/s', 'Gb/s'];
let i = 0;
while (bitsPerS >= 1000 && i < u.length - 1) {
bitsPerS /= 1000;
i++;
}
return `${bitsPerS >= 100 || i === 0 ? Math.round(bitsPerS) : bitsPerS.toFixed(1)} ${u[i]}`;
};
const C = {
cpu: '#58a6ff',
ram: '#bc8cff',
rx: '#3fb950',
tx: '#58a6ff',
read: '#d29922',
write: '#f778ba'
};
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
const sections = $derived.by(() => {
@@ -337,7 +449,7 @@
</div>
<div class="chips">
{#if host}
<span class={['chip', host.cpuPct > 85 && 'warn']}>CPU {Math.round(host.cpuPct)}%</span>
<span class={['chip', cpuNow > 85 && 'warn']}>CPU {Math.round(cpuNow)}%</span>
<span class={['chip', memPct > 90 && 'warn']}>RAM {Math.round(memPct)}%</span>
{#if disk}<span class={['chip', disk.pct >= 85 && 'bad']}>Disk {Math.round(disk.pct)}%</span
>{/if}
@@ -500,15 +612,53 @@
<h2>
<a href="http://stan-ovh.tail665403.ts.net:19999">Server</a>
<small class={{ bad: stale }}
>{host ? `${host.hostname} · ${ago(host.at, now)}` : 'no snapshot yet'}{stale && host
? ' (stale)'
: ''}</small
>{#if isLive}<span class="live" title="Live: updates every second"><i></i>live</span>
· {host?.hostname ?? ''}{:else}{host
? `${host.hostname} · ${ago(host.at, now)}`
: 'no snapshot yet'}{stale && host
? ' (stale)'
: ''}{#if liveState === 'connecting'}
· connecting…{/if}{/if}</small
>
</h2>
{#if host}
<div class="meters">
{@render meter('CPU', host.cpuPct, `${Math.round(host.cpuPct)}% · ${host.cpus} cores`)}
{@render meter('RAM', memPct, `${bytes(host.mem.used)} / ${bytes(host.mem.total)}`)}
{@render meter(
'CPU',
cpuT.current,
`${cpuT.current.toFixed(1)}% · ${host.cpus} cores`,
[{ values: hist.map((h) => h.cpu), color: C.cpu, fill: true }],
100
)}
{@render meter(
'RAM',
(100 * memT.current) / memTotal,
`${bytes(memT.current)} / ${bytes(memTotal)}`,
[{ values: hist.map((h) => h.mem.used), color: C.ram, fill: true }],
memTotal
)}
{#if isLive}
{@render flow(
'Net',
`↓ ${rate(rxT.current)}`,
`↑ ${rate(txT.current)}`,
[
{ values: hist.map((h) => h.net.rx), color: C.rx, fill: true },
{ values: hist.map((h) => h.net.tx), color: C.tx }
],
[C.rx, C.tx]
)}
{@render flow(
'Disk IO',
`R ${bytes(rdT.current)}/s`,
`W ${bytes(wrT.current)}/s`,
[
{ values: hist.map((h) => h.io.read), color: C.read, fill: true },
{ values: hist.map((h) => h.io.write), color: C.write }
],
[C.read, C.write]
)}
{/if}
{@render meter(
'Swap',
swapPct,
@@ -523,7 +673,7 @@
{/each}
</div>
<p class="sub">
load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
load {load.map((n) => n.toFixed(2)).join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
containers in {containerGroups.length} stacks
</p>
<div class="stacks">
@@ -542,8 +692,8 @@
>{/if}</span
>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num">{c.cpu ?? ''}</span>
<span class="num">{c.mem ? bytes(memBytes(c.mem)) : ''}</span>
<span class="num cpu">{pct(c.cpuN)}</span>
<span class="num mem">{c.memN ? bytes(c.memN) : ''}</span>
</div>
{:else}
<details
@@ -561,8 +711,8 @@
<small class={{ bad: g.up < g.items.length && g.dot !== 'off' }}
>{g.up}/{g.items.length}</small
>
<span class="num">{g.cpu ? `${g.cpu.toFixed(2)}%` : ''}</span>
<span class="num">{g.mem ? bytes(g.mem) : ''}</span>
<span class="num cpu">{g.up ? pct(g.cpu) : ''}</span>
<span class="num mem">{g.mem ? bytes(g.mem) : ''}</span>
</summary>
<ul class="containers">
{#each g.items as c (c.name)}
@@ -572,8 +722,8 @@
<i class="dot {containerDot(c)}"></i>
<span class="name">{label(c)}</span>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num">{c.cpu ?? ''}</span>
<span class="num">{c.mem ? bytes(memBytes(c.mem)) : ''}</span>
<span class="num cpu">{pct(c.cpuN)}</span>
<span class="num mem">{c.memN ? bytes(c.memN) : ''}</span>
</li>
{/each}
</ul>
@@ -689,7 +839,13 @@
</div>
</div>
{#snippet meter(label: string, pct: number, detail: string)}
{#snippet meter(
label: string,
pct: number,
detail: string,
spark?: { values: number[]; color: string; fill?: boolean }[],
max?: number
)}
<div class="meter">
<span class="label">{label}</span>
<span class="bar"
@@ -699,6 +855,31 @@
></i></span
>
<span class="num">{detail}</span>
<span class="sparkcell"
>{#if spark && hist.length > 1}<Sparkline
series={spark}
{max}
label="{label}, last minute"
/>{/if}</span
>
</div>
{/snippet}
{#snippet flow(
label: string,
a: string,
b: string,
spark: { values: number[]; color: string; fill?: boolean }[],
colors: string[]
)}
<div class="meter">
<span class="label">{label}</span>
<span class="num flow"
><span style:--c={colors[0]}>{a}</span><span style:--c={colors[1]}>{b}</span></span
>
<span class="sparkcell"
>{#if hist.length > 1}<Sparkline series={spark} label="{label}, last minute" />{/if}</span
>
</div>
{/snippet}
@@ -982,13 +1163,70 @@
}
.meters {
display: grid;
gap: 5px;
/* one grid for all rows (subgrid), so bars, numbers and sparklines line up */
grid-template-columns: 56px minmax(40px, 1fr) auto 72px;
gap: 5px 8px;
align-items: center;
}
.meter {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 8px;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
min-height: 20px;
}
.meter .label {
white-space: nowrap;
}
.meter .flow {
grid-column: 2 / 4;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.meter .flow > span {
min-width: 11ch;
text-align: right;
}
.meter .flow > span::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background: var(--c);
vertical-align: 1px;
}
.sparkcell {
height: 20px;
}
.sparkcell:has(:global(svg)) {
border-bottom: 1px solid var(--line);
}
.live {
color: var(--ok);
font-weight: 600;
}
.live i {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 4px;
border-radius: 50%;
background: var(--ok);
vertical-align: 1px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.live i {
animation: none;
}
}
.bar {
height: 7px;
@@ -999,6 +1237,9 @@
.bar i {
display: block;
height: 100%;
transition:
width 450ms ease,
background 300ms ease;
}
.bar .ok {
background: var(--ok);
@@ -1117,6 +1358,15 @@
.containers .num {
font-size: 11px;
}
/* fixed-width numbers: values change every second without moving the row */
.stacks .num.cpu {
min-width: 6ch;
text-align: right;
}
.stacks .num.mem {
min-width: 7.5ch;
text-align: right;
}
.dot {
display: inline-block;
flex: none;

View File

@@ -0,0 +1,58 @@
import { requireOwner } from '#lib/server/owner.js';
import { subscribe } from '#lib/server/live-stats.js';
import type { RequestHandler } from './$types';
// Server-Sent Events for /start: one frame a second with host and container stats. Owner
// only (anyone else gets the same 404 as an unknown URL). Each stream ends after 15 minutes
// and the browser's EventSource reconnects, which re-checks the session.
const MAX_MS = 15 * 60_000;
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const enc = new TextEncoder();
let open = true;
let unsubscribe = () => {};
let end: ReturnType<typeof setTimeout> | undefined;
let ctrl: ReadableStreamDefaultController<Uint8Array> | undefined;
const close = () => {
if (!open) return;
open = false;
clearTimeout(end);
unsubscribe();
try {
ctrl?.close();
} catch {
// already closed by the client
}
};
const write = (chunk: string) => {
if (!open || !ctrl) return;
try {
ctrl.enqueue(enc.encode(chunk));
} catch {
close();
}
};
const send = (name: string, data: unknown) =>
write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
ctrl = controller;
event.request.signal.addEventListener('abort', close);
end = setTimeout(close, MAX_MS);
write('retry: 3000\n\n');
const sub = await subscribe(send);
unsubscribe = sub.unsubscribe;
if (!open) return unsubscribe();
send('init', { history: sub.history, last: sub.last });
},
cancel: close
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'private, no-store, no-transform',
'x-accel-buffering': 'no'
}
});
};