c02ebc7bce
审计 P1「admin 三页纯 mock」之二。此前 GuardrailsPage 是写死的正则/敏感词 编辑框(改了不生效的假配置)+编造拦截日志。后端 /admin/guardrail-events 早已 现成(T4.B),纯前端做实。 - 命中事件流接真数据(guardrail_event,middleware.Guardrail 命中即落库): blocked 硬拦/suspect 灰区放行,带原因/信号/路径/来源;计数卡片+原因 Top 分布 (客户端从近100条聚合)+按 kind 筛。 - 诚实处理假配置:护栏规则(Tier1 正则/词库 + Tier2 LLM 分类器)是中间件代码 常量、非运行时可配,故删掉"能改却不生效"的编辑框,换成只读规则说明 + 指出 运行时可配需另建配置存储(参考提示词控制面)。不摆假控件。 live:触发注入越狱输入→422 硬拦+落库(reason「疑似提示词注入」)→页面渲染 真事件流+原因分布。tsc+41 vitest 全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
8.9 KiB
TypeScript
174 lines
8.9 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { guardrailEvents, type GuardrailEvent } from "../api";
|
||
|
||
// 输入护栏观测:真数据(来自 guardrail_event,middleware.Guardrail 命中即落库)。
|
||
// 命中事件流(blocked 硬拦 / suspect 灰区放行→Tier2 LLM 裁决) + 计数 + 原因分布。
|
||
//
|
||
// 诚实边界:护栏规则(Tier1 正则/敏感词 + Tier2 LLM 分类器)目前是中间件里的代码常量,
|
||
// 尚不支持运行时编辑(改规则需改代码重部署)。故本页只做观测,不摆"能改却不生效"的假配置面。
|
||
// 若要规则运行时可配,需另建配置存储 + 中间件读库(参考 prompt 控制面的热下发)。
|
||
|
||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600", suspect: "bg-amber-50 text-amber-600" };
|
||
const KIND_LABEL: Record<string, string> = { blocked: "硬拦截", suspect: "灰区放行" };
|
||
|
||
// 解析 signals(JSON 数组字符串)为可读标签。
|
||
function signalLabels(raw: string): string[] {
|
||
if (!raw) return [];
|
||
try {
|
||
const arr = JSON.parse(raw);
|
||
return Array.isArray(arr) ? arr.map(String) : [];
|
||
} catch {
|
||
return [raw];
|
||
}
|
||
}
|
||
|
||
export function GuardrailsPage() {
|
||
const [events, setEvents] = useState<GuardrailEvent[]>([]);
|
||
const [filter, setFilter] = useState<"" | "blocked" | "suspect">("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState("");
|
||
|
||
const load = () => {
|
||
setLoading(true);
|
||
guardrailEvents(100)
|
||
.then((r) => {
|
||
setEvents(r);
|
||
setErr("");
|
||
})
|
||
.catch((e) => setErr((e as Error).message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
useEffect(load, []);
|
||
|
||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||
// 原因/信号 Top(真实命中分布)。
|
||
const topReasons = useMemo(() => {
|
||
const m = new Map<string, number>();
|
||
for (const e of events) {
|
||
const keys = e.kind === "blocked" ? [e.reason || "未标注"] : signalLabels(e.signals);
|
||
for (const k of keys.length ? keys : ["未标注"]) m.set(k, (m.get(k) ?? 0) + 1);
|
||
}
|
||
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||
}, [events]);
|
||
|
||
const shown = filter ? events.filter((e) => e.kind === filter) : events;
|
||
|
||
if (loading) return <div className="text-sm text-gray-400">加载护栏事件中…</div>;
|
||
if (err) return <div className="text-sm text-rose-500">护栏事件加载失败:{err}</div>;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-gray-800">输入护栏</h2>
|
||
<p className="text-xs text-gray-400">命中事件流 · 硬拦截与灰区放行 · 原因分布(全平台真实命中)</p>
|
||
</div>
|
||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">刷新</button>
|
||
</div>
|
||
|
||
{/* 计数 */}
|
||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||
<Stat label="硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒(近 100 条内)" />
|
||
<Stat label="灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 执行前裁决" />
|
||
<Stat label="命中总数" value={String(events.length)} tone="violet" sub="近 100 条护栏事件" />
|
||
</div>
|
||
|
||
{/* 规则说明(诚实:规则在代码里,非运行时可配) */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏规则</h4>
|
||
<div className="space-y-1.5 text-xs leading-relaxed text-gray-500">
|
||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600">blocked</span> 拒绝请求。</p>
|
||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||
<p className="text-gray-400">规则当前定义在网关中间件代码中,尚不支持运行时编辑(改规则需改代码重部署)。运行时可配需另建配置存储 + 中间件读库,参考「提示词」控制面的热下发。</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 原因分布 */}
|
||
{topReasons.length > 0 && (
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<h4 className="mb-3 text-sm font-semibold text-gray-700">命中原因 Top</h4>
|
||
<div className="space-y-2">
|
||
{topReasons.map(([reason, n]) => (
|
||
<div key={reason} className="flex items-center gap-3">
|
||
<div className="w-48 shrink-0 truncate text-xs text-gray-600" title={reason}>{reason}</div>
|
||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100">
|
||
<div className="h-full rounded-full bg-violet-400" style={{ width: `${(n / topReasons[0][1]) * 100}%` }} />
|
||
</div>
|
||
<div className="w-8 text-right text-xs tabular-nums text-gray-500">{n}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 事件流 */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-700">命中事件流</h4>
|
||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||
<button key={v} onClick={() => setFilter(v)}
|
||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{shown.length === 0 ? (
|
||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有可疑输入</div>
|
||
) : (
|
||
<div className="max-h-96 overflow-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="sticky top-0 bg-white">
|
||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||
<th className="py-2 pr-3 font-medium">时间</th>
|
||
<th className="py-2 pr-3 font-medium">类型</th>
|
||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||
<th className="py-2 pr-3 font-medium">路径</th>
|
||
<th className="py-2 font-medium">来源</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{shown.map((e) => (
|
||
<tr key={e.id} className="border-b border-gray-50 last:border-0">
|
||
<td className="py-2 pr-3 text-xs text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||
<td className="py-2 pr-3">
|
||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>{KIND_LABEL[e.kind] ?? e.kind}</span>
|
||
</td>
|
||
<td className="py-2 pr-3">
|
||
{e.kind === "blocked" ? (
|
||
<span className="text-xs text-gray-700">{e.reason || "—"}</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{signalLabels(e.signals).map((sig, i) => (
|
||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||
))}
|
||
{signalLabels(e.signals).length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-2 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||
<td className="py-2 text-[11px] text-gray-400">{e.actor || e.ip || "匿名"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const TONE: Record<string, string> = { rose: "text-rose-500", amber: "text-amber-600", violet: "text-violet-600" };
|
||
function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) {
|
||
return (
|
||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||
<div className="text-xs text-gray-400">{label}</div>
|
||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
|
||
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
|
||
</div>
|
||
);
|
||
}
|