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 = { 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 = { 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 = { 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 实时通道 · {cur.label}; } /** 数据新鲜度:推送来的数据随空闲时间走动;只有引导读取时如实说明。 */ 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 ( {source === "push" ? "WS 推送" : "引导读取"} {age >= 0 ? ` · ${age}s 前更新` : " · 尚未更新"} ); } /** 手动刷新按钮:实时通道断开或需要立刻对账时用(不是轮询)。 */ function RefreshButton({ onClick, what }: { onClick: () => void; what: string }): React.ReactNode { return ( ); } /** 只读档说明:推送只发 owner/admin(design/10 §5),viewer 仍走 REST, * 因此这里如实说明并指向「刷新」,而不是让画面看起来还在实时刷新。 */ function ReadonlyScopeNote(): React.ReactNode { const role = getStoredRole(); if (role === null || role === "owner" || role === "admin") return null; return ( 当前凭据是只读档({role}):实时推送只发 owner/admin,请用「刷新」按需取数。 ); } /** 实时日志 tab:后端分级 tail(服务端推送)+ 本端 WS 回声(见 Observability.tsx)。 */ export function LogsPanel({ stream }: { stream: ObservabilityStream }): React.ReactNode { const [level, setLevel] = useState(""); const rows: LogRow[] = stream.logs.filter((l) => matchesLevel(l.level, level)); return (

实时日志(后端分级 tail)

WS observability_log · 引导 GET /api/v1/admin/observability/logs
{LOG_LEVEL_OPTIONS.map((f) => ( ))} 显示 {rows.length} / 保留 {stream.logs.length} 条 · 上限 200 {level ? ` · 本地筛选 ${level} 及以上` : ""}
{stream.logsLoading && stream.logs.length === 0 ? ( 加载日志… ) : stream.logsError && stream.logs.length === 0 ? ( 日志端点暂不可用:{stream.logsError} ) : rows.length === 0 ? ( 暂无日志{level ? `(本地筛选 level≥${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。 ) : ( <> {rows.length > LOG_VIRTUAL_THRESHOLD ? (
虚拟滚动 · 仅挂载视口内的行
) : null} l.key} renderRow={(l) => } rowHeight={22} threshold={LOG_VIRTUAL_THRESHOLD} maxHeight="min(60vh, 640px)" /> )} {stream.logsError && stream.logs.length > 0 ? ( 上次刷新失败(保留现有日志):{stream.logsError} ) : null} {stream.wsStatus !== "connected" ? ( 实时通道未连接:以上是最近一次引导读取的内容,连上后新日志会自动推来(也可点「刷新」立刻取一次)。 ) : null}
); } /** 一条等宽日志行:time · level · source · msg(source 不空白)。 */ function LogRow({ line }: { line: LogRow }): React.ReactNode { return (
{fmtLogTime(line.time)} {line.level || "info"} {line.source || "—"} {line.msg}
); } /** Tracing 视图:新 span 由推送追进列表,展开仍按 id 记忆。 */ export function TracePanel({ stream }: { stream: ObservabilityStream }): React.ReactNode { const [openId, setOpenId] = useState(null); return (

Tracing 视图

WS observability_trace · 引导 GET /api/v1/admin/observability/trace {stream.spans.length > VIRTUAL_THRESHOLD ? " · 虚拟滚动" : ""}
{stream.spansLoading && stream.spans.length === 0 ? ( 加载 span 数据… ) : stream.spansError && stream.spans.length === 0 ? ( trace 后端暂不可用:{stream.spansError} ) : stream.spans.length === 0 ? ( 暂无 span。接入 /ws/chat 发起会话后,LLM / 工具 / 编排 / 子代理等 span 会实时推来这里。 ) : ( sp.id} renderRow={(sp) => ( setOpenId(openId === sp.id ? null : sp.id)} /> )} rowClassName="pb-1.5" rowHeight={72} maxHeight="min(60vh, 640px)" /> )} 数据源:gateway trace 环形缓冲(经 TraceSink 对接 runtime 的 tracer);推送只发订阅之后新完成的 span,历史由引导读取提供。
); } function SpanRow({ span, open, onToggle }: { span: AdminTraceSpan; open: boolean; onToggle: () => void }): React.ReactNode { return ( ); } /** 指标视图:快照由 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 (
WS observability_metrics
} label="请求 QPS" value={m ? m.qps.toFixed(1) : "—"} sub={m ? `总请求 ${m.totalRequests}` : "网关指标加载中"} tone="accent" /> } label="LLM 调用次数" value={m ? m.llmCalls : "—"} sub="从 trace span 累计" tone="neutral" /> } label="子代理事件" value={m ? m.subagentEvents : "—"} sub="从 trace span 累计" tone="ok" /> } label="工时/Token" value="—" sub="成本后端暂不统计" tone="warn" /> {stream.metricsError ? 指标后端暂不可用:{stream.metricsError} : null} {m && (topPaths.length > 0 || Object.keys(m.statusCounts).length > 0) ? (

请求分布(真实网关计数)

{topPaths.length > 0 ? (
{topPaths.map(([path, count]) => (
{path} {count}
))}
) : null} {Object.keys(m.statusCounts).length > 0 ? (
{Object.entries(m.statusCounts).map(([code, count]) => ( = 500 ? "err" : "warn"}> {code} × {count} ))} 平均 {m.avgLatencyMs.toFixed(1)}ms · 最大 {m.maxLatencyMs}ms
) : null} ) : null} 指标快照只在变化时推送(空转不发帧),因此这里没有轮询也没有固定刷新节奏。LLM 调用次数 / 子代理事件由实际观测到的 trace span 累计;Token / 成本后端暂不统计(不伪造指标)。
); } /** 把纳秒时长渲染成人读刻度。 */ 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`; }