page.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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. import type { I18nKey } from "@/lib/i18n/dict";
  21. import { useI18n } from "@/lib/i18n/provider";
  22. function tryFormatJSON(raw?: string | null): string {
  23. if (!raw) return "";
  24. try {
  25. const parsed = JSON.parse(raw);
  26. return JSON.stringify(parsed, null, 2);
  27. } catch {
  28. return raw;
  29. }
  30. }
  31. function levelColor(level: string): string {
  32. if (level === "error") return "text-red-600";
  33. if (level === "warn") return "text-amber-600";
  34. return "text-emerald-600";
  35. }
  36. export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
  37. const { t, lang } = useI18n();
  38. const tr = (key: I18nKey, vars?: Record<string, string>) => {
  39. let s = t(key);
  40. if (!vars) return s;
  41. for (const k of Object.keys(vars)) {
  42. s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
  43. }
  44. return s;
  45. };
  46. const locale = lang === "en" ? "en-US" : "zh-CN";
  47. const [from, setFrom] = useState(() => {
  48. const d = new Date();
  49. d.setDate(d.getDate() - 6);
  50. return d.toISOString().slice(0, 10);
  51. });
  52. const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10));
  53. const [level, setLevel] = useState("");
  54. const [category, setCategory] = useState("");
  55. const [source, setSource] = useState("");
  56. const [event, setEvent] = useState("");
  57. const [keyword, setKeyword] = useState("");
  58. const [conversationId, setConversationId] = useState("");
  59. const [data, setData] = useState<QuerySystemLogsResult | null>(null);
  60. const [loading, setLoading] = useState(false);
  61. const [page, setPage] = useState(1);
  62. const pageSize = 50;
  63. const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null);
  64. const [policy, setPolicy] = useState<LogMinLevelPolicy | null>(null);
  65. const [policyDraft, setPolicyDraft] = useState("info");
  66. const [policyLoading, setPolicyLoading] = useState(false);
  67. const selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]);
  68. const loadPolicy = useCallback(async () => {
  69. setPolicyLoading(true);
  70. try {
  71. const p = await fetchLogMinLevelPolicy();
  72. setPolicy(p);
  73. setPolicyDraft(p.effective_min_level);
  74. } catch (e) {
  75. toast.error((e as Error).message || t("agent.logs.toast.loadPolicyFailed"));
  76. setPolicy(null);
  77. } finally {
  78. setPolicyLoading(false);
  79. }
  80. }, [t]);
  81. useEffect(() => {
  82. void loadPolicy();
  83. }, [loadPolicy]);
  84. const load = useCallback(async () => {
  85. setLoading(true);
  86. try {
  87. const conv = conversationId.trim() ? Number(conversationId) : undefined;
  88. const res = await fetchSystemLogs({
  89. from,
  90. to,
  91. level: level || undefined,
  92. category: category || undefined,
  93. source: source || undefined,
  94. event: event || undefined,
  95. keyword: keyword || undefined,
  96. conversationId: conv,
  97. page,
  98. pageSize,
  99. });
  100. setData(res);
  101. } catch (e) {
  102. toast.error((e as Error).message || t("agent.logs.toast.loadLogsFailed"));
  103. setData(null);
  104. } finally {
  105. setLoading(false);
  106. }
  107. }, [from, to, level, category, source, event, keyword, conversationId, page, t]);
  108. useEffect(() => {
  109. void load();
  110. }, [load]);
  111. const totalPages = useMemo(() => {
  112. if (!data) return 1;
  113. return Math.max(1, Math.ceil(data.total / data.page_size));
  114. }, [data]);
  115. return (
  116. <div
  117. className={`flex min-h-0 flex-col overflow-auto ${embedded ? "p-3 sm:p-4" : "w-full max-w-6xl p-4 sm:p-6 mx-auto"}`}
  118. >
  119. <div className="mb-4">
  120. <h1 className="text-xl font-semibold">{t("agent.logs.title")}</h1>
  121. <p className="text-sm text-muted-foreground mt-1">{t("agent.logs.subtitle")}</p>
  122. </div>
  123. <div className="rounded-xl border border-border/60 bg-card p-4 mb-4 space-y-3">
  124. <div className="flex flex-wrap items-start justify-between gap-3">
  125. <div>
  126. <h2 className="text-sm font-semibold">{t("agent.logs.policy.title")}</h2>
  127. <p className="text-xs text-muted-foreground mt-1 max-w-xl">{t("agent.logs.policy.desc")}</p>
  128. {policy ? (
  129. <p className="text-xs text-muted-foreground mt-2">
  130. {t("agent.logs.policy.current")}<span className="font-medium text-foreground">{policy.effective_min_level}</span>
  131. {" · "}
  132. {t("agent.logs.policy.env")}<span className="font-medium text-foreground">{policy.env_min_level}</span>
  133. {policy.persisted_in_database ? (
  134. <span className="text-amber-700 dark:text-amber-500">{t("agent.logs.policy.overridden")}</span>
  135. ) : null}
  136. </p>
  137. ) : null}
  138. </div>
  139. <div className="flex flex-wrap items-center gap-2">
  140. <select
  141. value={policyDraft}
  142. onChange={(e) => setPolicyDraft(e.target.value)}
  143. disabled={policyLoading}
  144. className="rounded-md border px-2 py-1.5 text-sm min-w-[140px]"
  145. >
  146. <option value="debug">debug</option>
  147. <option value="info">info</option>
  148. <option value="warn">warn</option>
  149. <option value="error">error</option>
  150. <option value="none">none</option>
  151. </select>
  152. <Button
  153. size="sm"
  154. disabled={policyLoading}
  155. onClick={async () => {
  156. setPolicyLoading(true);
  157. try {
  158. await putLogMinLevelPolicy(policyDraft);
  159. toast.success("OK");
  160. await loadPolicy();
  161. } catch (e) {
  162. toast.error((e as Error).message || t("agent.logs.toast.savePolicyFailed"));
  163. } finally {
  164. setPolicyLoading(false);
  165. }
  166. }}
  167. >
  168. {t("common.save")}
  169. </Button>
  170. <Button
  171. size="sm"
  172. variant="outline"
  173. disabled={policyLoading}
  174. onClick={async () => {
  175. setPolicyLoading(true);
  176. try {
  177. await deleteLogMinLevelPolicy();
  178. toast.success(t("agent.logs.toast.policyRestored"));
  179. await loadPolicy();
  180. } catch (e) {
  181. toast.error((e as Error).message || t("agent.logs.toast.restorePolicyFailed"));
  182. } finally {
  183. setPolicyLoading(false);
  184. }
  185. }}
  186. >
  187. {t("common.restoreEnv")}
  188. </Button>
  189. </div>
  190. </div>
  191. </div>
  192. <div className="mb-4 flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:flex-wrap sm:items-center">
  193. <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
  194. <span className="text-xs text-muted-foreground">{t("common.to")}</span>
  195. <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
  196. <select value={level} onChange={(e) => setLevel(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  197. <option value="">{t("agent.logs.level.all")}</option>
  198. <option value="info">info</option>
  199. <option value="warn">warn</option>
  200. <option value="error">error</option>
  201. </select>
  202. <select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  203. <option value="">{t("agent.logs.category.all")}</option>
  204. <option value="ai">ai</option>
  205. <option value="rag">rag</option>
  206. <option value="frontend">frontend</option>
  207. <option value="system">system</option>
  208. <option value="business">business</option>
  209. <option value="http">http</option>
  210. <option value="vector">vector</option>
  211. </select>
  212. <select value={source} onChange={(e) => setSource(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
  213. <option value="">{t("agent.logs.source.all")}</option>
  214. <option value="backend">backend</option>
  215. <option value="frontend">frontend</option>
  216. </select>
  217. <input
  218. placeholder={t("agent.logs.event.placeholder")}
  219. value={event}
  220. onChange={(e) => setEvent(e.target.value)}
  221. className="rounded-md border px-2 py-1 text-sm min-w-[180px]"
  222. />
  223. <input
  224. placeholder={t("agent.logs.conversationId.placeholder")}
  225. value={conversationId}
  226. onChange={(e) => setConversationId(e.target.value)}
  227. className="rounded-md border px-2 py-1 text-sm w-24"
  228. />
  229. <input
  230. placeholder={t("agent.logs.keyword.placeholder")}
  231. value={keyword}
  232. onChange={(e) => setKeyword(e.target.value)}
  233. className="rounded-md border px-2 py-1 text-sm min-w-[220px]"
  234. />
  235. <Button size="sm" disabled={loading} onClick={() => { setPage(1); void load(); }}>
  236. {loading ? t("common.loading") : t("common.search")}
  237. </Button>
  238. </div>
  239. <div className="rounded-xl border border-border/60 bg-card overflow-hidden">
  240. <div className="border-b px-3 py-2 text-xs text-muted-foreground">
  241. {tr("agent.logs.paginationSummary", {
  242. total: String(data?.total ?? 0),
  243. page: String(data?.page ?? page),
  244. pages: String(totalPages),
  245. })}
  246. </div>
  247. <div className="overflow-x-auto">
  248. <table className="w-full min-w-[720px] text-sm">
  249. <thead className="bg-muted/40 text-xs text-muted-foreground">
  250. <tr>
  251. <th className="text-left px-3 py-2">{t("agent.logs.table.time")}</th>
  252. <th className="text-left px-3 py-2">{t("agent.logs.table.level")}</th>
  253. <th className="text-left px-3 py-2">{t("agent.logs.table.category")}</th>
  254. <th className="text-left px-3 py-2">{t("agent.logs.table.event")}</th>
  255. <th className="text-left px-3 py-2">{t("agent.logs.table.conversation")}</th>
  256. <th className="text-left px-3 py-2">{t("agent.logs.table.source")}</th>
  257. <th className="text-left px-3 py-2">{t("agent.logs.table.message")}</th>
  258. </tr>
  259. </thead>
  260. <tbody>
  261. {(data?.items ?? []).map((item) => (
  262. <tr
  263. key={item.id}
  264. className="border-t cursor-pointer hover:bg-muted/30"
  265. onClick={() => setSelected(item)}
  266. >
  267. <td className="px-3 py-2 whitespace-nowrap text-xs">
  268. {new Date(item.timestamp).toLocaleString(locale)}
  269. </td>
  270. <td className={`px-3 py-2 font-medium ${levelColor(item.level)}`}>{item.level}</td>
  271. <td className="px-3 py-2">{item.category}</td>
  272. <td className="px-3 py-2">{item.event}</td>
  273. <td className="px-3 py-2">{item.conversation_id ?? "-"}</td>
  274. <td className="px-3 py-2">{item.source}</td>
  275. <td className="px-3 py-2 max-w-[560px] truncate" title={item.message}>{item.message}</td>
  276. </tr>
  277. ))}
  278. {(data?.items ?? []).length === 0 && !loading && (
  279. <tr>
  280. <td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>
  281. {t("agent.logs.empty")}
  282. </td>
  283. </tr>
  284. )}
  285. </tbody>
  286. </table>
  287. </div>
  288. <div className="px-3 py-2 border-t flex items-center justify-end gap-2">
  289. <Button
  290. variant="outline"
  291. size="sm"
  292. disabled={loading || page <= 1}
  293. onClick={() => setPage((p) => Math.max(1, p - 1))}
  294. >
  295. {t("common.prevPage")}
  296. </Button>
  297. <Button
  298. variant="outline"
  299. size="sm"
  300. disabled={loading || page >= totalPages}
  301. onClick={() => setPage((p) => p + 1)}
  302. >
  303. {t("common.nextPage")}
  304. </Button>
  305. </div>
  306. </div>
  307. <Dialog
  308. open={Boolean(selected)}
  309. onOpenChange={(open) => {
  310. if (!open) setSelected(null);
  311. }}
  312. >
  313. <DialogContent className="max-w-4xl">
  314. <DialogHeader>
  315. <DialogTitle className="flex items-center gap-2">
  316. <span>{t("agent.logs.detail.title")}</span>
  317. {selected ? (
  318. <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"}`}>
  319. {selected.level}
  320. </span>
  321. ) : null}
  322. </DialogTitle>
  323. </DialogHeader>
  324. {selected ? (
  325. <div className="space-y-3">
  326. <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
  327. <div className="rounded-lg border p-2">
  328. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.time")}</div>
  329. <div className="font-medium">
  330. {new Date(selected.timestamp).toLocaleString(locale)}
  331. </div>
  332. </div>
  333. <div className="rounded-lg border p-2">
  334. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.sourceEvent")}</div>
  335. <div className="font-medium">
  336. {selected.source} / {selected.event}
  337. </div>
  338. </div>
  339. <div className="rounded-lg border p-2">
  340. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.category")}</div>
  341. <div className="font-medium">{selected.category}</div>
  342. </div>
  343. <div className="rounded-lg border p-2">
  344. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.traceId")}</div>
  345. <div className="font-medium break-all">{selected.trace_id || "-"}</div>
  346. </div>
  347. <div className="rounded-lg border p-2">
  348. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.conversationId")}</div>
  349. <div className="font-medium">{selected.conversation_id ?? "-"}</div>
  350. </div>
  351. <div className="rounded-lg border p-2">
  352. <div className="text-xs text-muted-foreground">{t("agent.logs.detail.userVisitor")}</div>
  353. <div className="font-medium">
  354. {selected.user_id ?? "-"} / {selected.visitor_id ?? "-"}
  355. </div>
  356. </div>
  357. </div>
  358. <div className="rounded-lg border p-3">
  359. <div className="flex items-center justify-between gap-2 mb-2">
  360. <div className="text-sm font-medium">{t("agent.logs.detail.message")}</div>
  361. <Button
  362. size="sm"
  363. variant="outline"
  364. onClick={async () => {
  365. try {
  366. await navigator.clipboard.writeText(selected.message);
  367. toast.success(t("agent.logs.toast.messageCopied"));
  368. } catch {
  369. toast.error(t("agent.logs.toast.copyFailed"));
  370. }
  371. }}
  372. >
  373. <Copy className="h-4 w-4 mr-1" />
  374. {t("common.copy")}
  375. </Button>
  376. </div>
  377. <pre className="whitespace-pre-wrap text-sm bg-muted/30 rounded p-2 max-h-48 overflow-auto">{selected.message}</pre>
  378. </div>
  379. <div className="rounded-lg border p-3">
  380. <div className="text-sm font-medium mb-2">{t("agent.logs.detail.metaJson")}</div>
  381. <pre className="whitespace-pre-wrap text-xs bg-muted/30 rounded p-2 max-h-80 overflow-auto">
  382. {selectedMeta || t("agent.logs.detail.noMeta")}
  383. </pre>
  384. </div>
  385. </div>
  386. ) : null}
  387. </DialogContent>
  388. </Dialog>
  389. </div>
  390. );
  391. }