AkurAI Build
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)} &middot; 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>