AuditDashboard.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  1. import { useCallback, useEffect, useMemo, useState } from "react";
  2. import {
  3. Activity,
  4. BarChart2,
  5. Download,
  6. Filter,
  7. RefreshCw,
  8. ScrollText,
  9. Search,
  10. Users,
  11. X,
  12. } from "lucide-react";
  13. import {
  14. Bar,
  15. BarChart,
  16. CartesianGrid,
  17. Cell,
  18. Line,
  19. LineChart,
  20. Pie,
  21. PieChart,
  22. ResponsiveContainer,
  23. Tooltip,
  24. XAxis,
  25. YAxis,
  26. } from "recharts";
  27. import { AuditOperationResult } from "../../shared/api/enums";
  28. import { useDictionaries } from "../dictionaries/dictionaryContext";
  29. import {
  30. formatAuditDate,
  31. toUtcIso,
  32. } from "./auditLabels";
  33. import {
  34. useAuditBreakdowns,
  35. useAuditLogs,
  36. useAuditTrend,
  37. type RemoteState,
  38. } from "./auditState";
  39. import {
  40. AuditGranularity,
  41. type AuditActionStatisticsItem,
  42. type AuditLogQuery,
  43. type AuditTrendItem,
  44. type AuditUserStatisticsItem,
  45. } from "./auditTypes";
  46. import { systemApi } from "../system/systemApi";
  47. import {
  48. LOG_LEVELS,
  49. type SystemLogLevel,
  50. type SystemLogTail,
  51. } from "../system/systemTypes";
  52. const NAVY = "var(--dms-deep)";
  53. const BLUE = "var(--dms-bright)";
  54. const WHITE = "#fff";
  55. // 多彩配色(不再拘泥蓝色系):蓝为主色,辅以橙/绿/紫/红/青等,扇区按序取色。
  56. // 类别色板是内容语义色(计划例外),不随界面主题换肤。
  57. const COLORS = ["#3191eb", "#f5a623", "#34a853", "#a55eea", "#e74c3c",
  58. "#00bcd4", "#f8e71c", "#ff7043", "#7cb342", "#5c6bc0"];
  59. const EMPTY = "—";
  60. function ResourceState<T>({
  61. state,
  62. onRetry,
  63. label,
  64. }: {
  65. state: RemoteState<T>;
  66. onRetry: () => void;
  67. label: string;
  68. }) {
  69. if (state.loading && !state.data) {
  70. return (
  71. <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
  72. <RefreshCw size={12} className="inline animate-spin mr-2" />
  73. 正在加载{label}…
  74. </div>
  75. );
  76. }
  77. if (state.error) {
  78. return (
  79. <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
  80. <div>
  81. {state.error.httpStatus === 403
  82. ? `当前账号无权读取${label}`
  83. : state.error.message}
  84. </div>
  85. {state.error.requestId && (
  86. <div className="opacity-60 mt-1">
  87. 错误编号:{state.error.requestId}
  88. </div>
  89. )}
  90. <button
  91. onClick={onRetry}
  92. className="mt-2 px-3 py-1 text-white"
  93. style={{ background: BLUE }}
  94. >
  95. 重试
  96. </button>
  97. </div>
  98. );
  99. }
  100. if (Array.isArray(state.data) && state.data.length === 0) {
  101. return (
  102. <div className="p-4 text-base font-bold opacity-60" style={{ color: NAVY }}>
  103. 当前条件下暂无{label}
  104. </div>
  105. );
  106. }
  107. return null;
  108. }
  109. function StatsPanel({
  110. title,
  111. icon,
  112. children,
  113. }: {
  114. title: string;
  115. icon: React.ReactNode;
  116. children: React.ReactNode;
  117. }) {
  118. return (
  119. <section
  120. className="bg-white p-3 min-h-[170px]"
  121. style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
  122. >
  123. <div className="flex items-center gap-2 mb-2">
  124. {icon}
  125. <h3 className="text-base font-black" style={{ color: NAVY }}>
  126. {title}
  127. </h3>
  128. </div>
  129. {children}
  130. </section>
  131. );
  132. }
  133. /** 运行日志查看窗口(契约1.23,manageAuditLog 权限;读取服务端日志文件尾部)。 */
  134. function RuntimeLogModal({ onClose }: { onClose: () => void }) {
  135. const [lines, setLines] = useState(500);
  136. const [level, setLevel] = useState<SystemLogLevel | "">("");
  137. const [data, setData] = useState<SystemLogTail | null>(null);
  138. const [loading, setLoading] = useState(false);
  139. const [error, setError] = useState<string | null>(null);
  140. const [downloading, setDownloading] = useState(false);
  141. const load = useCallback(async () => {
  142. setLoading(true);
  143. setError(null);
  144. try {
  145. setData(await systemApi.readLogTail({ lines, level }));
  146. } catch (loadError) {
  147. setError(
  148. loadError instanceof Error ? loadError.message : "运行日志读取失败",
  149. );
  150. } finally {
  151. setLoading(false);
  152. }
  153. }, [lines, level]);
  154. useEffect(() => {
  155. void load();
  156. }, [load]);
  157. const handleDownload = async () => {
  158. setDownloading(true);
  159. setError(null);
  160. try {
  161. const result = await systemApi.downloadLog();
  162. const url = URL.createObjectURL(result.blob);
  163. const anchor = document.createElement("a");
  164. anchor.href = url;
  165. anchor.download = "dms-runtime.log";
  166. document.body.appendChild(anchor);
  167. anchor.click();
  168. anchor.remove();
  169. URL.revokeObjectURL(url);
  170. } catch (downloadError) {
  171. setError(
  172. downloadError instanceof Error ? downloadError.message : "日志下载失败",
  173. );
  174. } finally {
  175. setDownloading(false);
  176. }
  177. };
  178. const controlStyle = {
  179. borderColor: "rgba(var(--dms-bright-rgb),0.4)",
  180. color: NAVY,
  181. } as const;
  182. return (
  183. <div
  184. className="fixed inset-0 z-[80] flex items-center justify-center p-6"
  185. style={{ background: "rgba(0,0,0,0.5)" }}
  186. >
  187. <div
  188. className="w-full h-full max-w-5xl max-h-[85vh] rounded-sm shadow-2xl flex flex-col"
  189. style={{ background: WHITE }}
  190. >
  191. <div
  192. className="flex items-center justify-between px-5 py-3 border-b"
  193. style={{ background: BLUE, color: WHITE }}
  194. >
  195. <h3 className="text-base font-black tracking-wide flex items-center gap-2">
  196. <ScrollText size={16} />
  197. 运行日志
  198. </h3>
  199. <button
  200. type="button"
  201. onClick={onClose}
  202. className="opacity-80 hover:opacity-100"
  203. style={{ color: WHITE }}
  204. >
  205. <X size={16} />
  206. </button>
  207. </div>
  208. <div
  209. className="flex flex-wrap items-center gap-2 px-5 py-3 border-b"
  210. style={{ borderColor: "rgba(var(--dms-bright-rgb),0.2)", color: NAVY }}
  211. >
  212. <select
  213. value={lines}
  214. onChange={(e) => setLines(Number(e.target.value))}
  215. className="h-9 px-2 border text-base font-bold"
  216. style={controlStyle}
  217. >
  218. {[200, 500, 1000, 2000, 5000].map((count) => (
  219. <option key={count} value={count}>
  220. 尾部 {count} 行
  221. </option>
  222. ))}
  223. </select>
  224. <select
  225. value={level}
  226. onChange={(e) =>
  227. setLevel(e.target.value as SystemLogLevel | "")
  228. }
  229. className="h-9 px-2 border text-base font-bold"
  230. style={controlStyle}
  231. >
  232. <option value="">全部级别</option>
  233. {LOG_LEVELS.map((name) => (
  234. <option key={name} value={name}>
  235. {name}
  236. </option>
  237. ))}
  238. </select>
  239. <button
  240. type="button"
  241. onClick={() => void load()}
  242. disabled={loading}
  243. className="flex items-center gap-1 h-9 px-3 text-base font-bold rounded-sm border disabled:opacity-40"
  244. style={{ ...controlStyle, background: WHITE }}
  245. >
  246. <RefreshCw size={13} className={loading ? "animate-spin" : ""} />
  247. 刷新
  248. </button>
  249. <button
  250. type="button"
  251. onClick={() => void handleDownload()}
  252. disabled={downloading}
  253. className="flex items-center gap-1 h-9 px-3 text-base font-bold rounded-sm border disabled:opacity-40"
  254. style={{ ...controlStyle, background: WHITE }}
  255. >
  256. <Download size={13} />
  257. {downloading ? "下载中…" : "下载全文"}
  258. </button>
  259. </div>
  260. {error ? (
  261. <div className="flex-1 p-4 text-base font-bold" style={{ color: "#C0392B" }}>
  262. {error}
  263. </div>
  264. ) : (
  265. <div className="flex-1 flex flex-col min-h-0">
  266. <div
  267. className="px-5 py-2 text-sm border-b"
  268. style={{
  269. color: NAVY,
  270. borderColor: "rgba(var(--dms-bright-rgb),0.12)",
  271. }}
  272. >
  273. {data?.file ? (
  274. <>
  275. 文件:{data.file}
  276. {data.truncated && "(级别过滤为全量扫描后取尾部,非完整结果)"}
  277. </>
  278. ) : (
  279. "未配置运行日志文件(DMS_LOG_FILE),或文件尚未生成。"
  280. )}
  281. </div>
  282. <pre
  283. className="flex-1 overflow-auto px-4 py-2 m-0 text-sm leading-5"
  284. style={{ color: NAVY, fontFamily: "Consolas, 'Courier New', monospace" }}
  285. >
  286. {data?.error
  287. ? `读取失败:${data.error}`
  288. : data && data.lines.length > 0
  289. ? data.lines.join("\n")
  290. : "(无日志内容)"}
  291. </pre>
  292. </div>
  293. )}
  294. </div>
  295. </div>
  296. );
  297. }
  298. export function AuditDashboard() {
  299. const dictionaries = useDictionaries();
  300. const [keyword, setKeyword] = useState("");
  301. const [actionType, setActionType] = useState("");
  302. const [operationResult, setOperationResult] = useState("");
  303. const [targetType, setTargetType] = useState("");
  304. const [userId, setUserId] = useState("");
  305. const [organizationId, setOrganizationId] = useState("");
  306. const [targetId, setTargetId] = useState("");
  307. const [createdFromLocal, setCreatedFromLocal] = useState("");
  308. const [createdToLocal, setCreatedToLocal] = useState("");
  309. const [page, setPage] = useState(1);
  310. const [pageSize, setPageSize] = useState(20);
  311. const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
  312. const [granularity, setGranularity] =
  313. useState<AuditGranularity>(AuditGranularity.DAY);
  314. const [runtimeLogOpen, setRuntimeLogOpen] = useState(false);
  315. const createdFrom = toUtcIso(createdFromLocal);
  316. const createdTo = toUtcIso(createdToLocal);
  317. const query: AuditLogQuery = {
  318. keyword: keyword.trim() || undefined,
  319. actionType: actionType
  320. ? (actionType as AuditLogQuery["actionType"])
  321. : undefined,
  322. operationResult: operationResult
  323. ? (operationResult as AuditLogQuery["operationResult"])
  324. : undefined,
  325. targetType: targetType
  326. ? (targetType as AuditLogQuery["targetType"])
  327. : undefined,
  328. userId: userId.trim() || undefined,
  329. organizationId: organizationId.trim() || undefined,
  330. targetId: targetId.trim() || undefined,
  331. createdFrom,
  332. createdTo,
  333. page,
  334. pageSize,
  335. sortDirection,
  336. };
  337. const logs = useAuditLogs(query);
  338. const trend = useAuditTrend({ createdFrom, createdTo, granularity });
  339. const breakdowns = useAuditBreakdowns(
  340. { createdFrom, createdTo },
  341. { createdFrom, createdTo, limit: 6 },
  342. );
  343. const pieData = useMemo(
  344. () =>
  345. (breakdowns.actions.data ?? []).map((item) => ({
  346. name: dictionaries.label("auditActions", item.actionType),
  347. value: item.count,
  348. actionType: item.actionType,
  349. })),
  350. [breakdowns.actions.data, dictionaries.data],
  351. );
  352. const activeUsers = useMemo(
  353. () =>
  354. (breakdowns.users.data ?? []).map((item) => ({
  355. ...item,
  356. label: item.realName ?? `用户 ${item.userId}`,
  357. })),
  358. [breakdowns.users.data],
  359. );
  360. const resetPage = () => setPage(1);
  361. const clearFilters = () => {
  362. setKeyword("");
  363. setActionType("");
  364. setOperationResult("");
  365. setTargetType("");
  366. setUserId("");
  367. setOrganizationId("");
  368. setTargetId("");
  369. setCreatedFromLocal("");
  370. setCreatedToLocal("");
  371. setSortDirection("desc");
  372. setPage(1);
  373. };
  374. return (
  375. <>
  376. <aside
  377. className="w-[286px] flex-shrink-0 m-3 overflow-y-auto bg-white"
  378. style={{ border: "1px solid rgba(var(--dms-line-rgb),.2)", color: NAVY }}
  379. >
  380. <div
  381. className="px-4 py-3 flex items-center gap-2 border-b"
  382. style={{ borderColor: "rgba(var(--dms-line-rgb),.14)" }}
  383. >
  384. <Filter size={14} style={{ color: BLUE }} />
  385. <span className="text-base font-black">日志筛选</span>
  386. <button
  387. type="button"
  388. onClick={() => setRuntimeLogOpen(true)}
  389. className="ml-auto flex items-center gap-1 px-2 py-1 text-base font-bold rounded-sm border"
  390. style={{ borderColor: BLUE, color: NAVY }}
  391. title="查看服务端运行日志文件(需日志审计权限)"
  392. >
  393. <ScrollText size={13} />
  394. 运行日志
  395. </button>
  396. </div>
  397. <div className="p-3 space-y-2">
  398. <label className="flex h-9 items-center border px-2">
  399. <Search size={13} className="mr-2" style={{ color: BLUE }} />
  400. <input
  401. value={keyword}
  402. onChange={(event) => {
  403. setKeyword(event.target.value);
  404. resetPage();
  405. }}
  406. className="min-w-0 flex-1 outline-none text-base font-bold"
  407. placeholder="名称、用户、requestId"
  408. />
  409. </label>
  410. <select
  411. value={actionType}
  412. onChange={(event) => {
  413. setActionType(event.target.value);
  414. resetPage();
  415. }}
  416. className="w-full h-9 border px-2 text-base font-bold"
  417. >
  418. <option value="">全部动作</option>
  419. {dictionaries.options("auditActions").map((option) => (
  420. <option key={option.code} value={option.code}>
  421. {option.label}
  422. </option>
  423. ))}
  424. </select>
  425. <select
  426. value={operationResult}
  427. onChange={(event) => {
  428. setOperationResult(event.target.value);
  429. resetPage();
  430. }}
  431. className="w-full h-9 border px-2 text-base font-bold"
  432. >
  433. <option value="">全部结果</option>
  434. {dictionaries.options("auditResults").map((option) => (
  435. <option key={option.code} value={option.code}>
  436. {option.label}
  437. </option>
  438. ))}
  439. </select>
  440. <select
  441. value={targetType}
  442. onChange={(event) => {
  443. setTargetType(event.target.value);
  444. resetPage();
  445. }}
  446. className="w-full h-9 border px-2 text-base font-bold"
  447. >
  448. <option value="">全部对象类型</option>
  449. {dictionaries.options("auditTargets").map((option) => (
  450. <option key={option.code} value={option.code}>
  451. {option.label}
  452. </option>
  453. ))}
  454. </select>
  455. {[
  456. ["用户ID", userId, setUserId],
  457. ["组织ID", organizationId, setOrganizationId],
  458. ["目标ID", targetId, setTargetId],
  459. ].map(([label, value, setter]) => (
  460. <input
  461. key={label as string}
  462. value={value as string}
  463. inputMode="numeric"
  464. onChange={(event) => {
  465. (setter as (value: string) => void)(
  466. event.target.value.replace(/\D/g, ""),
  467. );
  468. resetPage();
  469. }}
  470. placeholder={label as string}
  471. className="w-full h-9 border px-2 text-base font-bold"
  472. />
  473. ))}
  474. <label className="block text-base font-black">
  475. 起始时间(含)
  476. <input
  477. type="datetime-local"
  478. value={createdFromLocal}
  479. onChange={(event) => {
  480. setCreatedFromLocal(event.target.value);
  481. resetPage();
  482. }}
  483. className="mt-1 w-full h-9 border px-2 text-base font-bold"
  484. />
  485. </label>
  486. <label className="block text-base font-black">
  487. 结束时间(不含)
  488. <input
  489. type="datetime-local"
  490. value={createdToLocal}
  491. onChange={(event) => {
  492. setCreatedToLocal(event.target.value);
  493. resetPage();
  494. }}
  495. className="mt-1 w-full h-9 border px-2 text-base font-bold"
  496. />
  497. </label>
  498. <div className="flex gap-2">
  499. <button
  500. onClick={clearFilters}
  501. className="flex-1 h-9 border text-base font-black"
  502. style={{ borderColor: BLUE }}
  503. >
  504. 清空
  505. </button>
  506. <button
  507. onClick={() => {
  508. logs.refresh();
  509. trend.refresh();
  510. breakdowns.refresh();
  511. }}
  512. className="flex-1 h-9 text-white text-base font-black"
  513. style={{ background: BLUE }}
  514. >
  515. 重新加载
  516. </button>
  517. </div>
  518. <div className="text-base leading-5 opacity-60">
  519. 时间按本地输入并转换为 UTC Z;查询区间为 [起始, 结束)。
  520. </div>
  521. </div>
  522. </aside>
  523. <main className="flex-1 flex gap-3 m-3 ml-0 min-w-0 min-h-0">
  524. <section
  525. className="flex-1 min-w-0 flex flex-col bg-white"
  526. style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
  527. >
  528. <div
  529. className="px-4 py-2.5 flex items-center gap-3 border-b"
  530. style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
  531. >
  532. <span className="font-black">操作日志</span>
  533. <span className="text-base font-black" style={{ color: "var(--dms-highlight)" }}>
  534. {logs.data?.total ?? 0} 条
  535. </span>
  536. <select
  537. value={sortDirection}
  538. onChange={(event) => {
  539. setSortDirection(event.target.value as "asc" | "desc");
  540. setPage(1);
  541. }}
  542. className="ml-auto h-8 border px-2 text-base font-bold"
  543. >
  544. <option value="desc">时间降序</option>
  545. <option value="asc">时间升序</option>
  546. </select>
  547. <select
  548. value={pageSize}
  549. onChange={(event) => {
  550. setPageSize(Number(event.target.value));
  551. setPage(1);
  552. }}
  553. className="h-8 border px-2 text-base font-bold"
  554. >
  555. {[20, 50, 100].map((size) => (
  556. <option key={size} value={size}>
  557. 每页 {size}
  558. </option>
  559. ))}
  560. </select>
  561. </div>
  562. {logs.loading && !logs.data && (
  563. <div className="p-8 text-base font-bold" style={{ color: NAVY }}>
  564. <RefreshCw size={14} className="inline animate-spin mr-2" />
  565. 正在读取审计日志…
  566. </div>
  567. )}
  568. {logs.error && (
  569. <div className="p-6 text-base font-bold" style={{ color: NAVY }}>
  570. <div>
  571. {logs.error.httpStatus === 403
  572. ? "当前账号无权读取审计日志"
  573. : logs.error.message}
  574. </div>
  575. {logs.error.requestId && (
  576. <div className="text-base opacity-60 mt-1">
  577. 错误编号:{logs.error.requestId}
  578. </div>
  579. )}
  580. <button
  581. onClick={logs.refresh}
  582. className="mt-3 px-3 py-1.5 text-base text-white"
  583. style={{ background: BLUE }}
  584. >
  585. 重试
  586. </button>
  587. </div>
  588. )}
  589. {logs.data && logs.data.items.length === 0 && (
  590. <div className="p-8 text-base font-bold opacity-60" style={{ color: NAVY }}>
  591. 当前筛选条件下暂无日志
  592. </div>
  593. )}
  594. {logs.data && logs.data.items.length > 0 && (
  595. <div className="flex-1 overflow-auto">
  596. <table className="w-full table-fixed min-w-[1000px] border-collapse">
  597. <colgroup>
  598. <col style={{ width: 195 }} />
  599. <col style={{ width: 125 }} />
  600. <col style={{ width: 120 }} />
  601. <col style={{ width: 115 }} />
  602. {/* 对象类型 / 名称:弹性列吃剩余宽度 */}
  603. <col />
  604. {/* 结果 / 失败原因:固定窄宽,容纳成功/失败徽标即可,失败原因换行展示 */}
  605. <col style={{ width: 130 }} />
  606. {/* 客户端IP:固定宽度保证完整显示 IPv4(最长 255.255.255.255) */}
  607. <col style={{ width: 180 }} />
  608. </colgroup>
  609. <thead className="sticky top-0 z-10 text-white" style={{ background: NAVY }}>
  610. <tr>
  611. {[
  612. "时间",
  613. "用户名 / 姓名",
  614. "组织",
  615. "动作",
  616. "对象类型 / 名称",
  617. "结果 / 失败原因",
  618. "客户端IP",
  619. ].map((heading) => (
  620. <th key={heading} className="px-3 py-2 text-left text-base font-black">
  621. {heading}
  622. </th>
  623. ))}
  624. </tr>
  625. </thead>
  626. <tbody>
  627. {logs.data.items.map((log, index) =>
  628. "invalid" in log ? (
  629. <tr
  630. key={`invalid-${index}`}
  631. className="border-b"
  632. style={{
  633. borderColor: "rgba(183,28,28,.25)",
  634. background: "rgba(183,28,28,.06)",
  635. color: "#B71C1C",
  636. }}
  637. >
  638. <td colSpan={7} className="px-3 py-2 text-base font-bold">
  639. 该行数据不符合DMS接口契约,无法展示
  640. </td>
  641. </tr>
  642. ) : (
  643. <tr
  644. key={log.id}
  645. className={`border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${index % 2 ? "bg-[rgba(var(--dms-line-rgb),.04)]" : "bg-white"}`}
  646. style={{
  647. color: NAVY,
  648. borderColor: "rgba(var(--dms-line-rgb),.1)",
  649. }}
  650. >
  651. <td
  652. className="px-3 py-2 text-base font-bold truncate"
  653. title={formatAuditDate(log.createdAt)}
  654. >
  655. {formatAuditDate(log.createdAt)}
  656. </td>
  657. <td className="px-3 py-2 text-base font-bold">
  658. <div className="truncate" title={log.username ?? EMPTY}>
  659. {log.username ?? EMPTY}
  660. </div>
  661. <div className="truncate opacity-60" title={log.realName ?? EMPTY}>
  662. {log.realName ?? EMPTY}
  663. </div>
  664. </td>
  665. <td
  666. className="px-3 py-2 text-base font-bold truncate"
  667. title={log.organizationName ?? EMPTY}
  668. >
  669. {log.organizationName ?? EMPTY}
  670. </td>
  671. <td
  672. className="px-3 py-2 text-base font-black truncate"
  673. title={dictionaries.label("auditActions", log.actionType)}
  674. >
  675. {dictionaries.label("auditActions", log.actionType)}
  676. </td>
  677. <td className="px-3 py-2 text-base font-bold">
  678. <div
  679. className="truncate"
  680. title={
  681. log.targetType
  682. ? dictionaries.label("auditTargets", log.targetType)
  683. : EMPTY
  684. }
  685. >
  686. {log.targetType
  687. ? dictionaries.label("auditTargets", log.targetType)
  688. : EMPTY}
  689. </div>
  690. <div
  691. className="truncate opacity-60"
  692. title={
  693. log.targetName
  694. ? `${log.targetName}${log.targetId ? `(${log.targetId})` : ""}`
  695. : EMPTY
  696. }
  697. >
  698. {log.targetName ?? EMPTY}
  699. {log.targetId ? `(${log.targetId})` : ""}
  700. </div>
  701. </td>
  702. <td className="px-3 py-2 text-base font-bold">
  703. <span
  704. className="inline-block px-2 py-0.5 text-white"
  705. style={{
  706. background:
  707. log.operationResult === AuditOperationResult.SUCCESS
  708. ? BLUE
  709. : NAVY,
  710. }}
  711. >
  712. {dictionaries.label("auditResults", log.operationResult)}
  713. </span>
  714. <div className="mt-1 opacity-60 break-words">
  715. {log.failureReason ?? EMPTY}
  716. </div>
  717. </td>
  718. <td
  719. className="px-3 py-2 text-base font-bold truncate"
  720. title={log.clientIp ?? EMPTY}
  721. >
  722. {log.clientIp ?? EMPTY}
  723. </td>
  724. </tr>
  725. )
  726. )}
  727. </tbody>
  728. </table>
  729. </div>
  730. )}
  731. <div
  732. className="p-2 border-t flex items-center justify-center gap-3 text-base font-bold"
  733. style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
  734. >
  735. <button
  736. disabled={page <= 1}
  737. onClick={() => setPage((value) => Math.max(1, value - 1))}
  738. className="px-3 py-1 border disabled:opacity-30"
  739. >
  740. 上一页
  741. </button>
  742. <span>
  743. 第 {logs.data?.page ?? page} / {logs.data?.totalPages ?? 0} 页
  744. </span>
  745. <button
  746. disabled={!logs.data || page >= logs.data.totalPages}
  747. onClick={() => setPage((value) => value + 1)}
  748. className="px-3 py-1 border disabled:opacity-30"
  749. >
  750. 下一页
  751. </button>
  752. </div>
  753. </section>
  754. <aside className="w-[330px] flex-shrink-0 overflow-y-auto space-y-3">
  755. <StatsPanel
  756. title="操作趋势"
  757. icon={<BarChart2 size={14} style={{ color: BLUE }} />}
  758. >
  759. <div className="flex gap-1 mb-2">
  760. {Object.values(AuditGranularity).map((value) => (
  761. <button
  762. key={value}
  763. onClick={() => setGranularity(value)}
  764. className="flex-1 py-1 text-base font-black"
  765. style={{
  766. color: granularity === value ? WHITE : NAVY,
  767. background: granularity === value ? NAVY : "var(--dms-canvas)",
  768. }}
  769. >
  770. {value}
  771. </button>
  772. ))}
  773. </div>
  774. <ResourceState<AuditTrendItem[]>
  775. state={trend}
  776. onRetry={trend.refresh}
  777. label="趋势数据"
  778. />
  779. {trend.data && trend.data.length > 0 && (
  780. <>
  781. <ResponsiveContainer width="100%" height={115}>
  782. <LineChart data={trend.data}>
  783. <CartesianGrid strokeDasharray="3 3" stroke="rgba(var(--dms-line-rgb),.12)" />
  784. <XAxis dataKey="period" tick={{ fontSize: 18, fill: NAVY }} />
  785. <YAxis tick={{ fontSize: 18, fill: NAVY }} />
  786. <Tooltip />
  787. <Line
  788. dataKey="operationCount"
  789. name="操作次数"
  790. stroke={BLUE}
  791. strokeWidth={2}
  792. />
  793. </LineChart>
  794. </ResponsiveContainer>
  795. </>
  796. )}
  797. </StatsPanel>
  798. <StatsPanel
  799. title="动作分布"
  800. icon={<Activity size={14} style={{ color: BLUE }} />}
  801. >
  802. <ResourceState<AuditActionStatisticsItem[]>
  803. state={breakdowns.actions}
  804. onRetry={breakdowns.refresh}
  805. label="动作分布"
  806. />
  807. {pieData.length > 0 && (
  808. <>
  809. <ResponsiveContainer width="100%" height={120}>
  810. <PieChart>
  811. <Pie
  812. data={pieData}
  813. dataKey="value"
  814. nameKey="name"
  815. innerRadius={25}
  816. outerRadius={45}
  817. >
  818. {pieData.map((item, index) => (
  819. <Cell
  820. key={item.actionType}
  821. fill={COLORS[index % COLORS.length]}
  822. />
  823. ))}
  824. </Pie>
  825. <Tooltip />
  826. </PieChart>
  827. </ResponsiveContainer>
  828. </>
  829. )}
  830. </StatsPanel>
  831. <StatsPanel
  832. title="活跃用户 TOP 6"
  833. icon={<Users size={14} style={{ color: BLUE }} />}
  834. >
  835. <ResourceState<AuditUserStatisticsItem[]>
  836. state={breakdowns.users}
  837. onRetry={breakdowns.refresh}
  838. label="活跃用户"
  839. />
  840. {activeUsers.length > 0 && (
  841. <>
  842. <ResponsiveContainer width="100%" height={115}>
  843. <BarChart data={activeUsers} layout="vertical">
  844. <CartesianGrid strokeDasharray="3 3" horizontal={false} />
  845. <XAxis type="number" tick={{ fontSize: 18 }} />
  846. <YAxis
  847. type="category"
  848. dataKey="label"
  849. width={76}
  850. tick={{ fontSize: 18, fill: NAVY }}
  851. />
  852. <Tooltip />
  853. <Bar
  854. dataKey="operationCount"
  855. name="操作次数"
  856. fill={BLUE}
  857. />
  858. </BarChart>
  859. </ResponsiveContainer>
  860. </>
  861. )}
  862. </StatsPanel>
  863. </aside>
  864. </main>
  865. {runtimeLogOpen && <RuntimeLogModal onClose={() => setRuntimeLogOpen(false)} />}
  866. </>
  867. );
  868. }