feat(webui): overview runtime dashboard

This commit is contained in:
xiaoxixi 2026-07-26 21:32:54 +08:00
parent 6e719e7fcb
commit 0b02fd580b

View File

@ -0,0 +1,198 @@
<script>
import { onMount } from "svelte";
import { api, formatTime } from "../lib/api.js";
import MetricTile from "../lib/components/MetricTile.svelte";
import CapacityMeter from "../lib/components/CapacityMeter.svelte";
import Sparkline from "../lib/components/Sparkline.svelte";
let status = $state(null);
let error = $state("");
let inflight = false;
const running = $derived(status && (status.phase === "active" || status.phase === "steady"));
const uptime = $derived.by(() => {
if (!status) return "—";
const s = status.uptime_secs || 0;
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${sec}s`;
});
const totalTokens = $derived(status ? (status.metrics?.tokens_in || 0) + (status.metrics?.tokens_out || 0) : 0);
const totalCost = $derived(status ? (status.metrics?.cost || 0).toFixed(2) : "0.00");
const p95 = $derived(status ? (status.metrics?.turn_latency_p95_ms || 0) : 0);
const mcpCount = $derived(status ? (status.mcp || []).filter((m) => m.connected).length : 0);
function fmt(n) {
return (n || 0).toLocaleString();
}
async function load() {
if (inflight) return;
inflight = true;
try {
status = await api("/api/status");
error = "";
} catch (caught) {
error = caught.message;
} finally {
inflight = false;
}
}
onMount(() => {
load();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
</script>
<section class="page active content-page">
{#if error && !status}
<div class="offline panel">
<span class="pulse-dot"></span>
<span>离线 — {error}</span>
</div>
{:else if status}
<div class="status-bar panel">
<div class="status-left">
<span class="pulse-dot" class:active={running}></span>
<strong class="mono">{running ? "RUNNING" : "IDLE"}</strong>
<span class="label-caps">GEN {status.generation}</span>
</div>
<div class="status-meta mono">
<span>{uptime}</span>
<span>v{status.version}</span>
<span>WS {status.ws_connections}</span>
<span>BG {status.background_tasks}</span>
<span class="label-caps">{status.phase}</span>
</div>
</div>
{#if error}
<div class="warn-bar">{error}</div>
{/if}
<div class="tiles">
<MetricTile label="SESSIONS" value={fmt(status.sessions?.total)} sub="{status.sessions?.active_turns || 0} active" />
<MetricTile label="TOKENS" value={fmt(totalTokens)} sub="cost ${totalCost}" />
<MetricTile label="TOOL CALLS" value={fmt(status.metrics?.tool_calls)} />
<MetricTile label="TURNS" value={fmt(status.metrics?.turns)} sub="p95 {fmt(p95)}ms" />
</div>
<div class="panel section">
<span class="label-caps">PROVIDERS</span>
<table class="provider-table">
<thead>
<tr><th>名称</th><th>模型</th><th>状态</th><th>延迟</th><th>Tokens In</th><th>Tokens Out</th><th>Cost</th></tr>
</thead>
<tbody>
{#each status.providers || [] as p}
<tr>
<td class="mono">{p.name}</td>
<td class="mono">{p.model}</td>
<td><span class="badge" class:ok={p.status === "ok"} class:fail={p.status !== "ok"}>{p.status}</span></td>
<td class="spark-cell"><Sparkline values={p.latencies || []} color={p.status === "ok" ? "var(--signal)" : "var(--danger)"} /></td>
<td class="mono">{fmt(p.tokens_in)}</td>
<td class="mono">{fmt(p.tokens_out)}</td>
<td class="mono">${(p.cost || 0).toFixed(3)}</td>
</tr>
{:else}
<tr><td colspan="7" class="empty-cell">暂无 Provider</td></tr>
{/each}
</tbody>
</table>
</div>
<div class="panel section">
<span class="label-caps">MESSAGE BUS</span>
<div class="bus-grid">
<div class="bus-item">
<span class="bus-label">Inbound</span>
<CapacityMeter depth={status.bus?.inbound?.depth || 0} cap={status.bus?.inbound?.cap || 1} label="inbound" />
<span class="mono bus-depth">{status.bus?.inbound?.depth || 0}/{status.bus?.inbound?.cap || 1}</span>
</div>
<div class="bus-item">
<span class="bus-label">Outbound</span>
<CapacityMeter depth={status.bus?.outbound?.depth || 0} cap={status.bus?.outbound?.cap || 1} label="outbound" />
<span class="mono bus-depth">{status.bus?.outbound?.depth || 0}/{status.bus?.outbound?.cap || 1}</span>
</div>
<div class="bus-item">
<span class="bus-label">Control</span>
<CapacityMeter depth={status.bus?.control?.depth || 0} cap={status.bus?.control?.cap || 1} label="control" />
<span class="mono bus-depth">{status.bus?.control?.depth || 0}/{status.bus?.control?.cap || 1}</span>
</div>
</div>
<div class="bus-footer mono">
<span>Lanes: {status.bus?.active_lanes || 0}</span>
<span>Scheduler: {status.scheduler?.enabled || 0}/{status.scheduler?.jobs || 0}</span>
<span>MCP: {mcpCount}</span>
</div>
</div>
<div class="two-col">
<div class="panel section">
<span class="label-caps">CHANNELS</span>
<div class="channel-list">
{#each status.channels || [] as ch}
<div class="channel-row">
<span class="mono">{ch.name}</span>
<span class="badge" class:ok={ch.status === "connected"} class:fail={ch.status !== "connected"}>{ch.status}</span>
</div>
{:else}
<span class="empty-cell">暂无 Channel</span>
{/each}
</div>
</div>
<div class="panel section">
<span class="label-caps">SCHEDULER</span>
<div class="sched-grid mono">
<span>Jobs: {status.scheduler?.jobs || 0}</span>
<span>Enabled: {status.scheduler?.enabled || 0}</span>
<span class:error-text={status.scheduler?.failed_jobs > 0}>Failed: {status.scheduler?.failed_jobs || 0}</span>
<span>Next: {formatTime(status.scheduler?.next_run_at)}</span>
</div>
</div>
</div>
{:else}
<div class="loading">加载中…</div>
{/if}
</section>
<style>
.offline { display: flex; align-items: center; gap: 10px; padding: 18px 22px; color: var(--danger); font-size: 13px; }
.status-bar { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; margin-bottom: 14px; flex-wrap: wrap; gap: 10px; }
.status-left { display: flex; align-items: center; gap: 10px; }
.status-meta { display: flex; gap: 16px; font-size: 11px; color: var(--text-soft); flex-wrap: wrap; }
.warn-bar { color: var(--accent); font-size: 11px; margin-bottom: 10px; }
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.section { padding: 16px 18px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; }
.provider-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.provider-table th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--line); font-size: 10px; }
.provider-table td { padding: 7px 8px; border-bottom: 1px solid var(--line); color: var(--text-soft); }
.provider-table tr:last-child td { border-bottom: none; }
.spark-cell { width: 80px; height: 24px; }
.empty-cell { color: var(--muted); text-align: center; padding: 12px; }
.bus-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.bus-item { display: flex; flex-direction: column; gap: 6px; }
.bus-label { font-size: 10px; color: var(--muted); }
.bus-depth { font-size: 10px; color: var(--text-soft); }
.bus-footer { display: flex; gap: 20px; font-size: 11px; color: var(--text-soft); border-top: 1px solid var(--line); padding-top: 10px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.channel-list { display: flex; flex-direction: column; gap: 8px; }
.channel-row { display: flex; justify-content: space-between; align-items: center; }
.sched-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: var(--text-soft); }
.error-text { color: var(--danger); }
@media (max-width: 800px) {
.tiles { grid-template-columns: repeat(2, 1fr); }
.bus-grid { grid-template-columns: 1fr; }
.two-col { grid-template-columns: 1fr; }
}
</style>