Files
AI-CS/frontend/app/agent/logs/page.tsx
T
2026-03-25 18:50:58 +08:00

289 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { fetchSystemLogs, 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 selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]);
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-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>
);
}