feat(gateway,admin-web): 可观测面从轮询改为 WS 帧推送(裁决 05.4.3 + 16.4.3-WS)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增三个下行帧(纯新增,既有帧名/形状零改动):observability_log / observability_trace /
  observability_metrics,各带 ≤1s 节拍、无新数据不发帧、单帧上限(日志 200 / span 100)
- 帧壳 {type,data} 不带 sessionId;**不进 frameReachesAll 白名单** ⇒ fail-closed 只发 owner+admin
  (变异验证两次:加进白名单 → viewer 用例必红)
- 推送开启条件=owner/admin 凭据「读过一次」对应 REST(引导数据仍由该次读取返回),
  连接全断即退订并停循环(无常驻 goroutine);既有 REST 端点路径/RBAC/形状完全不变
- 前端:删除 logs/metrics/trace 三处 useAutoReload 轮询;新增 observabilityStream(引导+去重合并+窗口)
  与 observabilityViews(三个面板 + WS 徽标),长列表统一走已落地的 VirtualList
- 复核:build/vet 绿 + gateway 全包测试绿(含真 bus→真 /ws/chat 三角色帧断言)+ `-race`;
  前端 tsc 0 错 + vite build 成功
- 待登记契约:api-client 需新增「可观测实时推送」一节
This commit is contained in:
AskaEth committed 2026-10-04 13:58:03 +08:00
1 parent 74b12d87ca
commit 0e1f163ef9
10 files changed
+1449 -312

No files matched your search

+36 -306
View File
@@ -1,18 +1,24 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAutoReload } from "../hooks";
import {
adminObservabilityAudit,
adminObservabilityLogs,
adminObservabilityMetrics,
adminObservabilityTrace,
} from "../../lib/api";
import type { AdminLogLevel, AdminLogLine, AdminTraceSpan, AuditRecord } from "../../lib/types";
import { adminObservabilityAudit } from "../../lib/api";
import type { AuditRecord } from "../../lib/types";
import { useObservabilityStream } from "./observabilityStream";
import { LogsPanel, MetricsPanel, TracePanel, WsBadge } from "./observabilityViews";
/**
* 可观测性面板。日志 / 指标 / trace 三种数据在裁定 05.4.3 + 16.4.3-WS 后改为
* **WS 帧推送**(`useObservabilityStream()`:挂载时各读一次引导数据,此后由
* observability_log / observability_trace / observability_metrics 帧追增量),
* 因此这里不再有任何 1s/3s/30s 的自动刷新。
*
* 审计 tab 例外:它读的是审计链(写操作凭证,非实时监控数据),裁定只覆盖
* 日志/指标/trace,因此仍按 5s 读取,并保留本地兜底。
*/
type Tab = "logs" | "trace" | "metrics" | "audit";
@@ -23,50 +29,9 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "audit", label: "审计" },
];
/** Live backend log tail (GET /admin/observability/logs): level filter + refresh. */
const LOG_LIMIT = 200;
const LOG_RELOAD_MS = 30_000;
const LEVEL_FILTERS: ReadonlyArray<{ id: AdminLogLevel | ""; label: string }> = [
{ id: "", label: "全部" },
{ id: "debug", label: "debug" },
{ id: "info", label: "info" },
{ id: "warn", label: "warn" },
{ id: "error", label: "error" },
];
const LEVEL_TEXT: Record<string, string> = {
debug: "text-[var(--mu-text-lo)]",
info: "text-[var(--mu-accent-hi)]",
warn: "text-[var(--mu-warn)]",
error: "text-[var(--mu-err)]",
};
/** `time` is RFC3339 on current builds but the contract allows epoch ms too, so
* both are normalized; an unparseable value is shown verbatim rather than faked. */
function fmtLogTime(t: string | number): string {
const d = new Date(t);
if (Number.isNaN(d.getTime())) return String(t);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
}
function levelTextClass(level: string): string {
return LEVEL_TEXT[level.toLowerCase()] ?? "text-[var(--mu-text-lo)]";
}
const KIND_LABEL: Record<string, string> = {
llm_call: "LLM",
tool_call: "工具",
orchestration: "编排",
state_transition: "状态",
subagent: "子代理",
memory: "记忆",
guardrail: "护栏",
};
export function ObservabilityModule(): React.ReactNode {
const [tab, setTab] = useState<Tab>("logs");
const stream = useObservabilityStream();
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
const audit = useAuditStore((s) => s.records);
const clearAudit = useAuditStore((s) => s.clear);
@@ -87,128 +52,26 @@ export function ObservabilityModule(): React.ReactNode {
{t.label}
</button>
))}
<span className="ml-auto">
<WsBadge status={stream.wsStatus} />
</span>
</div>
{tab === "logs" ? <LogsTab lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "logs" ? (
<div className="space-y-4">
<LogsPanel stream={stream} />
<LogsTail lines={deviceUpdates} />
</div>
) : null}
{tab === "trace" ? <TracePanel stream={stream} /> : null}
{tab === "metrics" ? <MetricsPanel stream={stream} /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : null}
</div>
);
}
/** 实时日志 tab: the backend's structured log tail first (authoritative, leveled
* and filtered server-side), then the WS event echo as a client-side complement. */
function LogsTab({ lines }: { lines: string[] }): React.ReactNode {
return (
<div className="space-y-4">
<BackendLogs />
<LogsTail lines={lines} />
</div>
);
}
/** Backend log tail: level filter chips over a monospace list, refreshed every
* 30s. Loading / empty / error are each surfaced honestly — no placeholder rows. */
function BackendLogs(): React.ReactNode {
const [level, setLevel] = useState<AdminLogLevel | "">("");
const logs = useAsyncData(
() => adminObservabilityLogs({ level: level || undefined, limit: LOG_LIMIT }),
[level]
);
useAutoReload(logs.reload, LOG_RELOAD_MS);
const rows = logs.data ?? [];
// "更新于 N 秒前" needs a ticking clock; the interval only forces a re-render,
// the timestamp itself is captured when fresh data lands.
const [updatedAt, setUpdatedAt] = useState(0);
useEffect(() => {
if (logs.data) setUpdatedAt(Date.now());
}, [logs.data]);
const [, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">实时日志(后端分级 tail)</h3>
<Tag tone="ok">tail</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/observability/logs
</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
{updatedAt > 0 ? `${Math.max(0, Math.round((Date.now() - updatedAt) / 1000))}s 前更新` : "尚未更新"}
{" · 30s 自动刷新"}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{LEVEL_FILTERS.map((f) => (
<button
key={f.id || "all"}
onClick={() => setLevel(f.id)}
aria-pressed={level === f.id}
className={`rounded-[var(--mu-radius)] px-2.5 py-1 font-mono text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
level === f.id
? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]"
: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
}`}
>
{f.label}
</button>
))}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
共 {rows.length} 条 · 上限 {LOG_LIMIT}
{level ? ` · 筛选 ${level} 及以上` : ""}
</span>
</div>
{logs.loading && logs.data === null ? (
<Notice>加载日志…</Notice>
) : logs.error && logs.data === null ? (
<Notice tone="warn">日志端点暂不可用:{logs.error}</Notice>
) : rows.length === 0 ? (
<Notice>
暂无日志{level ? `(level=${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。
</Notice>
) : (
<div className="max-h-96 overflow-auto rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] py-1.5">
{rows.map((line, i) => (
<LogRow key={`${line.time}-${line.source}-${i}`} line={line} />
))}
</div>
)}
{logs.error && logs.data !== null ? (
<Notice tone="warn">上次刷新失败(保留现有日志):{logs.error}</Notice>
) : null}
</Surface>
);
}
/** One monospace log line: time · level · source · msg (source never blank). */
function LogRow({ line }: { line: AdminLogLine }): React.ReactNode {
return (
<div className="flex items-start gap-2 px-3 py-0.5 font-mono text-xs leading-relaxed hover:bg-[var(--mu-surface-3)]/60">
<span className="shrink-0 text-[var(--mu-text-lo)]" title={String(line.time)}>
{fmtLogTime(line.time)}
</span>
<span className={`w-12 shrink-0 font-semibold ${levelTextClass(line.level)}`}>
{line.level || "info"}
</span>
<span className="shrink-0 text-[var(--mu-accent-hi)]">{line.source || "—"}</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-[var(--mu-text-mid)]">
{line.msg}
</span>
</div>
);
}
/** Client-side WS event echo — the frames this browser received, kept alongside
* the server log because the two answer different questions (server truth vs.
* what this client actually saw). */
/** 本端 WS 事件回声 —— 这个浏览器实际收到的帧,与服务端日志并列展示:
* 两者回答的是不同问题(服务端真相 vs 本端到底看到了什么)。 */
function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
return (
<Surface className="space-y-3">
@@ -223,140 +86,13 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
{lines.join("\n")}
</pre>
)}
<Notice>这是当前浏览器的 WS 帧回显,不是服务端日志;服务端分级日志见上方「实时日志(后端分级 tail)」。</Notice>
<Notice>
这是当前浏览器的 WS 帧回显,不是服务端日志;可观测推送帧(日志/指标/trace)走订阅通道,不会污染这份回显。
</Notice>
</Surface>
);
}
function TraceView(): React.ReactNode {
const trace = useAsyncData(() => adminObservabilityTrace(100), []);
useAutoReload(trace.reload, 3000);
const spans = trace.data ?? [];
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<Tag tone="ok">实时</Tag>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/observability/trace</span>
</div>
{trace.loading ? (
<Notice>加载 span 数据…</Notice>
) : trace.error ? (
<Notice tone="warn">trace 后端暂不可用:{trace.error}</Notice>
) : spans.length === 0 ? (
<Notice>暂无 span。接入 /ws/chat 发起会话后,LLM / 工具 / 编排 / 子代理等 span 会实时记录到环形缓冲。</Notice>
) : (
<SpanList spans={spans} />
)}
<Notice>数据源:gateway trace 环形缓冲(经 TraceSink 对接 runtime 的 tracer)。可在 TraceSink 注入前展示占位,注入后即真实 span。</Notice>
</Surface>
);
}
function SpanList({ spans }: { spans: AdminTraceSpan[] }): React.ReactNode {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div className="space-y-1.5">
{spans.map((sp) => {
const open = openId === sp.id;
return (
<button
key={sp.id}
onClick={() => setOpenId(open ? null : sp.id)}
className="w-full rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-left text-xs transition-colors hover:bg-[var(--mu-surface-3)]"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-[var(--mu-text)]">{sp.name}</span>
<Tag tone="neutral">{KIND_LABEL[sp.kind] ?? sp.kind}</Tag>
<Tag tone={sp.status === "ok" ? "ok" : "err"}>{sp.status === "ok" ? "成功" : "失败"}</Tag>
<span className="font-mono text-[var(--mu-text-lo)]">{sp.id}</span>
<span className="ml-auto font-mono text-[var(--mu-text-lo)]">{fmtDuration(sp.durationNs)}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[var(--mu-text-lo)]">
<span>{new Date(sp.startedAt).toLocaleString()}</span>
{sp.parentId ? <span>父 {sp.parentId}</span> : <span>根 span</span>}
</div>
{open ? (
<div className="mt-2 space-y-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-[var(--mu-text-mid)]">
{(sp.events?.length ?? 0) === 0 && !sp.props ? (
<span className="text-[var(--mu-text-lo)]">无事件/属性。</span>
) : null}
{sp.events?.map((ev, i) => (
<div key={i} className="flex items-start gap-2">
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">{new Date(ev.time).toLocaleTimeString()}</span>
<span className="font-medium">{ev.type}</span>
{ev.props ? (
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(ev.props)}</code>
) : null}
</div>
))}
{sp.props ? (
<code className="block break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(sp.props)}</code>
) : null}
</div>
) : null}
</button>
);
})}
</div>
);
}
function MetricsView(): React.ReactNode {
const metrics = useAsyncData(adminObservabilityMetrics, []);
useAutoReload(metrics.reload, 3000);
const m = metrics.data;
const topPaths = Object.entries(m?.perPath ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
return (
<div className="space-y-4">
<CardGrid>
<StatCard icon={<Icon name="gear" size={18} />} label="请求 QPS" value={m ? m.qps.toFixed(1) : "—"} sub={m ? `总请求 ${m.totalRequests}` : "网关指标加载中"} tone="accent" />
<StatCard icon={<Icon name="robot" size={18} />} label="LLM 调用次数" value={m ? m.llmCalls : "—"} sub="从 trace span 累计" tone="neutral" />
<StatCard icon={<Icon name="agent" size={18} />} label="子代理事件" value={m ? m.subagentEvents : "—"} sub="从 trace span 累计" tone="ok" />
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本后端暂不统计" tone="warn" />
</CardGrid>
{metrics.error ? <Notice tone="warn">指标后端暂不可用:{metrics.error}</Notice> : null}
{m && (topPaths.length > 0 || Object.keys(m.statusCounts).length > 0) ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">请求分布(真实网关计数)</h3>
{topPaths.length > 0 ? (
<div className="space-y-1">
{topPaths.map(([path, count]) => (
<div key={path} className="flex items-center gap-2 text-xs">
<code className="min-w-0 flex-1 break-all font-mono text-[var(--mu-text-mid)]">{path}</code>
<span className="text-[var(--mu-text-lo)]">{count}</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--mu-surface-3)]">
<div className="h-full rounded-full bg-[var(--mu-accent)]/60" style={{ width: `${((count / (m.totalRequests || 1)) * 100).toFixed(1)}%` }} />
</div>
</div>
))}
</div>
) : null}
{Object.keys(m.statusCounts).length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{Object.entries(m.statusCounts).map(([code, count]) => (
<Tag key={code} tone={code === "200" ? "ok" : Number(code) >= 500 ? "err" : "warn"}>
{code} × {count}
</Tag>
))}
<span className="text-xs text-[var(--mu-text-lo)]">平均 {m.avgLatencyMs.toFixed(1)}ms · 最大 {m.maxLatencyMs}ms</span>
</div>
) : null}
</Surface>
) : null}
<Notice>LLM 调用次数 / 子代理事件由实际观测到的 trace span 累计;Token / 成本后端暂不统计(不伪造指标)。指标时序图 / 导出需后端 M10+ 提供。</Notice>
</div>
);
}
interface AuditViewProps {
records: AuditRecord[];
onClear: () => void;
@@ -401,7 +137,9 @@ function AuditView({ records: local, onClear }: AuditViewProps): React.ReactNode
<td className="px-2 py-2 text-xs">{r.who}</td>
<td className="px-2 py-2 text-xs font-mono">{r.what}</td>
<td className="px-2 py-2 text-xs text-[var(--mu-text-lo)]">{new Date(r.when).toLocaleString()}</td>
<td className="px-2 py-2"><Tag tone={r.result === "ok" ? "ok" : "err"}>{r.result}</Tag></td>
<td className="px-2 py-2">
<Tag tone={r.result === "ok" ? "ok" : "err"}>{r.result}</Tag>
</td>
</tr>
))}
</tbody>
@@ -410,11 +148,3 @@ function AuditView({ records: local, onClear }: AuditViewProps): React.ReactNode
</Surface>
);
}
/** Formats a nanosecond duration into a human-readable label. */
function fmtDuration(ns: number): string {
if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)} s`;
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`;
if (ns >= 1e3) return `${(ns / 1e3).toFixed(1)} µs`;
return `${ns} ns`;
}
@@ -0,0 +1,306 @@
import { useEffect, useState } from "react";
import {
adminObservabilityLogs,
adminObservabilityMetrics,
adminObservabilityTrace,
} from "../../lib/api";
import { subscribeObservabilityFrames } from "../../lib/ws";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAppStore } from "../../store/useAppStore";
import type {
AdminLogLevel,
AdminLogLine,
AdminObservabilityMetrics,
AdminTraceSpan,
WsStatus,
} from "../../lib/types";
/**
* 可观测面板的数据流(裁定 05.4.3 + 16.4.3-WS:日志 / 指标 / trace 由 WS 帧
* 推送替代轮询)。
*
* 每个数据集两条来源:
* ① **引导读取**——面板挂载时各读一次 REST,拿首屏;服务端把这次读取同时
* 记作订阅握手,此后新数据由 WS 帧推来(面板不再周期刷新)。
* ② **推送帧**——`observability_log` / `observability_trace` /
* `observability_metrics`,载荷与 REST 同形,因此两条来源可以在同一份
* 缓冲里合并去重。
*
* 合并口径:按业务键去重、按时间倒序、只保留最近窗口(日志与 span 都是尾部
* 视图,更旧的条目本就不在窗口内)。日志按内容折叠完全相同的行(同一毫秒内
* 重复的同一行只留一条),span 按 id 折叠。
*/
/** 面板保留窗口:与日志 REST 尾部的默认上限一致(LOG_LIMIT=200)。 */
export const LOG_BUFFER = 200;
/** span 保留窗口:面板发起引导读取时请的是 100 条,留一倍余量接推送。 */
export const SPAN_BUFFER = 200;
const FRAME_LOG = "observability_log";
const FRAME_TRACE = "observability_trace";
const FRAME_METRICS = "observability_metrics";
/** 一行的稳定键:REST 与推送会看到同一行,去重靠它。 */
export interface LogRow {
key: string;
time: string | number;
level: string;
source: string;
msg: string;
}
/** 把一条线上日志行折成可去重的表行。 */
export function logRowOf(line: AdminLogLine): LogRow {
return {
key: `${line.time}|${line.level}|${line.source}|${line.msg}`,
time: line.time,
level: line.level,
source: line.source,
msg: line.msg,
};
}
/** 面板的级别筛选档位,`""` 表示全部。 */
export const LOG_LEVEL_OPTIONS: ReadonlyArray<{ id: AdminLogLevel | ""; label: string }> = [
{ id: "", label: "全部" },
{ id: "debug", label: "debug" },
{ id: "info", label: "info" },
{ id: "warn", label: "warn" },
{ id: "error", label: "error" },
];
const LEVEL_RANK: Record<string, number> = { debug: 0, info: 1, warn: 2, error: 3 };
/** 级别筛选:推送帧不做分级过滤(帧面向所有订阅连接广播),因此在本地过滤。 */
export function matchesLevel(level: string, min: AdminLogLevel | ""): boolean {
if (!min) return true;
const got = LEVEL_RANK[level.toLowerCase()];
if (got === undefined) return true;
return got >= LEVEL_RANK[min];
}
/** 合并两条来源:按业务键去重(新数据优先)、按时间倒序、截到窗口上限。 */
function mergeRows<T>(
prev: readonly T[],
incoming: readonly T[],
keyOf: (row: T) => string,
timeOf: (row: T) => number,
cap: number,
): T[] {
const byKey = new Map<string, T>();
for (const row of incoming) byKey.set(keyOf(row), row);
for (const row of prev) {
const k = keyOf(row);
if (!byKey.has(k)) byKey.set(k, row);
}
const out = [...byKey.values()];
out.sort((a, b) => timeOf(b) - timeOf(a));
return out.slice(0, cap);
}
/** 把时间字段解析成毫秒;不可解析的值一律排到最后(不伪造时间)。 */
function timeMs(t: string | number): number {
const ms = typeof t === "number" ? t : Date.parse(t);
return Number.isNaN(ms) ? 0 : ms;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** 从 observability_log 帧的 data 里取出条目(形状不对的条目被丢弃)。 */
function readLogEntries(data: unknown): AdminLogLine[] {
if (!isRecord(data) || !Array.isArray(data.entries)) return [];
const out: AdminLogLine[] = [];
for (const item of data.entries) {
if (!isRecord(item) || typeof item.msg !== "string") continue;
out.push({
time: typeof item.time === "string" || typeof item.time === "number" ? item.time : "",
level: typeof item.level === "string" ? item.level : "",
source: typeof item.source === "string" ? item.source : "",
msg: item.msg,
});
}
return out;
}
/** 从 observability_trace 帧的 data 里取出 span(形状不对的条目被丢弃)。 */
function readSpans(data: unknown): AdminTraceSpan[] {
if (!isRecord(data) || !Array.isArray(data.spans)) return [];
const out: AdminTraceSpan[] = [];
for (const item of data.spans) {
if (!isRecord(item) || typeof item.id !== "string") continue;
out.push({
id: item.id,
parentId: typeof item.parentId === "string" ? item.parentId : undefined,
name: typeof item.name === "string" ? item.name : "",
kind: typeof item.kind === "string" ? item.kind : "",
status: typeof item.status === "string" ? item.status : "",
startedAt: typeof item.startedAt === "string" ? item.startedAt : "",
endedAt: typeof item.endedAt === "string" ? item.endedAt : undefined,
durationNs: typeof item.durationNs === "number" ? item.durationNs : 0,
props: isRecord(item.props) ? item.props : undefined,
events: Array.isArray(item.events) ? (item.events as AdminTraceSpan["events"]) : undefined,
});
}
return out;
}
function readCounts(v: unknown): Record<string, number> {
const out: Record<string, number> = {};
if (!isRecord(v)) return out;
for (const [k, n] of Object.entries(v)) {
if (typeof n === "number") out[k] = n;
}
return out;
}
/** 从 observability_metrics 帧的 data 里取出指标(与 REST 的 AdminMetrics 同形)。 */
function readMetrics(data: unknown): AdminObservabilityMetrics | null {
if (!isRecord(data) || typeof data.totalRequests !== "number") return null;
const num = (v: unknown): number => (typeof v === "number" ? v : 0);
return {
totalRequests: num(data.totalRequests),
qps: num(data.qps),
avgLatencyMs: num(data.avgLatencyMs),
maxLatencyMs: num(data.maxLatencyMs),
perMethod: readCounts(data.perMethod),
perPath: readCounts(data.perPath),
statusCounts: readCounts(data.statusCounts),
llmCalls: num(data.llmCalls),
subagentEvents: num(data.subagentEvents),
};
}
/** 面板消费的完整数据流与各数据集的状态。 */
export interface ObservabilityStream {
logs: LogRow[];
logsLoading: boolean;
logsError: string | null;
logsUpdatedAt: number;
logsPushed: boolean;
reloadLogs: () => void;
spans: AdminTraceSpan[];
spansLoading: boolean;
spansError: string | null;
spansUpdatedAt: number;
spansPushed: boolean;
reloadSpans: () => void;
metrics: AdminObservabilityMetrics | null;
metricsLoading: boolean;
metricsError: string | null;
metricsUpdatedAt: number;
metricsPushed: boolean;
reloadMetrics: () => void;
/** 实时通道状态:断线时面板只剩引导数据,并且会如实说明。 */
wsStatus: WsStatus;
}
/** 订阅可观测数据流:一次引导读取 + 持续推送(替代原先的 3–30s 轮询)。 */
export function useObservabilityStream(): ObservabilityStream {
const [logs, setLogs] = useState<LogRow[]>([]);
const [logsUpdatedAt, setLogsUpdatedAt] = useState(0);
const [logsPushed, setLogsPushed] = useState(false);
const [spans, setSpans] = useState<AdminTraceSpan[]>([]);
const [spansUpdatedAt, setSpansUpdatedAt] = useState(0);
const [spansPushed, setSpansPushed] = useState(false);
const [metrics, setMetrics] = useState<AdminObservabilityMetrics | null>(null);
const [metricsUpdatedAt, setMetricsUpdatedAt] = useState(0);
const [metricsPushed, setMetricsPushed] = useState(false);
const logsRead = useAsyncData(() => adminObservabilityLogs({ limit: LOG_BUFFER }), []);
const spansRead = useAsyncData(() => adminObservabilityTrace(SPAN_BUFFER), []);
const metricsRead = useAsyncData(adminObservabilityMetrics, []);
// 引导读取:首屏数据。这次读取在服务端同时是订阅握手。
useEffect(() => {
const data = logsRead.data;
if (!data) return;
setLogs((prev) => mergeRows(prev, data.map(logRowOf), (r) => r.key, (r) => timeMs(r.time), LOG_BUFFER));
setLogsUpdatedAt(Date.now());
}, [logsRead.data]);
useEffect(() => {
const data = spansRead.data;
if (!data) return;
setSpans((prev) =>
mergeRows(prev, data, (s) => s.id, (s) => timeMs(s.startedAt), SPAN_BUFFER),
);
setSpansUpdatedAt(Date.now());
}, [spansRead.data]);
useEffect(() => {
const data = metricsRead.data;
if (!data) return;
setMetrics(data);
setMetricsUpdatedAt(Date.now());
}, [metricsRead.data]);
// 推送帧:面板打开期间由服务端推来增量。
useEffect(
() =>
subscribeObservabilityFrames((frame) => {
if (frame.type === FRAME_LOG) {
const entries = readLogEntries(frame.data);
if (entries.length === 0) return;
setLogs((prev) => mergeRows(prev, entries.map(logRowOf), (r) => r.key, (r) => timeMs(r.time), LOG_BUFFER));
setLogsUpdatedAt(Date.now());
setLogsPushed(true);
return;
}
if (frame.type === FRAME_TRACE) {
const pushed = readSpans(frame.data);
if (pushed.length === 0) return;
setSpans((prev) => mergeRows(prev, pushed, (s) => s.id, (s) => timeMs(s.startedAt), SPAN_BUFFER));
setSpansUpdatedAt(Date.now());
setSpansPushed(true);
return;
}
if (frame.type === FRAME_METRICS) {
const snap = readMetrics(frame.data);
if (!snap) return;
setMetrics(snap);
setMetricsUpdatedAt(Date.now());
setMetricsPushed(true);
}
}),
[],
);
const wsStatus = useAppStore((s) => s.wsStatus);
return {
logs,
logsLoading: logsRead.loading,
logsError: logsRead.error,
logsUpdatedAt,
logsPushed,
reloadLogs: logsRead.reload,
spans,
spansLoading: spansRead.loading,
spansError: spansRead.error,
spansUpdatedAt,
spansPushed,
reloadSpans: spansRead.reload,
metrics,
metricsLoading: metricsRead.loading,
metricsError: metricsRead.error,
metricsUpdatedAt,
metricsPushed,
reloadMetrics: metricsRead.reload,
wsStatus,
};
}
/** 秒级重渲染:让「N 秒前更新」随空闲时间走动(只驱动重渲染,不取数)。 */
export function useSecondsTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return tick;
}
@@ -0,0 +1,363 @@
import { useState } from "react";
import { CardGrid, Notice, StatCard, VirtualList, VIRTUAL_THRESHOLD } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import type { AdminLogLevel, AdminTraceSpan, WsStatus } from "../../lib/types";
import { matchesLevel, LOG_LEVEL_OPTIONS, useSecondsTick, type LogRow, type ObservabilityStream } from "./observabilityStream";
import { getStoredRole } from "../../lib/api";
/**
* 可观测面板的视图层(裁定 05.4.3 + 16.4.3-WS):数据来自
* `useObservabilityStream()`(引导读取 + WS 推送),本文件只管呈现:
*
* - 日志与 span 都是「条数多、还会被追帧」的长列表,统一用已落地的
* `VirtualList` 承接(DOM 行数与总条数解耦);
* - 加载 / 空 / 错误三态各自如实呈现,不用占位数据充数;
* - 实时通道断开时明说「只剩引导数据」,而不是让画面看起来还在刷新。
*/
const LEVEL_TEXT: Record<string, string> = {
debug: "text-[var(--mu-text-lo)]",
info: "text-[var(--mu-accent-hi)]",
warn: "text-[var(--mu-warn)]",
error: "text-[var(--mu-err)]",
};
const KIND_LABEL: Record<string, string> = {
llm_call: "LLM",
tool_call: "工具",
orchestration: "编排",
state_transition: "状态",
subagent: "子代理",
memory: "记忆",
guardrail: "护栏",
};
/** 日志行稠密(22px 上下),超过这个行数就开始只挂载视口内的行。 */
const LOG_VIRTUAL_THRESHOLD = 20;
/** `time` 当前构建是 RFC3339,契约也允许 epoch 毫秒,两者都归一化;
* 解析不出的值原样显示,不编造时间。 */
function fmtLogTime(t: string | number): string {
const d = new Date(t);
if (Number.isNaN(d.getTime())) return String(t);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
}
/** 实时通道状态徽标:owner/admin 的 /ws/chat 连接是推送的载体。 */
export function WsBadge({ status }: { status: WsStatus }): React.ReactNode {
const map: Record<WsStatus, { label: string; tone: "ok" | "warn" | "err" | "neutral" }> = {
connected: { label: "推送中", tone: "ok" },
connecting: { label: "连接中", tone: "neutral" },
reconnecting: { label: "重连中", tone: "warn" },
disconnected: { label: "已断开", tone: "err" },
expired: { label: "登录过期", tone: "err" },
idle: { label: "未连接", tone: "neutral" },
};
const cur = map[status];
return <Tag tone={cur.tone}>实时通道 · {cur.label}</Tag>;
}
/** 数据新鲜度:推送来的数据随空闲时间走动;只有引导读取时如实说明。 */
function Freshness({ updatedAt, source }: { updatedAt: number; source: "push" | "read" }): React.ReactNode {
useSecondsTick();
const age = updatedAt > 0 ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : -1;
return (
<span className="text-xs text-[var(--mu-text-lo)]">
{source === "push" ? "WS 推送" : "引导读取"}
{age >= 0 ? ` · ${age}s 前更新` : " · 尚未更新"}
</span>
);
}
/** 手动刷新按钮:实时通道断开或需要立刻对账时用(不是轮询)。 */
function RefreshButton({ onClick, what }: { onClick: () => void; what: string }): React.ReactNode {
return (
<Button size="sm" variant="ghost" onClick={onClick} title={`重新读取${what}`}>
刷新
</Button>
);
}
/** 只读档说明:推送只发 owner/admin(design/10 §5),viewer 仍走 REST,
* 因此这里如实说明并指向「刷新」,而不是让画面看起来还在实时刷新。 */
function ReadonlyScopeNote(): React.ReactNode {
const role = getStoredRole();
if (role === null || role === "owner" || role === "admin") return null;
return (
<Notice tone="warn">
当前凭据是只读档({role}):实时推送只发 owner/admin,请用「刷新」按需取数。
</Notice>
);
}
/** 实时日志 tab:后端分级 tail(服务端推送)+ 本端 WS 回声(见 Observability.tsx)。 */
export function LogsPanel({ stream }: { stream: ObservabilityStream }): React.ReactNode {
const [level, setLevel] = useState<AdminLogLevel | "">("");
const rows: LogRow[] = stream.logs.filter((l) => matchesLevel(l.level, level));
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">实时日志(后端分级 tail)</h3>
<WsBadge status={stream.wsStatus} />
<span className="font-mono text-xs text-[var(--mu-text-lo)]">
WS observability_log · 引导 GET /api/v1/admin/observability/logs
</span>
<span className="ml-auto flex items-center gap-2">
<Freshness updatedAt={stream.logsUpdatedAt} source={stream.logsPushed ? "push" : "read"} />
<RefreshButton onClick={stream.reloadLogs} what="日志" />
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{LOG_LEVEL_OPTIONS.map((f) => (
<button
key={f.id || "all"}
onClick={() => setLevel(f.id)}
aria-pressed={level === f.id}
className={`rounded-[var(--mu-radius)] px-2.5 py-1 font-mono text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
level === f.id
? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]"
: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
}`}
>
{f.label}
</button>
))}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
显示 {rows.length} / 保留 {stream.logs.length} 条 · 上限 200
{level ? ` · 本地筛选 ${level} 及以上` : ""}
</span>
</div>
{stream.logsLoading && stream.logs.length === 0 ? (
<Notice>加载日志…</Notice>
) : stream.logsError && stream.logs.length === 0 ? (
<Notice tone="warn">日志端点暂不可用:{stream.logsError}</Notice>
) : rows.length === 0 ? (
<Notice>
暂无日志{level ? `(本地筛选 level≥${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。
</Notice>
) : (
<>
{rows.length > LOG_VIRTUAL_THRESHOLD ? (
<div className="text-xs text-[var(--mu-text-lo)]">虚拟滚动 · 仅挂载视口内的行</div>
) : null}
<VirtualList
items={rows}
rowKey={(l) => l.key}
renderRow={(l) => <LogRow line={l} />}
rowHeight={22}
threshold={LOG_VIRTUAL_THRESHOLD}
maxHeight="min(60vh, 640px)"
/>
</>
)}
{stream.logsError && stream.logs.length > 0 ? (
<Notice tone="warn">上次刷新失败(保留现有日志):{stream.logsError}</Notice>
) : null}
{stream.wsStatus !== "connected" ? (
<Notice tone="warn">
实时通道未连接:以上是最近一次引导读取的内容,连上后新日志会自动推来(也可点「刷新」立刻取一次)。
</Notice>
) : null}
<ReadonlyScopeNote />
</Surface>
);
}
/** 一条等宽日志行:time · level · source · msg(source 不空白)。 */
function LogRow({ line }: { line: LogRow }): React.ReactNode {
return (
<div className="flex items-start gap-2 px-3 py-0.5 font-mono text-xs leading-relaxed hover:bg-[var(--mu-surface-3)]/60">
<span className="shrink-0 text-[var(--mu-text-lo)]" title={String(line.time)}>
{fmtLogTime(line.time)}
</span>
<span className={`w-12 shrink-0 font-semibold ${LEVEL_TEXT[line.level.toLowerCase()] ?? "text-[var(--mu-text-lo)]"}`}>
{line.level || "info"}
</span>
<span className="shrink-0 text-[var(--mu-accent-hi)]">{line.source || "—"}</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-[var(--mu-text-mid)]">{line.msg}</span>
</div>
);
}
/** Tracing 视图:新 span 由推送追进列表,展开仍按 id 记忆。 */
export function TracePanel({ stream }: { stream: ObservabilityStream }): React.ReactNode {
const [openId, setOpenId] = useState<string | null>(null);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<WsBadge status={stream.wsStatus} />
<span className="ml-auto flex items-center gap-2">
<Freshness updatedAt={stream.spansUpdatedAt} source={stream.spansPushed ? "push" : "read"} />
<RefreshButton onClick={stream.reloadSpans} what="span" />
</span>
</div>
<div className="text-xs text-[var(--mu-text-lo)]">
WS observability_trace · 引导 GET /api/v1/admin/observability/trace
{stream.spans.length > VIRTUAL_THRESHOLD ? " · 虚拟滚动" : ""}
</div>
{stream.spansLoading && stream.spans.length === 0 ? (
<Notice>加载 span 数据…</Notice>
) : stream.spansError && stream.spans.length === 0 ? (
<Notice tone="warn">trace 后端暂不可用:{stream.spansError}</Notice>
) : stream.spans.length === 0 ? (
<Notice>暂无 span。接入 /ws/chat 发起会话后,LLM / 工具 / 编排 / 子代理等 span 会实时推来这里。</Notice>
) : (
<VirtualList
items={stream.spans}
rowKey={(sp) => sp.id}
renderRow={(sp) => (
<SpanRow span={sp} open={openId === sp.id} onToggle={() => setOpenId(openId === sp.id ? null : sp.id)} />
)}
rowClassName="pb-1.5"
rowHeight={72}
maxHeight="min(60vh, 640px)"
/>
)}
<Notice>数据源:gateway trace 环形缓冲(经 TraceSink 对接 runtime 的 tracer);推送只发订阅之后新完成的 span,历史由引导读取提供。</Notice>
<ReadonlyScopeNote />
</Surface>
);
}
function SpanRow({ span, open, onToggle }: { span: AdminTraceSpan; open: boolean; onToggle: () => void }): React.ReactNode {
return (
<button
onClick={onToggle}
className="w-full rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-left text-xs transition-colors hover:bg-[var(--mu-surface-3)]"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-[var(--mu-text)]">{span.name}</span>
<Tag tone="neutral">{KIND_LABEL[span.kind] ?? span.kind}</Tag>
<Tag tone={span.status === "ok" ? "ok" : "err"}>{span.status === "ok" ? "成功" : "失败"}</Tag>
<span className="font-mono text-[var(--mu-text-lo)]">{span.id}</span>
<span className="ml-auto font-mono text-[var(--mu-text-lo)]">{fmtDuration(span.durationNs)}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[var(--mu-text-lo)]">
<span>{span.startedAt ? new Date(span.startedAt).toLocaleString() : "—"}</span>
{span.parentId ? <span>父 {span.parentId}</span> : <span>根 span</span>}
</div>
{open ? (
<div className="mt-2 space-y-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-[var(--mu-text-mid)]">
{(span.events?.length ?? 0) === 0 && !span.props ? (
<span className="text-[var(--mu-text-lo)]">无事件/属性。</span>
) : null}
{span.events?.map((ev, i) => (
<div key={i} className="flex items-start gap-2">
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">{new Date(ev.time).toLocaleTimeString()}</span>
<span className="font-medium">{ev.type}</span>
{ev.props ? (
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(ev.props)}</code>
) : null}
</div>
))}
{span.props ? (
<code className="block break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(span.props)}</code>
) : null}
</div>
) : null}
</button>
);
}
/** 指标视图:快照由 observability_metrics 帧持续更新(有变化才发帧)。 */
export function MetricsPanel({ stream }: { stream: ObservabilityStream }): React.ReactNode {
const m = stream.metrics;
const topPaths = Object.entries(m?.perPath ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<WsBadge status={stream.wsStatus} />
<span className="font-mono text-xs text-[var(--mu-text-lo)]">WS observability_metrics</span>
<span className="ml-auto flex items-center gap-2">
<Freshness updatedAt={stream.metricsUpdatedAt} source={stream.metricsPushed ? "push" : "read"} />
<RefreshButton onClick={stream.reloadMetrics} what="指标" />
</span>
</div>
<CardGrid>
<StatCard
icon={<Icon name="gear" size={18} />}
label="请求 QPS"
value={m ? m.qps.toFixed(1) : "—"}
sub={m ? `总请求 ${m.totalRequests}` : "网关指标加载中"}
tone="accent"
/>
<StatCard
icon={<Icon name="robot" size={18} />}
label="LLM 调用次数"
value={m ? m.llmCalls : "—"}
sub="从 trace span 累计"
tone="neutral"
/>
<StatCard
icon={<Icon name="agent" size={18} />}
label="子代理事件"
value={m ? m.subagentEvents : "—"}
sub="从 trace span 累计"
tone="ok"
/>
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本后端暂不统计" tone="warn" />
</CardGrid>
{stream.metricsError ? <Notice tone="warn">指标后端暂不可用:{stream.metricsError}</Notice> : null}
{m && (topPaths.length > 0 || Object.keys(m.statusCounts).length > 0) ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">请求分布(真实网关计数)</h3>
{topPaths.length > 0 ? (
<div className="space-y-1">
{topPaths.map(([path, count]) => (
<div key={path} className="flex items-center gap-2 text-xs">
<code className="min-w-0 flex-1 break-all font-mono text-[var(--mu-text-mid)]">{path}</code>
<span className="text-[var(--mu-text-lo)]">{count}</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--mu-surface-3)]">
<div
className="h-full rounded-full bg-[var(--mu-accent)]/60"
style={{ width: `${((count / (m.totalRequests || 1)) * 100).toFixed(1)}%` }}
/>
</div>
</div>
))}
</div>
) : null}
{Object.keys(m.statusCounts).length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{Object.entries(m.statusCounts).map(([code, count]) => (
<Tag key={code} tone={code === "200" ? "ok" : Number(code) >= 500 ? "err" : "warn"}>
{code} × {count}
</Tag>
))}
<span className="text-xs text-[var(--mu-text-lo)]">
平均 {m.avgLatencyMs.toFixed(1)}ms · 最大 {m.maxLatencyMs}ms
</span>
</div>
) : null}
</Surface>
) : null}
<Notice>
指标快照只在变化时推送(空转不发帧),因此这里没有轮询也没有固定刷新节奏。LLM 调用次数 / 子代理事件由实际观测到的 trace span
累计;Token / 成本后端暂不统计(不伪造指标)。
</Notice>
<ReadonlyScopeNote />
</div>
);
}
/** 把纳秒时长渲染成人读刻度。 */
function fmtDuration(ns: number): string {
if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)} s`;
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`;
if (ns >= 1e3) return `${(ns / 1e3).toFixed(1)} µs`;
return `${ns} ns`;
}