84463394d4
清单里那批小毛病,逐条复核后修(「审计详情列缺失」那条已不成立,早补上了)。
1. 审计筛选只在当前页生效 —— 影响最大的一条。分页是服务端的,筛选却在
前端对已取回的 50 条做,于是搜一个用户 ID 显示"无结果"时,后面几页
可能还有几百条。审计的用途就是查证,"搜不到"会被读成"没发生过"。
改为 action/path/q 三个条件全部落到 SQL,前端只管发条件(防抖 300ms)。
2. 服务状态页把 mcp-go/mcp-py 的工具数写死成 23/4 —— 增删工具后一直骗人,
且服务离线时照样显示,看不出工具其实一个都没注册上。改取实际上报值。
3. 模型删除一点即删,无任何确认。补二次确认,并对"正在使用中"的模型
单独说明后果(删掉会立刻打断线上对话/向量能力)。
审计筛选补了 4 组回归测试,两条是踩出来的坑:
- q 的 OR 组必须带括号:gorm 以 AND 拼接各 Where,裸 OR 会让 action
条件被绕过(测试里用"同 IP 不同方法"两行钉死这个语义);
- LIKE 必须显式写 ESCAPE '\':Postgres 默认拿反斜杠当转义符,SQLite
不写就没有转义符——原来的写法在单测里静默失效,搜 "100%" 命中 0 条。
顺带把 ILIKE 换成 LOWER()+LIKE,这段才能被内存库覆盖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { listAudit, type AuditEntry } from "../api";
|
||
|
||
// 操作方法配色
|
||
const ACTION_STYLE: Record<string, string> = {
|
||
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
|
||
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
|
||
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
|
||
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
|
||
GET: "bg-gray-50 text-gray-600 border border-gray-100",
|
||
};
|
||
|
||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统");
|
||
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
|
||
|
||
const LIMIT = 20;
|
||
|
||
const ROUTE_OPTS = [
|
||
{ value: "", label: "所有路由" },
|
||
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
|
||
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
|
||
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
|
||
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
|
||
];
|
||
|
||
export function AuditPage() {
|
||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||
const [err, setErr] = useState("");
|
||
|
||
// 筛选器状态
|
||
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
|
||
const [routeFilter, setRouteFilter] = useState<string>("");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
// 分页状态
|
||
const [page, setPage] = useState(1);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
|
||
const load = async (pageNum = page, showRef = false) => {
|
||
if (showRef) setRefreshing(true);
|
||
else setLoading(true);
|
||
|
||
try {
|
||
const offset = (pageNum - 1) * LIMIT;
|
||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||
const data = await listAudit(LIMIT + 1, offset, {
|
||
action: methodFilter,
|
||
path: routeFilter,
|
||
q: searchQuery,
|
||
});
|
||
if (data.length > LIMIT) {
|
||
setAudit(data.slice(0, LIMIT));
|
||
setHasMore(true);
|
||
} else {
|
||
setAudit(data);
|
||
setHasMore(false);
|
||
}
|
||
setUpdatedAt(new Date());
|
||
setErr("");
|
||
} catch (er) {
|
||
setErr((er as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
void load(page);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [page]);
|
||
|
||
// 筛选条件变化 → 回第 1 页重新向服务端查询(防抖 300ms,免得每敲一个字打一次库)。
|
||
// 已在第 1 页时 setPage 不会触发上面的 effect,所以这里直接 load。
|
||
useEffect(() => {
|
||
const t = window.setTimeout(() => {
|
||
if (page !== 1) setPage(1);
|
||
else void load(1);
|
||
}, 300);
|
||
return () => window.clearTimeout(t);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [methodFilter, routeFilter, searchQuery]);
|
||
|
||
// 筛选已由服务端完成(全库匹配 + 分页),前端不再二次过滤:
|
||
// 否则会在"服务端已筛过的一页"上再筛一次,翻页计数与实际结果对不上。
|
||
const filteredAudit = audit;
|
||
|
||
const handlePrevPage = () => {
|
||
if (page > 1) setPage(page - 1);
|
||
};
|
||
|
||
const handleNextPage = () => {
|
||
if (hasMore) setPage(page + 1);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* 顶栏 */}
|
||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
|
||
<div>
|
||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">敏感操作审计</h3>
|
||
<p className="text-xs text-gray-400">敏感操作全链路留痕 · 只增不改,供合规审计与故障溯源</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||
{updatedAt && <span>最近更新:{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||
<button
|
||
onClick={() => void load(page, true)}
|
||
disabled={refreshing}
|
||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||
</svg>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 筛选工具栏 */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
{/* HTTP Method Button Group */}
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
|
||
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => { setMethodFilter(m); setPage(1); }}
|
||
className={`rounded px-3 py-1 font-semibold transition-all ${
|
||
methodFilter === m
|
||
? "bg-violet-600 text-white shadow-sm"
|
||
: "text-gray-500 hover:text-gray-700"
|
||
}`}
|
||
>
|
||
{m || "全部"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{/* Route prefix select */}
|
||
<select
|
||
value={routeFilter}
|
||
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
|
||
>
|
||
{ROUTE_OPTS.map((o) => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Search Input */}
|
||
<input
|
||
type="text"
|
||
placeholder="搜索操作者 ID / IP / 详情…"
|
||
value={searchQuery}
|
||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 审计日志列表 */}
|
||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
|
||
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
|
||
<div className="flex items-baseline gap-2">
|
||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作日志明细</h3>
|
||
<span className="text-xs text-gray-400">当前页展示 {filteredAudit.length} 条</span>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="py-12 text-center text-xs text-gray-400 animate-pulse">正在载入审计记录…</div>
|
||
) : err ? (
|
||
<div className="py-12 text-center text-xs text-rose-500">加载失败:{err}</div>
|
||
) : filteredAudit.length === 0 ? (
|
||
<div className="py-12 text-center text-xs text-gray-400">没有匹配的审计记录</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||
<th className="py-2 pr-3 font-medium w-16">方法</th>
|
||
<th className="py-2 pr-3 font-medium">路由路径</th>
|
||
<th className="py-2 pr-3 font-medium w-16">状态</th>
|
||
<th className="py-2 pr-3 font-medium">详情</th>
|
||
<th className="py-2 pr-3 font-medium w-24">操作人</th>
|
||
<th className="py-2 pr-3 font-medium w-28">客户端 IP</th>
|
||
<th className="py-2 font-medium w-36">触发时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredAudit.map((a) => (
|
||
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||
<td className="py-2.5 pr-3">
|
||
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||
{a.action}
|
||
</span>
|
||
</td>
|
||
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
|
||
{a.path}
|
||
</td>
|
||
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
|
||
{a.status}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
|
||
{a.detail || "—"}
|
||
</td>
|
||
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
|
||
{shortId(a.actor)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-400 font-mono">
|
||
{a.ip}
|
||
</td>
|
||
<td className="py-2.5 text-gray-400">
|
||
{fmt(a.at)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination controls */}
|
||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||
<span className="text-xs text-gray-400">
|
||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handlePrevPage}
|
||
disabled={page === 1}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
上一页
|
||
</button>
|
||
<button
|
||
onClick={handleNextPage}
|
||
disabled={!hasMore}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|