feat(webui): overview runtime dashboard
This commit is contained in:
parent
6e719e7fcb
commit
0b02fd580b
198
webui/src/pages/OverviewPage.svelte
Normal file
198
webui/src/pages/OverviewPage.svelte
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user