- 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 空)。
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
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`;
|
||
}
|