feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard
This commit is contained in:
@@ -1,33 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
|
||||
import { listAudit, type AuditEntry } from "../api";
|
||||
|
||||
// 操作方法配色。
|
||||
// 操作方法配色
|
||||
const ACTION_STYLE: Record<string, string> = {
|
||||
POST: "bg-emerald-50 text-emerald-600",
|
||||
PUT: "bg-amber-50 text-amber-600",
|
||||
DELETE: "bg-rose-50 text-rose-600",
|
||||
PATCH: "bg-violet-50 text-violet-600",
|
||||
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");
|
||||
|
||||
// uid 太长,展示时截断(保留首尾,hover 看全)。
|
||||
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 [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
// 筛选器状态
|
||||
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 [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
|
||||
setAudit(a);
|
||||
setEvents(e);
|
||||
const offset = (pageNum - 1) * LIMIT;
|
||||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||||
const data = await listAudit(LIMIT + 1, offset);
|
||||
if (data.length > LIMIT) {
|
||||
setAudit(data.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setAudit(data);
|
||||
setHasMore(false);
|
||||
}
|
||||
setUpdatedAt(new Date());
|
||||
setErr("");
|
||||
} catch (er) {
|
||||
@@ -39,36 +66,47 @@ export function AuditPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载审计记录中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">加载失败:{err}</div>;
|
||||
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
|
||||
const filteredAudit = audit.filter((a) => {
|
||||
if (methodFilter && a.action !== methodFilter) return false;
|
||||
if (routeFilter && !a.path.startsWith(routeFilter)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = a.actor.toLowerCase().includes(q);
|
||||
const matchIp = a.ip.toLowerCase().includes(q);
|
||||
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false;
|
||||
const matchPath = a.path.toLowerCase().includes(q);
|
||||
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
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 gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">审计 & 安全事件</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作留痕 + 输入护栏命中 · 只增不改,供运维溯源</p>
|
||||
<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="grid grid-cols-3 divide-x divide-gray-100">
|
||||
<Stat value={audit.length} label="操作留痕" />
|
||||
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
|
||||
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{updatedAt && <span>更新于 {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<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()}
|
||||
onClick={() => void load(page, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||
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" />
|
||||
@@ -78,54 +116,134 @@ export function AuditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全事件(护栏命中) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">安全事件</h3>
|
||||
<span className="text-xs text-gray-400">输入护栏拦截 / 灰区放行({events.length})</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无护栏命中 🎉</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
|
||||
{e.kind === "blocked" ? "拦截" : "灰区"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
|
||||
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 操作审计 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作审计</h3>
|
||||
<span className="text-xs text-gray-400">改配置 / 改密钥 / 激活提示词 / 审批 等变更操作({audit.length})</span>
|
||||
<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>
|
||||
{audit.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无操作留痕</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="divide-y divide-gray-100">
|
||||
{audit.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
|
||||
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
|
||||
<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>
|
||||
@@ -133,11 +251,3 @@ export function AuditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user