Files
sundynix-agentix/sundynix-admin/src/pages/GuardrailsPage.tsx
T
Blizzard c02ebc7bce feat(admin): 「安全护栏」页做实 —— 接真护栏事件,去 mock (P1)
审计 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>
2026-07-18 12:07:32 +08:00

174 lines
8.9 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 { useEffect, useMemo, useState } from "react";
import { guardrailEvents, type GuardrailEvent } from "../api";
// 输入护栏观测:真数据(来自 guardrail_eventmiddleware.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>
);
}