| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- "use client";
- import { useCallback, useEffect, useMemo, useState } from "react";
- import { Button } from "@/components/ui/button";
- import {
- deleteLogMinLevelPolicy,
- fetchLogMinLevelPolicy,
- fetchSystemLogs,
- putLogMinLevelPolicy,
- type LogMinLevelPolicy,
- type QuerySystemLogsResult,
- } from "@/features/agent/services/systemLogApi";
- import { toast } from "@/hooks/useToast";
- import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- } from "@/components/ui/dialog";
- import { Copy } from "lucide-react";
- function tryFormatJSON(raw?: string | null): string {
- if (!raw) return "";
- try {
- const parsed = JSON.parse(raw);
- return JSON.stringify(parsed, null, 2);
- } catch {
- return raw;
- }
- }
- function levelColor(level: string): string {
- if (level === "error") return "text-red-600";
- if (level === "warn") return "text-amber-600";
- return "text-emerald-600";
- }
- export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
- const [from, setFrom] = useState(() => {
- const d = new Date();
- d.setDate(d.getDate() - 6);
- return d.toISOString().slice(0, 10);
- });
- const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10));
- const [level, setLevel] = useState("");
- const [category, setCategory] = useState("");
- const [source, setSource] = useState("");
- const [event, setEvent] = useState("");
- const [keyword, setKeyword] = useState("");
- const [conversationId, setConversationId] = useState("");
- const [data, setData] = useState<QuerySystemLogsResult | null>(null);
- const [loading, setLoading] = useState(false);
- const [page, setPage] = useState(1);
- const pageSize = 50;
- const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null);
- const [policy, setPolicy] = useState<LogMinLevelPolicy | null>(null);
- const [policyDraft, setPolicyDraft] = useState("info");
- const [policyLoading, setPolicyLoading] = useState(false);
- const selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]);
- const loadPolicy = useCallback(async () => {
- setPolicyLoading(true);
- try {
- const p = await fetchLogMinLevelPolicy();
- setPolicy(p);
- setPolicyDraft(p.effective_min_level);
- } catch (e) {
- toast.error((e as Error).message || "加载落库策略失败");
- setPolicy(null);
- } finally {
- setPolicyLoading(false);
- }
- }, []);
- useEffect(() => {
- void loadPolicy();
- }, [loadPolicy]);
- const load = useCallback(async () => {
- setLoading(true);
- try {
- const conv = conversationId.trim() ? Number(conversationId) : undefined;
- const res = await fetchSystemLogs({
- from,
- to,
- level: level || undefined,
- category: category || undefined,
- source: source || undefined,
- event: event || undefined,
- keyword: keyword || undefined,
- conversationId: conv,
- page,
- pageSize,
- });
- setData(res);
- } catch (e) {
- toast.error((e as Error).message || "加载日志失败");
- setData(null);
- } finally {
- setLoading(false);
- }
- }, [from, to, level, category, source, event, keyword, conversationId, page]);
- useEffect(() => {
- void load();
- }, [load]);
- const totalPages = useMemo(() => {
- if (!data) return 1;
- return Math.max(1, Math.ceil(data.total / data.page_size));
- }, [data]);
- return (
- <div className={`flex flex-col min-h-0 overflow-auto ${embedded ? "p-4" : "p-6 max-w-6xl mx-auto w-full"}`}>
- <div className="mb-4">
- <h1 className="text-xl font-semibold">日志中心</h1>
- <p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
- </div>
- <div className="rounded-xl border border-border/60 bg-card p-4 mb-4 space-y-3">
- <div className="flex flex-wrap items-start justify-between gap-3">
- <div>
- <h2 className="text-sm font-semibold">落库级别(性能)</h2>
- <p className="text-xs text-muted-foreground mt-1 max-w-xl">
- 仅将不低于所选级别的记录写入数据库。设为 <code className="text-foreground">warn</code> 可大幅减少成功类{" "}
- <code className="text-foreground">info</code> 写入。也可在根目录{" "}
- <code className="text-foreground">SYSTEM_LOG_MIN_LEVEL</code> 配置默认值;此处保存后会写入数据库并覆盖环境变量,直至点击「恢复环境变量」。
- </p>
- {policy ? (
- <p className="text-xs text-muted-foreground mt-2">
- 当前生效:<span className="font-medium text-foreground">{policy.effective_min_level}</span>
- {" · "}
- 环境变量默认:<span className="font-medium text-foreground">{policy.env_min_level}</span>
- {policy.persisted_in_database ? (
- <span className="text-amber-700 dark:text-amber-500">(已由控制台覆盖)</span>
- ) : null}
- </p>
- ) : null}
- </div>
- <div className="flex flex-wrap items-center gap-2">
- <select
- value={policyDraft}
- onChange={(e) => setPolicyDraft(e.target.value)}
- disabled={policyLoading}
- className="rounded-md border px-2 py-1.5 text-sm min-w-[140px]"
- >
- <option value="debug">debug</option>
- <option value="info">info</option>
- <option value="warn">warn</option>
- <option value="error">error</option>
- <option value="none">none(关闭落库)</option>
- </select>
- <Button
- size="sm"
- disabled={policyLoading}
- onClick={async () => {
- setPolicyLoading(true);
- try {
- await putLogMinLevelPolicy(policyDraft);
- toast.success("已保存并生效");
- await loadPolicy();
- } catch (e) {
- toast.error((e as Error).message || "保存失败");
- } finally {
- setPolicyLoading(false);
- }
- }}
- >
- 保存到服务器
- </Button>
- <Button
- size="sm"
- variant="outline"
- disabled={policyLoading}
- onClick={async () => {
- setPolicyLoading(true);
- try {
- await deleteLogMinLevelPolicy();
- toast.success("已恢复为环境变量默认值");
- await loadPolicy();
- } catch (e) {
- toast.error((e as Error).message || "恢复失败");
- } finally {
- setPolicyLoading(false);
- }
- }}
- >
- 恢复环境变量
- </Button>
- </div>
- </div>
- </div>
- <div className="rounded-xl border border-border/60 bg-card p-3 mb-4 flex flex-wrap gap-2 items-center">
- <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
- <span className="text-xs text-muted-foreground">到</span>
- <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
- <select value={level} onChange={(e) => setLevel(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
- <option value="">全部级别</option>
- <option value="info">info</option>
- <option value="warn">warn</option>
- <option value="error">error</option>
- </select>
- <select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
- <option value="">全部分类</option>
- <option value="ai">ai</option>
- <option value="rag">rag</option>
- <option value="frontend">frontend</option>
- <option value="system">system</option>
- <option value="business">business</option>
- <option value="http">http</option>
- <option value="vector">vector</option>
- </select>
- <select value={source} onChange={(e) => setSource(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
- <option value="">全部来源</option>
- <option value="backend">backend</option>
- <option value="frontend">frontend</option>
- </select>
- <input
- placeholder="事件名(event)"
- value={event}
- onChange={(e) => setEvent(e.target.value)}
- className="rounded-md border px-2 py-1 text-sm min-w-[180px]"
- />
- <input
- placeholder="会话ID"
- value={conversationId}
- onChange={(e) => setConversationId(e.target.value)}
- className="rounded-md border px-2 py-1 text-sm w-24"
- />
- <input
- placeholder="关键词(message/meta)"
- value={keyword}
- onChange={(e) => setKeyword(e.target.value)}
- className="rounded-md border px-2 py-1 text-sm min-w-[220px]"
- />
- <Button size="sm" disabled={loading} onClick={() => { setPage(1); void load(); }}>
- {loading ? "加载中..." : "查询"}
- </Button>
- </div>
- <div className="rounded-xl border border-border/60 bg-card overflow-hidden">
- <div className="px-3 py-2 border-b text-xs text-muted-foreground">
- 共 {data?.total ?? 0} 条,当前第 {data?.page ?? page}/{totalPages} 页
- </div>
- <div className="overflow-auto">
- <table className="w-full text-sm">
- <thead className="bg-muted/40 text-xs text-muted-foreground">
- <tr>
- <th className="text-left px-3 py-2">时间</th>
- <th className="text-left px-3 py-2">级别</th>
- <th className="text-left px-3 py-2">分类</th>
- <th className="text-left px-3 py-2">事件</th>
- <th className="text-left px-3 py-2">会话</th>
- <th className="text-left px-3 py-2">来源</th>
- <th className="text-left px-3 py-2">消息</th>
- </tr>
- </thead>
- <tbody>
- {(data?.items ?? []).map((item) => (
- <tr
- key={item.id}
- className="border-t cursor-pointer hover:bg-muted/30"
- onClick={() => setSelected(item)}
- >
- <td className="px-3 py-2 whitespace-nowrap text-xs">{new Date(item.timestamp).toLocaleString()}</td>
- <td className={`px-3 py-2 font-medium ${levelColor(item.level)}`}>{item.level}</td>
- <td className="px-3 py-2">{item.category}</td>
- <td className="px-3 py-2">{item.event}</td>
- <td className="px-3 py-2">{item.conversation_id ?? "-"}</td>
- <td className="px-3 py-2">{item.source}</td>
- <td className="px-3 py-2 max-w-[560px] truncate" title={item.message}>{item.message}</td>
- </tr>
- ))}
- {(data?.items ?? []).length === 0 && !loading && (
- <tr>
- <td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>暂无日志</td>
- </tr>
- )}
- </tbody>
- </table>
- </div>
- <div className="px-3 py-2 border-t flex items-center justify-end gap-2">
- <Button
- variant="outline"
- size="sm"
- disabled={loading || page <= 1}
- onClick={() => setPage((p) => Math.max(1, p - 1))}
- >
- 上一页
- </Button>
- <Button
- variant="outline"
- size="sm"
- disabled={loading || page >= totalPages}
- onClick={() => setPage((p) => p + 1)}
- >
- 下一页
- </Button>
- </div>
- </div>
- <Dialog
- open={Boolean(selected)}
- onOpenChange={(open) => {
- if (!open) setSelected(null);
- }}
- >
- <DialogContent className="max-w-4xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <span>日志详情</span>
- {selected ? (
- <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"}`}>
- {selected.level}
- </span>
- ) : null}
- </DialogTitle>
- </DialogHeader>
- {selected ? (
- <div className="space-y-3">
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">时间</div>
- <div className="font-medium">{new Date(selected.timestamp).toLocaleString()}</div>
- </div>
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">source / event</div>
- <div className="font-medium">
- {selected.source} / {selected.event}
- </div>
- </div>
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">category</div>
- <div className="font-medium">{selected.category}</div>
- </div>
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">trace_id</div>
- <div className="font-medium break-all">{selected.trace_id || "-"}</div>
- </div>
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">conversation_id</div>
- <div className="font-medium">{selected.conversation_id ?? "-"}</div>
- </div>
- <div className="rounded-lg border p-2">
- <div className="text-xs text-muted-foreground">user_id / visitor_id</div>
- <div className="font-medium">
- {selected.user_id ?? "-"} / {selected.visitor_id ?? "-"}
- </div>
- </div>
- </div>
- <div className="rounded-lg border p-3">
- <div className="flex items-center justify-between gap-2 mb-2">
- <div className="text-sm font-medium">message</div>
- <Button
- size="sm"
- variant="outline"
- onClick={async () => {
- try {
- await navigator.clipboard.writeText(selected.message);
- toast.success("已复制 message");
- } catch {
- toast.error("复制失败");
- }
- }}
- >
- <Copy className="h-4 w-4 mr-1" />
- 复制
- </Button>
- </div>
- <pre className="whitespace-pre-wrap text-sm bg-muted/30 rounded p-2 max-h-48 overflow-auto">{selected.message}</pre>
- </div>
- <div className="rounded-lg border p-3">
- <div className="text-sm font-medium mb-2">meta_json</div>
- <pre className="whitespace-pre-wrap text-xs bg-muted/30 rounded p-2 max-h-80 overflow-auto">
- {selectedMeta || "(无 meta_json)"}
- </pre>
- </div>
- </div>
- ) : null}
- </DialogContent>
- </Dialog>
- </div>
- );
- }
|