Files
Cyrene_v2/frontend/web/src/admin/modules/Observability.tsx
T
Aska e0a543cc37
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(observability): 管理面板可观测性接真实数据——metrics/trace/audit 三端点 + 审计持久化
- observability.go/observability_metrics.go/observability_trace.go/observability_audit.go:
  拆分保证 ≤500行/函数≤80。metrics 并发安全 MetricsCollector(方法/路径/状态计数、平均/最大延迟、
  近1s QPS窗口);trace 并发安全 RingBuffer(默认512, newest-first)+traceSink+MultiTraceWriter;
  audit AuditStore 接口+内存回退+SQLite 持久化(NewSQLiteAuditStore 参数化 SQL+audit_logs 表)。
- server.go:Deps.AuditStore + Server.obs + 3 路由 GET /admin/observability/{metrics,trace,audit};
  ObserveSpan/TraceSink 注入点;middleware logging recordMetric;audit 落库(仅 slog→Append)。
- 前端 Obs.tsx:metrics 接 4 张真实 StatCard+请求分布、trace 接 span 列表(展开 props/events)、
  audit 从后端拉(前端内存审计作回退);api.ts/types.ts 加 obs 端点/类型(AdminObservabilityMetrics/
  AdminTraceSpan/AuditRecord.id 放宽)。成本指标不伪造(保留'—'+注明)。
- wire.go 收口:Deps.AuditStore 注入 gateway.NewSQLiteAuditStore(ctx, a.db)(审计真落库),
  未注入回退内存。
- Docs/api-client.md §2/v0.6。

实机验证:三端点 200,metrics 返回 totalRequests:4/qps:4/perPath/statusCounts:{200:4}。
go build/vet/test 全绿 + 前端 tsc。trace 数据源对接(gw.TraceSink→loop tracer)因 gw/tracer
构造顺序留作可选后置(端点已就绪, 未对接 span 空)。
2026-09-06 21:02:46 +08:00

272 lines
12 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 { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } 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, adminObservabilityMetrics, adminObservabilityTrace } from "../../lib/api";
import type { AdminTraceSpan, AuditRecord } from "../../lib/types";
type Tab = "logs" | "trace" | "metrics" | "audit";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "logs", label: "实时日志" },
{ id: "trace", label: "Tracing" },
{ id: "metrics", label: "指标" },
{ id: "audit", label: "审计" },
];
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 deviceUpdates = useAppStore((s) => s.deviceUpdates);
const audit = useAuditStore((s) => s.records);
const clearAudit = useAuditStore((s) => s.clear);
return (
<div className="space-y-5">
<PageHeader title="可观测性" subtitle="实时日志 tail · Tracing(LLM/工具/编排) · 指标 · 审计" />
<div className="flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
tab === t.id ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "logs" ? <LogsTail lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : null}
</div>
);
}
function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">实时日志流(WS 事件回显)</h3>
<Tag tone="ok">tail</Tag>
</div>
{lines.length === 0 ? (
<Notice>暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。</Notice>
) : (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
{lines.join("\n")}
</pre>
)}
<Notice>完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。</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;
}
function AuditView({ records: local, onClear }: AuditViewProps): React.ReactNode {
const audit = useAsyncData(() => adminObservabilityAudit(100), []);
useAutoReload(audit.reload, 5000);
// Backend is the authoritative source; the local store is a fallback while the
// endpoint is unavailable.
const records = audit.data ?? local;
const source = audit.data ? "后端" : "本地(后端未接入)";
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">审计日志(who / what / when / result)</h3>
<Tag tone={audit.data ? "ok" : "warn"}>{source}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">管理 + 敏感操作全程落审计</span>
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={onClear}>
清空本地
</Button>
</span>
</div>
{audit.error && !audit.data ? <Notice tone="warn">审计端点暂不可用,回退本地记录:{audit.error}</Notice> : null}
{records.length === 0 ? (
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--mu-glass-border)] text-left text-xs uppercase tracking-wider text-[var(--mu-text-lo)]">
<th className="px-2 py-1.5">who</th>
<th className="px-2 py-1.5">what</th>
<th className="px-2 py-1.5">when</th>
<th className="px-2 py-1.5">result</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-b border-[var(--mu-glass-border)]/60 last:border-0">
<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>
</tr>
))}
</tbody>
</table>
)}
</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`;
}