diff --git a/src/app/(main)/audit-logs/page.tsx b/src/app/(main)/audit-logs/page.tsx new file mode 100644 index 0000000..a6e3cb1 --- /dev/null +++ b/src/app/(main)/audit-logs/page.tsx @@ -0,0 +1,5 @@ +import { AuditLogPanel } from "@/components/audit/AuditLogPanel"; + +export default function AuditLogsPage() { + return ; +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 29511e2..3b76364 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -26,6 +26,7 @@ import { AiOutlineSecurityScan } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { ManageAccounts as ManageAccountsIcon, + FactCheck as FactCheckIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -270,6 +271,14 @@ const App = (props: React.PropsWithChildren) => { label: "系统管理", }, }, + { + name: "审计日志", + list: "/audit-logs", + meta: { + icon: , + label: "审计日志", + }, + }, ] : []), ]} diff --git a/src/components/audit/AuditLogPanel.tsx b/src/components/audit/AuditLogPanel.tsx new file mode 100644 index 0000000..d82c5e7 --- /dev/null +++ b/src/components/audit/AuditLogPanel.tsx @@ -0,0 +1,1055 @@ +"use client"; + +import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + alpha, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + LinearProgress, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import "dayjs/locale/zh-cn"; +import dayjs from "dayjs"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { + AdminPanelSettings as AdminPanelSettingsIcon, + Close as CloseIcon, + Download as DownloadIcon, + FactCheck as FactCheckIcon, + FilterAltOff as FilterAltOffIcon, + InfoOutlined as InfoOutlinedIcon, + Refresh as RefreshIcon, + Search as SearchIcon, + Visibility as VisibilityIcon, +} from "@mui/icons-material"; +import { config } from "@config/config"; +import { apiFetch } from "@/lib/apiFetch"; + +type AuditLog = { + id: string; + user_id: string | null; + project_id: string | null; + action: string; + resource_type: string | null; + resource_id: string | null; + ip_address: string | null; + request_method: string | null; + request_path: string | null; + request_data: Record | null; + response_status: number | null; + timestamp: string; +}; + +type MetadataUser = { + id: string; + username: string; + email: string; +}; + +type AdminProject = { + project_id: string; + name: string; + code: string; +}; + +type AuditFilters = { + user_id: string; + project_id: string; + action: string; + resource_type: string; + status: AuditStatusFilter; + start_time: string; + end_time: string; +}; + +type AuditStatusFilter = + | "all" + | "success" + | "redirect" + | "client_error" + | "server_error" + | "no_status"; + +const defaultFilters: AuditFilters = { + user_id: "", + project_id: "", + action: "", + resource_type: "", + status: "all", + start_time: "", + end_time: "", +}; + +const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ + { value: "all", label: "全部状态" }, + { value: "success", label: "成功 2xx" }, + { value: "redirect", label: "重定向 3xx" }, + { value: "client_error", label: "客户端错误 4xx" }, + { value: "server_error", label: "服务端错误 5xx" }, + { value: "no_status", label: "无响应状态" }, +]; + +const cardSx = { + borderRadius: 2, + borderColor: "divider", + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)", +}; + +const tableSx = { + minWidth: 1080, + "& .MuiTableCell-head": { + bgcolor: "action.hover", + color: "text.secondary", + fontSize: 12, + fontWeight: 700, + letterSpacing: 0, + }, + "& .MuiTableRow-root:last-child .MuiTableCell-body": { + borderBottom: 0, + }, +}; + +const selectMenuProps = { + disableScrollLock: true, +}; + +const readErrorText = async (response: Response) => { + const text = await response.text(); + return text || `HTTP ${response.status}`; +}; + +const normalizeDateTime = (value: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString(); +}; + +const formatDateTime = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +}; + +const getStatusMeta = (status: number | null) => { + if (status == null) { + return { label: "无状态", color: "default" as const }; + } + if (status >= 200 && status < 300) { + return { label: String(status), color: "success" as const }; + } + if (status >= 300 && status < 400) { + return { label: String(status), color: "info" as const }; + } + if (status >= 400 && status < 500) { + return { label: String(status), color: "warning" as const }; + } + if (status >= 500) { + return { label: String(status), color: "error" as const }; + } + return { label: String(status), color: "default" as const }; +}; + +const matchesStatusFilter = (log: AuditLog, filter: AuditStatusFilter) => { + const status = log.response_status; + if (filter === "all") return true; + if (filter === "no_status") return status == null; + if (status == null) return false; + if (filter === "success") return status >= 200 && status < 300; + if (filter === "redirect") return status >= 300 && status < 400; + if (filter === "client_error") return status >= 400 && status < 500; + if (filter === "server_error") return status >= 500; + return true; +}; + +const appendCsvValue = (value: unknown) => { + const text = + typeof value === "string" + ? value + : value == null + ? "" + : JSON.stringify(value); + return `"${text.replaceAll('"', '""')}"`; +}; + +const buildCsv = (logs: AuditLog[], userMap: Map, projectMap: Map) => { + const header = [ + "时间", + "操作者", + "用户ID", + "项目", + "项目ID", + "动作", + "资源类型", + "资源ID", + "状态", + "方法", + "路径", + "IP", + "请求数据", + ]; + const rows = logs.map((log) => [ + formatDateTime(log.timestamp), + log.user_id ? userMap.get(log.user_id)?.username ?? "" : "", + log.user_id ?? "", + log.project_id ? projectMap.get(log.project_id)?.name ?? "" : "", + log.project_id ?? "", + log.action, + log.resource_type ?? "", + log.resource_id ?? "", + log.response_status ?? "", + log.request_method ?? "", + log.request_path ?? "", + log.ip_address ?? "", + log.request_data ?? "", + ]); + + return [header, ...rows] + .map((row) => row.map(appendCsvValue).join(",")) + .join("\n"); +}; + +const buildServerParams = (filters: AuditFilters, skip: number, limit: number) => { + const params = new URLSearchParams(); + if (filters.user_id) params.set("user_id", filters.user_id); + if (filters.project_id) params.set("project_id", filters.project_id); + if (filters.action.trim()) params.set("action", filters.action.trim()); + if (filters.resource_type.trim()) { + params.set("resource_type", filters.resource_type.trim()); + } + const startTime = normalizeDateTime(filters.start_time); + const endTime = normalizeDateTime(filters.end_time); + if (startTime) params.set("start_time", startTime); + if (endTime) params.set("end_time", endTime); + params.set("skip", String(skip)); + params.set("limit", String(limit)); + return params; +}; + +const buildCountParams = (filters: AuditFilters) => { + const params = buildServerParams(filters, 0, 1); + params.delete("skip"); + params.delete("limit"); + return params; +}; + +const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( + + + {label} + + +); + +const StatusChip = ({ status }: { status: number | null }) => { + const meta = getStatusMeta(status); + return ; +}; + +const DetailLine = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( + + + {label} + + + {value || "-"} + + +); + +const DetailSection = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( + + + {title} + + + {children} + + +); + +export const AuditLogPanel = () => { + const [adminChecked, setAdminChecked] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); + const [logs, setLogs] = useState([]); + const [users, setUsers] = useState([]); + const [projects, setProjects] = useState([]); + const [filters, setFilters] = useState(defaultFilters); + const [appliedFilters, setAppliedFilters] = useState(defaultFilters); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + const [error, setError] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const [lastLoadedAt, setLastLoadedAt] = useState(null); + + const userMap = useMemo( + () => new Map(users.map((user) => [user.id, user])), + [users], + ); + const projectMap = useMemo( + () => new Map(projects.map((project) => [project.project_id, project])), + [projects], + ); + const usesClientStatusFilter = appliedFilters.status !== "all"; + + const visibleLogs = useMemo(() => { + if (!usesClientStatusFilter) return logs; + const filtered = logs.filter((log) => matchesStatusFilter(log, appliedFilters.status)); + return filtered.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + }, [appliedFilters.status, logs, page, rowsPerPage, usesClientStatusFilter]); + + const loadOptions = useCallback(async () => { + const [usersResult, projectsResult] = await Promise.allSettled([ + apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`), + apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`), + ]); + + if (usersResult.status === "fulfilled" && usersResult.value.ok) { + setUsers((await usersResult.value.json()) as MetadataUser[]); + } + if (projectsResult.status === "fulfilled" && projectsResult.value.ok) { + setProjects((await projectsResult.value.json()) as AdminProject[]); + } + }, []); + + const loadLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + if (appliedFilters.status === "all") { + const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); + const countParams = buildCountParams(appliedFilters); + const [logsResponse, countResponse] = await Promise.all([ + apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), + apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), + ]); + if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); + if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); + const countPayload = (await countResponse.json()) as { count?: number }; + setLogs((await logsResponse.json()) as AuditLog[]); + setTotalCount(Number(countPayload.count ?? 0)); + } else { + const params = buildServerParams(appliedFilters, 0, 1000); + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + if (!response.ok) throw new Error(await readErrorText(response)); + const payload = (await response.json()) as AuditLog[]; + setLogs(payload); + setTotalCount(payload.filter((log) => matchesStatusFilter(log, appliedFilters.status)).length); + } + setLastLoadedAt(new Date().toISOString()); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }, [appliedFilters, page, rowsPerPage]); + + useEffect(() => { + let cancelled = false; + + const checkAdmin = async () => { + try { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + if (cancelled) return; + if (!response.ok) { + setIsAuthorized(false); + setAdminChecked(true); + return; + } + const payload = await response.json(); + setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin")); + setAdminChecked(true); + } catch (err) { + if (!cancelled) { + setIsAuthorized(false); + setAdminChecked(true); + setError(String(err)); + } + } + }; + + void checkAdmin(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!adminChecked || !isAuthorized) return; + void loadOptions(); + }, [adminChecked, isAuthorized, loadOptions]); + + useEffect(() => { + if (!adminChecked || !isAuthorized) return; + void loadLogs(); + }, [adminChecked, isAuthorized, loadLogs]); + + const applyFilters = (event: FormEvent) => { + event.preventDefault(); + setPage(0); + setAppliedFilters(filters); + }; + + const resetFilters = () => { + setFilters(defaultFilters); + setAppliedFilters(defaultFilters); + setPage(0); + }; + + const exportLogs = async () => { + setExporting(true); + setError(null); + try { + const params = buildServerParams(appliedFilters, 0, 1000); + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + if (!response.ok) throw new Error(await readErrorText(response)); + const payload = ((await response.json()) as AuditLog[]).filter((log) => + matchesStatusFilter(log, appliedFilters.status), + ); + const csv = buildCsv(payload, userMap, projectMap); + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = href; + link.download = `tjwater-audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(href); + } catch (err) { + setError(String(err)); + } finally { + setExporting(false); + } + }; + + return ( + + + ({ + ...cardSx, + p: { xs: 2, md: 3 }, + bgcolor: + theme.palette.mode === "dark" + ? alpha(theme.palette.info.main, 0.1) + : alpha(theme.palette.info.main, 0.04), + })} + > + + + ({ + width: 48, + height: 48, + borderRadius: 2, + display: "grid", + placeItems: "center", + color: "info.main", + bgcolor: alpha(theme.palette.info.main, 0.12), + })} + > + + + + + 审计日志 + + + 管理员审计查询 + + + + {adminChecked && isAuthorized && ( + } + label="管理员权限已验证" + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + )} + + + + {!adminChecked && ( + + + + 正在校验审计权限 + + + )} + + {adminChecked && !isAuthorized && ( + + 无审计日志访问权限 + + )} + + {error && ( + setError(null)} sx={{ borderRadius: 2 }}> + {error} + + )} + + {adminChecked && isAuthorized && ( + <> + + + + + 匹配记录 + + + {totalCount} + + + + + + + 最近刷新 + + + {lastLoadedAt ? formatDateTime(lastLoadedAt) : "-"} + + + + + + + + + + 操作者 + + + + 项目 + + + + setFilters((current) => ({ ...current, action: event.target.value })) + } + sx={{ minWidth: { xs: "100%", md: 180 } }} + /> + + setFilters((current) => ({ ...current, resource_type: event.target.value })) + } + sx={{ minWidth: { xs: "100%", md: 180 } }} + /> + + + + + 状态 + + + + setFilters((current) => ({ + ...current, + start_time: value?.isValid() ? value.toISOString() : "", + })) + } + maxDateTime={filters.end_time ? dayjs(filters.end_time) : undefined} + slotProps={{ + textField: { + size: "small", + sx: { minWidth: { xs: "100%", md: 220 } }, + }, + }} + /> + + setFilters((current) => ({ + ...current, + end_time: value?.isValid() ? value.toISOString() : "", + })) + } + minDateTime={filters.start_time ? dayjs(filters.start_time) : undefined} + slotProps={{ + textField: { + size: "small", + sx: { minWidth: { xs: "100%", md: 220 } }, + }, + }} + /> + + + + + + + + + + + + {usesClientStatusFilter && ( + } sx={{ borderRadius: 2 }}> + 状态筛选基于当前查询最多 1000 条记录处理。 + + )} + + + + + + 查询结果 + + + {totalCount} 条匹配记录 + + + + + + {loading && } + + + + + + 时间 + 操作者 + 动作 + 资源 + 状态 + 请求 + IP + 详情 + + + + {visibleLogs.length === 0 && ( + + )} + {visibleLogs.map((log) => { + const user = log.user_id ? userMap.get(log.user_id) : null; + return ( + + + {formatDateTime(log.timestamp)} + + + + + {user?.username ?? "未知用户"} + + + {log.user_id ?? "-"} + + + + + + {log.action} + + + + + + {log.resource_type ?? "-"} + + + {log.resource_id ?? "-"} + + + + + + + + + + {log.request_method ?? "-"} + + + {log.request_path ?? "-"} + + + + {log.ip_address ?? "-"} + + + setSelectedLog(log)}> + + + + + + ); + })} + +
+
+ setPage(nextPage)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(event) => { + setRowsPerPage(Number(event.target.value)); + setPage(0); + }} + SelectProps={{ MenuProps: selectMenuProps }} + rowsPerPageOptions={[10, 25, 50, 100]} + labelRowsPerPage="每页行数" + /> +
+ + )} +
+ + setSelectedLog(null)} + maxWidth="md" + fullWidth + disableScrollLock + transitionDuration={{ enter: 120, exit: 0 }} + > + + + + 审计详情 + + + {selectedLog ? formatDateTime(selectedLog.timestamp) : ""} + + + setSelectedLog(null)} + sx={{ position: "absolute", right: 12, top: 12 }} + > + + + + + {selectedLog && ( + + ({ + p: 2, + borderRadius: 2, + bgcolor: alpha(theme.palette.info.main, 0.05), + borderColor: alpha(theme.palette.info.main, 0.2), + })} + > + + + + 审计动作 + + + {selectedLog.action} + + + {selectedLog.request_method ?? "-"} {selectedLog.request_path ?? "-"} + + + + + + + + + + + + + + + + + + + + + } + /> + + + + + + + 请求数据 + + + + + {selectedLog.request_data + ? JSON.stringify(selectedLog.request_data, null, 2) + : "{}"} + + + + )} + + + + + +
+ ); +};