Menu
Public repository / a7e96191cab7cc0297487b9e56f4e19a4c057957
AkurAI-Mail / src/routes/(admin)/status/+page.svelte
Latest change aa0081daf17648c0a6e369ad847bdc30c0c7d321 - feat(status): realtime process list, animated charts, auto-polling by Ólafur Búi Ólafsson
<script lang="ts">
import { onMount } from "svelte";
let { data } = $props();
let metrics = $state(data.metrics ?? {});
let metricsHistory = $state(data.metricsHistory ?? []);
let services = $state<Record<string, string>>({});
let processes = $state<any[]>([]);
let lastUpdate = $state(Date.now());
let connected = $state(false);
function pct(value: number | undefined) {
return `${Math.round(value ?? 0)}%`;
}
function smoothPoints(key: "cpu" | "memory" | "disk") {
const rows = metricsHistory;
if (rows.length === 0) return "0,54 260,54";
return rows.map((row: any, index: number) => {
const x = rows.length === 1 ? 260 : (index / (rows.length - 1)) * 260;
const y = 58 - Math.max(0, Math.min(100, Number(row[key] ?? 0))) * 0.52;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
}
function areaPath(key: "cpu" | "memory" | "disk") {
const rows = metricsHistory;
if (rows.length === 0) return "";
const pts = rows.map((row: any, index: number) => {
const x = rows.length === 1 ? 260 : (index / (rows.length - 1)) * 260;
const y = 58 - Math.max(0, Math.min(100, Number(row[key] ?? 0))) * 0.52;
return [x, y];
});
return `M${pts[0][0].toFixed(1)},58 ` +
pts.map(p => `L${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ") +
` L${pts[pts.length - 1][0].toFixed(1)},58 Z`;
}
async function poll() {
try {
const res = await fetch("/api/metrics");
if (!res.ok) return;
const d = await res.json();
metrics = d.metrics;
services = d.services ?? {};
processes = d.processes ?? [];
lastUpdate = Date.now();
connected = true;
// Append to local history for charts
if (metrics?.at) {
const entry = { at: metrics.at, cpu: metrics.cpu, memory: metrics.memory?.percent, disk: metrics.disk?.percent };
metricsHistory = [...metricsHistory.slice(-47), entry];
}
} catch {
connected = false;
}
}
onMount(() => {
poll();
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
});
function serviceIcon(status: string) {
return status === "active" ? "up" : "down";
}
function timeSince(ts: number) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
function fmtRss(kb: number) {
if (kb >= 1024) return `${(kb / 1024).toFixed(0)} MB`;
return `${kb} KB`;
}
</script>
<svelte:head><title>VM Status - AkurAI Mail Admin</title></svelte:head>
<header class="topbar">
<div>
<p class="kicker">Infrastructure</p>
<h1>VM Status</h1>
<p class="updated">
<span class="dot" class:live={connected}></span>
Updated {timeSince(lastUpdate)} · polling every 5s
</p>
</div>
</header>
<section class="panel">
<div class="charts">
<article>
<div><span>CPU</span><strong class="val">{pct(metrics?.cpu)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="CPU usage trend">
<path class="base" d="M0 58H260" />
<path class="area" d={areaPath("cpu")} />
<polyline points={smoothPoints("cpu")} />
</svg>
</article>
<article>
<div><span>Memory</span><strong class="val">{pct(metrics?.memory?.percent)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="Memory usage trend">
<path class="base" d="M0 58H260" />
<path class="area" d={areaPath("memory")} />
<polyline points={smoothPoints("memory")} />
</svg>
<p class="sub">{metrics?.memory?.usedMb ?? 0} MB / {metrics?.memory?.totalMb ?? 0} MB</p>
</article>
<article>
<div><span>Disk</span><strong class="val">{pct(metrics?.disk?.percent)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="Disk usage trend">
<path class="base" d="M0 58H260" />
<path class="area" d={areaPath("disk")} />
<polyline points={smoothPoints("disk")} />
</svg>
<p class="sub">{metrics?.disk?.usedGb ?? 0} GB / {metrics?.disk?.totalGb ?? 0} GB</p>
</article>
</div>
</section>
{#if Object.keys(services).length > 0}
<section class="panel">
<h2 class="section-title">Services</h2>
<div class="services">
{#each Object.entries(services) as [name, status]}
<div class="svc" class:active={status === "active"}>
<span class="svc-dot"></span>
<span class="svc-name">{name}</span>
<span class="svc-status">{status}</span>
</div>
{/each}
</div>
</section>
{/if}
{#if processes.length > 0}
<section class="panel">
<h2 class="section-title">Processes</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>PID</th>
<th>User</th>
<th class="num">CPU%</th>
<th class="num">MEM%</th>
<th class="num">RSS</th>
<th>Command</th>
</tr>
</thead>
<tbody>
{#each processes as proc}
<tr>
<td class="mono">{proc.pid}</td>
<td>{proc.user}</td>
<td class="num">{proc.cpu.toFixed(1)}</td>
<td class="num">{proc.mem.toFixed(1)}</td>
<td class="num">{fmtRss(proc.rss)}</td>
<td class="cmd">{proc.command}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
<style>
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;min-width:0}
.kicker{margin:0 0 6px;color:#126b5a;font-size:13px;font-weight:800}
.topbar h1{margin:0;font-size:30px;line-height:1.15;text-wrap:balance}
.updated{margin:6px 0 0;color:#61716c;font-size:13px;display:flex;align-items:center;gap:6px}
.dot{width:8px;height:8px;border-radius:50%;background:#b0b8b5;flex:0 0 auto;transition:background .3s}
.dot.live{background:#22c55e;box-shadow:0 0 6px #22c55e88}
.panel{background:#fff;border:1px solid #dce4e1;border-radius:8px;min-width:0;padding:18px}
.section-title{margin:0 0 14px;font-size:16px;font-weight:700}
.charts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
.charts article{border:1px solid #edf1ef;border-radius:8px;padding:14px;min-width:0}
.charts div{display:flex;justify-content:space-between;gap:12px}
.charts span{color:#61716c}
.val{font-size:24px;transition:all .3s ease}
.charts svg{width:100%;height:76px;margin-top:8px}
.base{stroke:#e1e8e5}
.area{fill:#126b5a12;transition:d .6s ease}
.charts polyline{fill:none;stroke:#126b5a;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;transition:points .6s ease}
.sub{margin:6px 0 0;color:#61716c;font-size:13px}
.services{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px}
.svc{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid #edf1ef;border-radius:6px}
.svc-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;flex:0 0 auto}
.svc.active .svc-dot{background:#22c55e}
.svc-name{font-weight:600;font-size:14px;flex:1}
.svc-status{color:#61716c;font-size:12px}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
thead{position:sticky;top:0;background:#fff}
th{text-align:left;padding:8px 10px;border-bottom:2px solid #e1e8e5;font-weight:700;color:#61716c;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
td{padding:7px 10px;border-bottom:1px solid #f0f3f2}
.num{text-align:right}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.cmd{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#475854}
tbody tr:hover{background:#f8faf9}
@media(max-width:1050px){.charts{grid-template-columns:minmax(0,1fr)}}
@media(max-width:760px){.topbar{display:grid}.topbar h1{font-size:25px}}
@media(max-width:420px){.panel{padding:15px}.topbar h1{font-size:23px}}
</style>