Files
Cyrene_v2/frontend/web/src/admin/modules/observabilityViews.tsx
T
AskaEth 0e1f163ef9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 需新增「可观测实时推送」一节
2026-10-04 13:58:03 +08:00

364 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;
}