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
All checks were successful
Deploy production / deploy (push) Successful in 1m29s
This commit is contained in:
@@ -13,6 +13,18 @@ every minute (systemd **user** timer `home-collector.timer`) and upserts two row
|
||||
The page reads both rows in the same parallel query batch as tasks and links, so a slow
|
||||
upstream never delays it; a snapshot older than 3 minutes is shown as stale.
|
||||
|
||||
## Live stats (1 s)
|
||||
|
||||
On top of the minute snapshot, `/start` opens a Server-Sent Events stream (`/start/live`,
|
||||
owner only) while the tab is visible. The app reads netdata on the host
|
||||
(`NETDATA_URL`, default `http://100.116.93.104:19999`, the Tailscale-only bind) once a second
|
||||
through one shared poller (`src/lib/server/live-stats.ts`): host CPU, RAM, network, disk IO
|
||||
and per-container CPU/mem/state/health (netdata's docker and cgroups collectors), so the app
|
||||
still never touches the Docker socket. The poller stops 5 s after the last viewer leaves.
|
||||
The app container reaches netdata through one ufw rule:
|
||||
`ufw allow in on br-c1585f0009ac from 172.26.0.0/16 to 100.116.93.104 port 19999 proto tcp`
|
||||
(the `backend` compose network's bridge).
|
||||
|
||||
## Setup
|
||||
|
||||
1. A login role that may only upsert snapshots (created once, cluster superuser):
|
||||
|
||||
@@ -42,6 +42,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
);
|
||||
if (ownerOnly) {
|
||||
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
|
||||
// Keep a stricter value a route set itself (the /start/live stream adds no-transform).
|
||||
if (!response.headers.get('cache-control')?.includes('no-store'))
|
||||
response.headers.set('cache-control', 'private, no-store');
|
||||
}
|
||||
return response;
|
||||
|
||||
58
src/lib/components/Sparkline.svelte
Normal file
58
src/lib/components/Sparkline.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
// A tiny inline sparkline (no axes): the last `slots` samples, newest on the right. Pass
|
||||
// `max` for a fixed scale (e.g. 100 for %), otherwise it scales to the visible peak.
|
||||
type Series = { values: number[]; color: string; fill?: boolean };
|
||||
let {
|
||||
series,
|
||||
max,
|
||||
slots = 60,
|
||||
label = ''
|
||||
}: { series: Series[]; max?: number; slots?: number; label?: string } = $props();
|
||||
|
||||
const W = 100;
|
||||
const H = 20;
|
||||
const top = $derived(
|
||||
max ?? Math.max(1, ...series.flatMap((s) => s.values.slice(-slots).map((v) => v || 0)))
|
||||
);
|
||||
function points(values: number[]) {
|
||||
const v = values.slice(-slots);
|
||||
const off = slots - v.length;
|
||||
return v.map((n, i) => {
|
||||
const x = ((off + i) / (slots - 1)) * W;
|
||||
const y = H - 1 - (Math.min(top, Math.max(0, n || 0)) / top) * (H - 2);
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg class="spark" viewBox="0 0 {W} {H}" preserveAspectRatio="none" role="img" aria-label={label}>
|
||||
{#each series as s, i (i)}
|
||||
{@const p = points(s.values)}
|
||||
{#if p.length > 1}
|
||||
{#if s.fill}
|
||||
<polygon
|
||||
points="{p[0].split(',')[0]},{H} {p.join(' ')} {W},{H}"
|
||||
fill={s.color}
|
||||
fill-opacity="0.14"
|
||||
/>
|
||||
{/if}
|
||||
<polyline
|
||||
points={p.join(' ')}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
stroke-width="1.25"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.spark {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -67,6 +67,29 @@ export type HostSnapshot = {
|
||||
timers: TimerState[];
|
||||
b2: Record<string, unknown> | null;
|
||||
};
|
||||
/** One second of live stats from /start/live (see #lib/server/live-stats). */
|
||||
export type LiveContainer = {
|
||||
/** CPU % of one core (like `docker stats`), memory in bytes. */
|
||||
cpu: number | null;
|
||||
mem: number | null;
|
||||
state: string | null;
|
||||
health: string | null;
|
||||
};
|
||||
export type LiveFrame = {
|
||||
t: number;
|
||||
/** Host CPU % (all cores = 100) and iowait share. */
|
||||
cpu: number;
|
||||
iowait: number;
|
||||
mem: { used: number; total: number };
|
||||
/** Bits per second. */
|
||||
net: { rx: number; tx: number };
|
||||
/** Bytes per second. */
|
||||
io: { read: number; write: number };
|
||||
load: number[];
|
||||
containers: Record<string, LiveContainer>;
|
||||
};
|
||||
export type LiveHistory = Pick<LiveFrame, 't' | 'cpu' | 'mem' | 'net' | 'io'>[];
|
||||
|
||||
export type NtfyMessage = {
|
||||
id: string;
|
||||
topic: string;
|
||||
|
||||
51
src/lib/server/live-stats.test.ts
Normal file
51
src/lib/server/live-stats.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseAllmetrics } from './live-stats.js';
|
||||
|
||||
const d = (o: Record<string, number>) =>
|
||||
Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { name: k, value: v }]));
|
||||
|
||||
describe('parseAllmetrics', () => {
|
||||
it('turns netdata charts into a live frame', () => {
|
||||
const f = parseAllmetrics(
|
||||
{
|
||||
'system.cpu': { dimensions: d({ user: 30, system: 5, iowait: 4, idle: 61 }) },
|
||||
'system.ram': { dimensions: d({ free: 1024, used: 2048, cached: 1024, buffers: 0 }) },
|
||||
'system.net': { dimensions: d({ InOctets: 1000, OutOctets: -500 }) },
|
||||
'system.io': { dimensions: d({ in: 10, out: -20 }) },
|
||||
'system.load': { dimensions: d({ load1: 1.234, load5: 2, load15: 3 }) },
|
||||
'cgroup_web-1.cpu': { dimensions: d({ user: 120, system: 30 }) },
|
||||
'cgroup_web-1.mem_usage': { dimensions: d({ ram: 100, swap: 0 }) },
|
||||
'docker_local.container_web-1_state': {
|
||||
dimensions: d({ running: 1, exited: 0, paused: 0 })
|
||||
},
|
||||
'docker_local.container_web-1_health_status': {
|
||||
dimensions: d({ healthy: 0, not_running_unhealthy: 0, no_healthcheck: 1 })
|
||||
},
|
||||
'cgroup_old-1.cpu': { dimensions: d({ user: 5, system: 0 }) },
|
||||
'docker_local.container_old-1_state': { dimensions: d({ running: 0, exited: 1 }) },
|
||||
'docker_local.container_old-1_health_status': {
|
||||
dimensions: d({ healthy: 0, not_running_unhealthy: 1 })
|
||||
}
|
||||
},
|
||||
1
|
||||
);
|
||||
expect(f.cpu).toBe(39);
|
||||
expect(f.iowait).toBe(4);
|
||||
expect(f.mem).toEqual({ used: 2048 * 1024 * 1024, total: 4096 * 1024 * 1024 });
|
||||
expect(f.net).toEqual({ rx: 1_000_000, tx: 500_000 });
|
||||
expect(f.io).toEqual({ read: 10240, write: 20480 });
|
||||
expect(f.load).toEqual([1.23, 2, 3]);
|
||||
expect(f.containers['web-1']).toEqual({
|
||||
cpu: 150,
|
||||
mem: 100 * 1024 * 1024,
|
||||
state: 'running',
|
||||
health: null
|
||||
});
|
||||
expect(f.containers['old-1']).toEqual({
|
||||
cpu: null,
|
||||
mem: null,
|
||||
state: 'exited',
|
||||
health: 'unhealthy'
|
||||
});
|
||||
});
|
||||
});
|
||||
203
src/lib/server/live-stats.ts
Normal file
203
src/lib/server/live-stats.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Live server stats for /start (CPU, RAM, network, disk IO and per-container CPU/mem/state,
|
||||
// once a second). netdata on the host already samples all of this every second, including
|
||||
// the Docker state/health of every container, so the app never needs the Docker socket: one
|
||||
// shared poller reads netdata's allmetrics (one ~40 KB local request, ~5 ms) and fans the
|
||||
// frame out to every open /start/live stream. It runs only while someone is watching and
|
||||
// stops a few seconds after the last viewer leaves.
|
||||
import type { LiveContainer, LiveFrame, LiveHistory } from '#lib/home.js';
|
||||
|
||||
const NETDATA = (process.env.NETDATA_URL || 'http://100.116.93.104:19999').replace(/\/$/, '');
|
||||
const FILTER = [
|
||||
'system.cpu',
|
||||
'system.ram',
|
||||
'system.net',
|
||||
'system.io',
|
||||
'system.load',
|
||||
'cgroup_*.cpu',
|
||||
'cgroup_*.mem_usage',
|
||||
'docker_local.container_*_state',
|
||||
'docker_local.container_*_health_status'
|
||||
].join(' ');
|
||||
const HISTORY = 60;
|
||||
const GRACE_MS = 5_000;
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
type Dims = Record<string, { name: string; value: number | null }>;
|
||||
type Chart = { dimensions: Dims };
|
||||
|
||||
const dim = (c: Chart | undefined, id: string) => Number(c?.dimensions[id]?.value ?? 0) || 0;
|
||||
const sum = (c: Chart | undefined) =>
|
||||
Object.values(c?.dimensions ?? {}).reduce((n, d) => n + (Number(d.value) || 0), 0);
|
||||
/** The dimension that is 1 in a netdata one-hot chart (container state / health). */
|
||||
const hot = (c: Chart | undefined) =>
|
||||
Object.entries(c?.dimensions ?? {}).find(([, d]) => d.value === 1)?.[0] ?? null;
|
||||
const HEALTH: Record<string, string | null> = {
|
||||
healthy: 'healthy',
|
||||
unhealthy: 'unhealthy',
|
||||
not_running_unhealthy: 'unhealthy',
|
||||
starting: 'starting',
|
||||
no_healthcheck: null
|
||||
};
|
||||
|
||||
async function get(path: string): Promise<unknown> {
|
||||
const r = await fetch(`${NETDATA}${path}`, { signal: AbortSignal.timeout(900) });
|
||||
if (!r.ok) throw new Error(`netdata ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function parseAllmetrics(all: Record<string, Chart>, t = Date.now()): LiveFrame {
|
||||
const cpu = all['system.cpu'];
|
||||
const ram = all['system.ram'];
|
||||
const containers: Record<string, LiveContainer> = {};
|
||||
for (const [id, chart] of Object.entries(all)) {
|
||||
let m = /^cgroup_(.+)\.(cpu|mem_usage)$/.exec(id);
|
||||
if (m) {
|
||||
const c = (containers[m[1]] ??= { cpu: null, mem: null, state: null, health: null });
|
||||
if (m[2] === 'cpu') c.cpu = round(sum(chart), 2);
|
||||
else c.mem = Math.round(dim(chart, 'ram') * MiB);
|
||||
continue;
|
||||
}
|
||||
m = /^docker_local\.container_(.+)_(state|health_status)$/.exec(id);
|
||||
if (m) {
|
||||
const c = (containers[m[1]] ??= { cpu: null, mem: null, state: null, health: null });
|
||||
const v = hot(chart);
|
||||
if (m[2] === 'state') c.state = v;
|
||||
else c.health = v && v in HEALTH ? HEALTH[v] : v;
|
||||
}
|
||||
}
|
||||
// A stopped container keeps a stale cgroup chart for a while; don't show its last CPU.
|
||||
for (const c of Object.values(containers))
|
||||
if (c.state && c.state !== 'running') {
|
||||
c.cpu = null;
|
||||
c.mem = null;
|
||||
}
|
||||
return {
|
||||
t,
|
||||
cpu: cpu ? round(100 - dim(cpu, 'idle'), 1) : 0,
|
||||
iowait: round(dim(cpu, 'iowait'), 1),
|
||||
mem: { used: Math.round(dim(ram, 'used') * MiB), total: Math.round(sum(ram) * MiB) },
|
||||
net: {
|
||||
rx: Math.round(Math.abs(dim(all['system.net'], 'InOctets')) * 1000),
|
||||
tx: Math.round(Math.abs(dim(all['system.net'], 'OutOctets')) * 1000)
|
||||
},
|
||||
io: {
|
||||
read: Math.round(Math.abs(dim(all['system.io'], 'in')) * 1024),
|
||||
write: Math.round(Math.abs(dim(all['system.io'], 'out')) * 1024)
|
||||
},
|
||||
load: ['load1', 'load5', 'load15'].map((d) => round(dim(all['system.load'], d), 2)),
|
||||
containers
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n: number, d: number) => Math.round(n * 10 ** d) / 10 ** d;
|
||||
|
||||
/** The last minute from netdata's own history, so sparklines are full on the first paint. */
|
||||
async function backfill(): Promise<LiveHistory> {
|
||||
const q = (chart: string) =>
|
||||
get(`/api/v1/data?chart=${chart}&after=-${HISTORY}&points=${HISTORY}&format=json`) as Promise<{
|
||||
labels: string[];
|
||||
data: number[][];
|
||||
}>;
|
||||
const [cpu, ram, net, io] = await Promise.all(
|
||||
['system.cpu', 'system.ram', 'system.net', 'system.io'].map(q)
|
||||
);
|
||||
const col = (r: { labels: string[] }, name: string) => r.labels.indexOf(name);
|
||||
const at = (r: { data: number[][] }, t: number) => r.data.find((row) => row[0] === t);
|
||||
const iIdle = col(cpu, 'idle');
|
||||
const iUsed = col(ram, 'used');
|
||||
const out: LiveHistory = [];
|
||||
for (const row of [...cpu.data].sort((a, b) => a[0] - b[0])) {
|
||||
const t = row[0];
|
||||
const r = at(ram, t);
|
||||
const n = at(net, t);
|
||||
const d = at(io, t);
|
||||
if (!r || !n || !d) continue;
|
||||
out.push({
|
||||
t: t * 1000,
|
||||
cpu: round(100 - row[iIdle], 1),
|
||||
mem: {
|
||||
used: Math.round(r[iUsed] * MiB),
|
||||
total: Math.round(r.slice(1).reduce((a, b) => a + b, 0) * MiB)
|
||||
},
|
||||
net: { rx: Math.round(Math.abs(n[1]) * 1000), tx: Math.round(Math.abs(n[2]) * 1000) },
|
||||
io: { read: Math.round(Math.abs(d[1]) * 1024), write: Math.round(Math.abs(d[2]) * 1024) }
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Listener = (event: 'frame' | 'fail', data: unknown) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
let history: LiveHistory = [];
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let stopTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inflight = false;
|
||||
let last: LiveFrame | null = null;
|
||||
|
||||
async function tick() {
|
||||
if (inflight) return; // never pile up requests if netdata is slow
|
||||
inflight = true;
|
||||
try {
|
||||
const all = (await get(
|
||||
`/api/v1/allmetrics?format=json&filter=${encodeURIComponent(FILTER)}`
|
||||
)) as Record<string, Chart>;
|
||||
const frame = parseAllmetrics(all);
|
||||
last = frame;
|
||||
history.push({ t: frame.t, cpu: frame.cpu, mem: frame.mem, net: frame.net, io: frame.io });
|
||||
if (history.length > HISTORY) history = history.slice(-HISTORY);
|
||||
for (const l of listeners) l('frame', frame);
|
||||
} catch (e) {
|
||||
for (const l of listeners) l('fail', { message: e instanceof Error ? e.message : 'failed' });
|
||||
} finally {
|
||||
inflight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopTimer) {
|
||||
clearTimeout(stopTimer);
|
||||
stopTimer = null;
|
||||
}
|
||||
if (timer) return;
|
||||
timer = setInterval(tick, 1000);
|
||||
void tick();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
history = [];
|
||||
last = null;
|
||||
}
|
||||
|
||||
/** Subscribe to frames; returns the current minute of history and an unsubscribe. */
|
||||
export async function subscribe(fn: Listener) {
|
||||
const fresh = !timer;
|
||||
listeners.add(fn);
|
||||
start();
|
||||
if (fresh || history.length < 5) {
|
||||
try {
|
||||
const h = await backfill();
|
||||
const lastT = h.at(-1)?.t ?? 0;
|
||||
if (history.length < h.length) history = h.concat(history.filter((x) => x.t > lastT));
|
||||
} catch {
|
||||
// sparklines just fill up live
|
||||
}
|
||||
}
|
||||
return {
|
||||
history: history.slice(-HISTORY),
|
||||
last,
|
||||
unsubscribe() {
|
||||
listeners.delete(fn);
|
||||
if (!listeners.size && !stopTimer)
|
||||
stopTimer = setTimeout(() => {
|
||||
stopTimer = null;
|
||||
if (!listeners.size) stop();
|
||||
}, GRACE_MS);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** For tests and diagnostics. */
|
||||
export const liveStatus = () => ({ viewers: listeners.size, running: !!timer });
|
||||
@@ -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
|
||||
>{#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)'
|
||||
: ''}</small
|
||||
: ''}{#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;
|
||||
|
||||
58
src/routes/start/live/+server.ts
Normal file
58
src/routes/start/live/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user