page.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { Button } from "@/components/ui/button";
  4. import {
  5. deleteLogMinLevelPolicy,
  6. fetchLogMinLevelPolicy,
  7. fetchSystemLogs,
  8. putLogMinLevelPolicy,
  9. type LogMinLevelPolicy,
  10. type QuerySystemLogsResult,
  11. } from "@/features/agent/services/systemLogApi";
  12. import { toast } from "@/hooks/useToast";
  13. import {
  14. Dialog,
  15. DialogContent,
  16. DialogHeader,
  17. DialogTitle,
  18. } from "@/components/ui/dialog";
  19. import { Copy } from "lucide-react";
  20. function tryFormatJSON(raw?: string | null): string {
  21. if (!raw) return "";
  22. try {
  23. const parsed = JSON.parse(raw);
  24. return JSON.stringify(parsed, null, 2);
  25. } catch {
  26. return raw;
  27. }
  28. }
  29. function levelColor(level: string): string {
  30. if (level === "error") return "text-red-600";
  31. if (level === "warn") return "text-amber-600";
  32. return "text-emerald-600";
  33. }
  34. export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
  35. const [from, setFrom] = useState(() => {
  36. const d = new Date();
  37. d.setDate(d.getDate() - 6);
  38. return d.toISOString().slice(0, 10);
  39. });
  40. const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10));
  41. const [level, setLevel] = useState("");
  42. const [category, setCategory] = useState("");
  43. const [source, setSource] = useState("");
  44. const [event, setEvent] = useState("");
  45. const [keyword, setKeyword] = useState("");
  46. const [conversationId, setConversationId] = useState("");
  47. const [data, setData] = useState<QuerySystemLogsResult | null>(null);
  48. const [loading, setLoading] = useState(false);
  49. const [page, setPage] = useState(1);
  50. const pageSize = 50;
  51. const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null);
  52. const [policy, setPolicy] = useState<LogMinLevelPolicy | null>(null);
  53. const [policyDraft, setPolicyDraft] = useState("info");
  54. const [policyLoading, setPolicyLoading] = useState(false);
  55. const selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]);
  56. const loadPolicy = useCallback(async () => {
  57. setPolicyLoading(true);
  58. try {
  59. const p = await fetchLogMinLevelPolicy();
  60. setPolicy(p);
  61. setPolicyDraft(p.effective_min_level);
  62. } catch (e) {
  63. toast.error((e as Error).message || "加载落库策略失败");
  64. setPolicy(null);
  65. } finally {
  66. setPolicyLoading(false);
  67. }
  68. }, []);
  69. useEffect(() => {
  70. void loadPolicy();
  71. }, [loadPolicy]);
  72. const load = useCallback(async () => {
  73. setLoading(true);
  74. try {
  75. const conv = conversationId.trim() ? Number(conversationId) : undefined;
  76. const res = await fetchSystemLogs({
  77. from,
  78. to,
  79. level: level || undefined,
  80. category: category || undefined,
  81. source: source || undefined,
  82. event: event || undefined,
  83. keyword: keyword || undefined,
  84. conversationId: conv,
  85. page,
  86. pageSize,
  87. });
  88. setData(res);
  89. } catch (e) {
  90. toast.error((e as Error).message || "加载日志失败");
  91. setData(null);
  92. } finally {
  93. setLoading(false);
  94. }
  95. }, [from, to, level, category, source, event, keyword, conversationId, page]);
  96. useEffect(() => {
  97. void load();
  98. }, [load]);
  99. const totalPages = useMemo(() => {
  100. if (!data) return 1;
  101. return Math.max(1, Math.ceil(data.total / data.page_size));
  102. }, [data]);
  103. return (
  104. <div className={`flex flex-col min-h-0 overflow-auto ${embedded ? "p-4" : "p-6 max-w-6xl mx-auto w-full"}`}>
  105. <div className="mb-4">
  106. <h1 className="text-xl font-semibold">日志中心</h1>
  107. <p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
  108. </div>
  109. <div className="rounded-xl border border-border/60 bg-card p-4 mb-4 space-y-3">
  110. <div className="flex flex-wrap items-start justify-between gap-3">
  111. <div>
  112. <h2 className="text-sm font-semibold">落库级别(性能)</h2>
  113. <p className="text-xs text-muted-foreground mt-1 max-w-xl">
  114. 仅将不低于所选级别的记录写入数据库。设为 <code className="text-foreground">warn</code> 可大幅减少成功类{" "}
  115. <code className="text-foreground">info</code> 写入。也可在根目录{" "}
  116. <code className="text-foreground">SYSTEM_LOG_MIN_LEVEL</code> 配置默认值;此处保存后会写入数据库并覆盖环境变量,直至点击「恢复环境变量」。
  117. </p>
  118. {policy ? (
  119. <p className="text-xs text-muted-foreground mt-2">
  120. 当前生效:<span className="font-medium text-foreground">{policy.effective_min_level}</span>
  121. {" · "}
  122. 环境变量默认:<span className="font-medium text-foreground">{policy.env_min_level}</span>
  123. {policy.persisted_in_database ? (
  124. <span className="text-amber-700 dark:text-amber-500">(已由控制台覆盖)</span>
  125. ) : null}
  126. </p>
  127. ) : null}
  128. </div>
  129. <div className="flex flex-wrap items-center gap-2">
  130. <select
  131. value={policyDraft}
  132. onChange={(e) => setPolicyDraft(e.target.value)}
  133. disabled={policyLoading}
  134. className="rounded-md border px-2 py-1.5 text-sm min-w-[140px]"
  135. >
  136. <option value="debug">debug</option>
  137. <option value="info">info</option>
  138. <option value="warn">warn</option>
  139. <option value="error">error</option>
  140. <option value="none">none(关闭落库)</option>
  141. </select>
  142. <Button
  143. size="sm"
  144. disabled={policyLoading}
  145. onClick={async () => {
  146. setPolicyLoading(true);
  147. try {
  148. await putLogMinLevelPolicy(policyDraft);
  149. toast.success("已保存并生效");
  150. await loadPolicy();
  151. } catch (e) {
  152. toast.error((e as Error).message || "保存失败");
  153. } finally {
  154. setPolicyLoading(false);
  155. }
  156. }}
  157. >
  158. 保存到服务器
  159. </Button>
  160. <Button
  161. size="sm"
  162. variant="outline"
  163. disabled={policyLoading}
  164. onClick={async () => {
  165. setPolicyLoading(true);
  166. try {
  167. await deleteLogMinLevelPolicy();
  168. toast.success("已恢复为环境变量默认值");
  169. await loadPolicy();
  170. } catch (e) {
  171. toast.error((e as Error).message || "恢复失败");
  172. } finally {
  173. setPolicyLoading(false);
  174. }
  175. }}
  176. >
  177. 恢复环境变量
  178. </Button>
  179. </div>
  180. </div>
  181. </div>
  182. <div className="rounded-xl border border-border/60 bg-card p-3 mb-4 flex flex-wrap gap-2 items-center">
  183. <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
  184. <span className="text-xs text-muted-foreground">到</span>
  185. <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
  186. <select value={level} onChange={(e) => setLevel(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  187. <option value="">全部级别</option>
  188. <option value="info">info</option>
  189. <option value="warn">warn</option>
  190. <option value="error">error</option>
  191. </select>
  192. <select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  193. <option value="">全部分类</option>
  194. <option value="ai">ai</option>
  195. <option value="rag">rag</option>
  196. <option value="frontend">frontend</option>
  197. <option value="system">system</option>
  198. <option value="business">business</option>
  199. <option value="http">http</option>
  200. <option value="vector">vector</option>
  201. </select>
  202. <select value={source} onChange={(e) => setSource(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  203. <option value="">全部来源</option>
  204. <option value="backend">backend</option>
  205. <option value="frontend">frontend</option>
  206. </select>
  207. <input
  208. placeholder="事件名(event)"
  209. value={event}
  210. onChange={(e) => setEvent(e.target.value)}
  211. className="rounded-md border px-2 py-1 text-sm min-w-[180px]"
  212. />
  213. <input
  214. placeholder="会话ID"
  215. value={conversationId}
  216. onChange={(e) => setConversationId(e.target.value)}
  217. className="rounded-md border px-2 py-1 text-sm w-24"
  218. />
  219. <input
  220. placeholder="关键词(message/meta)"
  221. value={keyword}
  222. onChange={(e) => setKeyword(e.target.value)}
  223. className="rounded-md border px-2 py-1 text-sm min-w-[220px]"
  224. />
  225. <Button size="sm" disabled={loading} onClick={() => { setPage(1); void load(); }}>
  226. {loading ? "加载中..." : "查询"}
  227. </Button>
  228. </div>
  229. <div className="rounded-xl border border-border/60 bg-card overflow-hidden">
  230. <div className="px-3 py-2 border-b text-xs text-muted-foreground">
  231. 共 {data?.total ?? 0} 条,当前第 {data?.page ?? page}/{totalPages} 页
  232. </div>
  233. <div className="overflow-auto">
  234. <table className="w-full text-sm">
  235. <thead className="bg-muted/40 text-xs text-muted-foreground">
  236. <tr>
  237. <th className="text-left px-3 py-2">时间</th>
  238. <th className="text-left px-3 py-2">级别</th>
  239. <th className="text-left px-3 py-2">分类</th>
  240. <th className="text-left px-3 py-2">事件</th>
  241. <th className="text-left px-3 py-2">会话</th>
  242. <th className="text-left px-3 py-2">来源</th>
  243. <th className="text-left px-3 py-2">消息</th>
  244. </tr>
  245. </thead>
  246. <tbody>
  247. {(data?.items ?? []).map((item) => (
  248. <tr
  249. key={item.id}
  250. className="border-t cursor-pointer hover:bg-muted/30"
  251. onClick={() => setSelected(item)}
  252. >
  253. <td className="px-3 py-2 whitespace-nowrap text-xs">{new Date(item.timestamp).toLocaleString()}</td>
  254. <td className={`px-3 py-2 font-medium ${levelColor(item.level)}`}>{item.level}</td>
  255. <td className="px-3 py-2">{item.category}</td>
  256. <td className="px-3 py-2">{item.event}</td>
  257. <td className="px-3 py-2">{item.conversation_id ?? "-"}</td>
  258. <td className="px-3 py-2">{item.source}</td>
  259. <td className="px-3 py-2 max-w-[560px] truncate" title={item.message}>{item.message}</td>
  260. </tr>
  261. ))}
  262. {(data?.items ?? []).length === 0 && !loading && (
  263. <tr>
  264. <td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>暂无日志</td>
  265. </tr>
  266. )}
  267. </tbody>
  268. </table>
  269. </div>
  270. <div className="px-3 py-2 border-t flex items-center justify-end gap-2">
  271. <Button
  272. variant="outline"
  273. size="sm"
  274. disabled={loading || page <= 1}
  275. onClick={() => setPage((p) => Math.max(1, p - 1))}
  276. >
  277. 上一页
  278. </Button>
  279. <Button
  280. variant="outline"
  281. size="sm"
  282. disabled={loading || page >= totalPages}
  283. onClick={() => setPage((p) => p + 1)}
  284. >
  285. 下一页
  286. </Button>
  287. </div>
  288. </div>
  289. <Dialog
  290. open={Boolean(selected)}
  291. onOpenChange={(open) => {
  292. if (!open) setSelected(null);
  293. }}
  294. >
  295. <DialogContent className="max-w-4xl">
  296. <DialogHeader>
  297. <DialogTitle className="flex items-center gap-2">
  298. <span>日志详情</span>
  299. {selected ? (
  300. <span className={`text-xs px-2 py-0.5 rounded border ${selected.level === "error" ? "border-red-200 text-red-700" : selected.level === "warn" ? "border-amber-200 text-amber-700" : "border-emerald-200 text-emerald-700"}`}>
  301. {selected.level}
  302. </span>
  303. ) : null}
  304. </DialogTitle>
  305. </DialogHeader>
  306. {selected ? (
  307. <div className="space-y-3">
  308. <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
  309. <div className="rounded-lg border p-2">
  310. <div className="text-xs text-muted-foreground">时间</div>
  311. <div className="font-medium">{new Date(selected.timestamp).toLocaleString()}</div>
  312. </div>
  313. <div className="rounded-lg border p-2">
  314. <div className="text-xs text-muted-foreground">source / event</div>
  315. <div className="font-medium">
  316. {selected.source} / {selected.event}
  317. </div>
  318. </div>
  319. <div className="rounded-lg border p-2">
  320. <div className="text-xs text-muted-foreground">category</div>
  321. <div className="font-medium">{selected.category}</div>
  322. </div>
  323. <div className="rounded-lg border p-2">
  324. <div className="text-xs text-muted-foreground">trace_id</div>
  325. <div className="font-medium break-all">{selected.trace_id || "-"}</div>
  326. </div>
  327. <div className="rounded-lg border p-2">
  328. <div className="text-xs text-muted-foreground">conversation_id</div>
  329. <div className="font-medium">{selected.conversation_id ?? "-"}</div>
  330. </div>
  331. <div className="rounded-lg border p-2">
  332. <div className="text-xs text-muted-foreground">user_id / visitor_id</div>
  333. <div className="font-medium">
  334. {selected.user_id ?? "-"} / {selected.visitor_id ?? "-"}
  335. </div>
  336. </div>
  337. </div>
  338. <div className="rounded-lg border p-3">
  339. <div className="flex items-center justify-between gap-2 mb-2">
  340. <div className="text-sm font-medium">message</div>
  341. <Button
  342. size="sm"
  343. variant="outline"
  344. onClick={async () => {
  345. try {
  346. await navigator.clipboard.writeText(selected.message);
  347. toast.success("已复制 message");
  348. } catch {
  349. toast.error("复制失败");
  350. }
  351. }}
  352. >
  353. <Copy className="h-4 w-4 mr-1" />
  354. 复制
  355. </Button>
  356. </div>
  357. <pre className="whitespace-pre-wrap text-sm bg-muted/30 rounded p-2 max-h-48 overflow-auto">{selected.message}</pre>
  358. </div>
  359. <div className="rounded-lg border p-3">
  360. <div className="text-sm font-medium mb-2">meta_json</div>
  361. <pre className="whitespace-pre-wrap text-xs bg-muted/30 rounded p-2 max-h-80 overflow-auto">
  362. {selectedMeta || "(无 meta_json)"}
  363. </pre>
  364. </div>
  365. </div>
  366. ) : null}
  367. </DialogContent>
  368. </Dialog>
  369. </div>
  370. );
  371. }