Files
sundynix-agentix/sundynix-admin/src/pages/AuditPage.tsx
T
Blizzard 84463394d4 fix(admin): 审计筛选下沉 SQL + 工具数不再写死 + 模型删除加确认
清单里那批小毛病,逐条复核后修(「审计详情列缺失」那条已不成立,早补上了)。

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>
2026-07-20 13:51:59 +08:00

258 lines
11 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, 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>
);
}