feat(gateway,admin-web): 可观测面从轮询改为 WS 帧推送(裁决 05.4.3 + 16.4.3-WS)
- 新增三个下行帧(纯新增,既有帧名/形状零改动):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:
1 parent
74b12d87ca
commit
0e1f163ef9
10 files changed
+1449
-312
No files matched your search
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user