182 lines
8.1 KiB
TypeScript
182 lines
8.1 KiB
TypeScript
import type {
|
|
AdminOperationAuditItem,
|
|
AdminOperationAuditResponse,
|
|
PrivateContentAccessAuditItem,
|
|
PrivateContentAccessAuditResponse,
|
|
} from "@dada/shared-contracts";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import "./admin-audit.css";
|
|
|
|
type AuditTab = "operations" | "private-content";
|
|
|
|
interface AuditPageState<Item> {
|
|
failed: boolean;
|
|
generatedAt: string | null;
|
|
items: Item[];
|
|
loading: boolean;
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
const emptyState = <Item,>(): AuditPageState<Item> => ({
|
|
failed: false,
|
|
generatedAt: null,
|
|
items: [],
|
|
loading: false,
|
|
nextCursor: null,
|
|
});
|
|
|
|
function formatTime(value: string) {
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
month: "2-digit",
|
|
second: "2-digit",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function operationSummary(item: AdminOperationAuditItem) {
|
|
if (item.after_summary) return item.after_summary;
|
|
if (item.before_summary) return item.before_summary;
|
|
return "无变更摘要";
|
|
}
|
|
|
|
export function AdminAuditPage() {
|
|
const [tab, setTab] = useState<AuditTab>("operations");
|
|
const [operations, setOperations] = useState<AuditPageState<AdminOperationAuditItem>>(emptyState);
|
|
const [privateAccess, setPrivateAccess] = useState<AuditPageState<PrivateContentAccessAuditItem>>(emptyState);
|
|
|
|
const loadOperations = useCallback(async (cursor?: string, append = false) => {
|
|
setOperations((current) => ({ ...current, failed: false, loading: true }));
|
|
try {
|
|
const query = new URLSearchParams({ limit: "50" });
|
|
if (cursor) query.set("cursor", cursor);
|
|
const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" });
|
|
if (response.status === 401) {
|
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error("admin_operation_audit_unavailable");
|
|
const body = await response.json() as AdminOperationAuditResponse;
|
|
setOperations((current) => ({
|
|
failed: false,
|
|
generatedAt: body.generated_at,
|
|
items: append ? [...current.items, ...body.items] : body.items,
|
|
loading: false,
|
|
nextCursor: body.next_cursor,
|
|
}));
|
|
} catch {
|
|
setOperations((current) => ({ ...current, failed: true, loading: false }));
|
|
}
|
|
}, []);
|
|
|
|
const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => {
|
|
setPrivateAccess((current) => ({ ...current, failed: false, loading: true }));
|
|
try {
|
|
const query = new URLSearchParams({ limit: "50" });
|
|
if (cursor) query.set("cursor", cursor);
|
|
const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" });
|
|
if (response.status === 401) {
|
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error("private_content_audit_unavailable");
|
|
const body = await response.json() as PrivateContentAccessAuditResponse;
|
|
setPrivateAccess((current) => ({
|
|
failed: false,
|
|
generatedAt: body.generated_at,
|
|
items: append ? [...current.items, ...body.items] : body.items,
|
|
loading: false,
|
|
nextCursor: body.next_cursor,
|
|
}));
|
|
} catch {
|
|
setPrivateAccess((current) => ({ ...current, failed: true, loading: false }));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { void loadOperations(); }, [loadOperations]);
|
|
|
|
function selectTab(next: AuditTab) {
|
|
setTab(next);
|
|
if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess();
|
|
}
|
|
|
|
const state = tab === "operations" ? operations : privateAccess;
|
|
const reload = tab === "operations" ? loadOperations : loadPrivateAccess;
|
|
|
|
return (
|
|
<main className="admin-audit-page" id="admin-main">
|
|
<header className="admin-audit-heading">
|
|
<div><p>IMMUTABLE / 180 DAYS</p><h2>审计</h2></div>
|
|
{state.generatedAt ? <time dateTime={state.generatedAt}>读取于 {formatTime(state.generatedAt)}</time> : null}
|
|
</header>
|
|
|
|
<div aria-label="审计类型" className="admin-audit-tabs" role="tablist">
|
|
<button aria-controls="operation-audit-panel" aria-selected={tab === "operations"} id="operation-audit-tab" onClick={() => selectTab("operations")} role="tab" type="button">后台操作审计</button>
|
|
<button aria-controls="private-audit-panel" aria-selected={tab === "private-content"} id="private-audit-tab" onClick={() => selectTab("private-content")} role="tab" type="button">私有内容访问审计</button>
|
|
</div>
|
|
|
|
{state.failed ? (
|
|
<div className="admin-audit-failure" role="alert">
|
|
<span>审计记录暂时无法读取{state.generatedAt ? ",已保留上次结果" : ""}。</span>
|
|
<button disabled={state.loading} onClick={() => void reload()} type="button">重试</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{tab === "operations" ? (
|
|
<section aria-labelledby="operation-audit-tab" id="operation-audit-panel" role="tabpanel">
|
|
{operations.loading && operations.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取后台操作审计</p> : null}
|
|
{!operations.loading && !operations.failed && operations.items.length === 0 ? <p className="admin-audit-status">当前没有后台操作审计记录。</p> : null}
|
|
{operations.items.length > 0 ? (
|
|
<div className="admin-audit-table-scroll">
|
|
<table>
|
|
<thead><tr><th>时间</th><th>管理员</th><th>操作类型</th><th>对象安全摘要</th><th>结果</th><th>Operation ID</th></tr></thead>
|
|
<tbody>{operations.items.map((item) => (
|
|
<tr key={item.log_id}>
|
|
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
|
<td><code>{item.actor_ref}</code><small>{item.actor_type}</small></td>
|
|
<td><code>{item.operation_type}</code></td>
|
|
<td><code>{item.target_type}:{item.target_ref}</code><small>{operationSummary(item)}</small></td>
|
|
<td><strong className={`is-${item.result}`}>{item.result}</strong></td>
|
|
<td><code>{item.log_id}</code></td>
|
|
</tr>
|
|
))}</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
) : (
|
|
<section aria-labelledby="private-audit-tab" id="private-audit-panel" role="tabpanel">
|
|
{privateAccess.loading && privateAccess.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取私有内容访问审计</p> : null}
|
|
{!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ? <p className="admin-audit-status">当前没有私有内容访问审计记录。</p> : null}
|
|
{privateAccess.items.length > 0 ? (
|
|
<div className="admin-audit-table-scroll">
|
|
<table>
|
|
<thead><tr><th>时间</th><th>管理员</th><th>安全目标标识</th><th>内容类型</th><th>到期时间</th><th>Access ID</th></tr></thead>
|
|
<tbody>{privateAccess.items.map((item) => (
|
|
<tr key={item.log_id}>
|
|
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
|
<td><code>{item.actor_ref}</code></td>
|
|
<td><code>{item.target_ref}</code></td>
|
|
<td>{item.content_type}</td>
|
|
<td><time dateTime={item.expires_at}>{formatTime(item.expires_at)}</time></td>
|
|
<td><code>{item.log_id}</code></td>
|
|
</tr>
|
|
))}</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
)}
|
|
|
|
{state.nextCursor ? (
|
|
<div className="admin-audit-pagination">
|
|
<button disabled={state.loading} onClick={() => void reload(state.nextCursor!, true)} type="button">{state.loading ? "正在读取" : "下一页"}</button>
|
|
</div>
|
|
) : null}
|
|
<p className="admin-audit-retention">记录保留 180 天。此页面不提供编辑、删除或清空能力。</p>
|
|
</main>
|
|
);
|
|
}
|