- 新增三个下行帧(纯新增,既有帧名/形状零改动):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 需新增「可观测实时推送」一节
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
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`;
|
||
}
|