513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
|
||
import { useMemo, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
|
||
import {
|
||
downloadLlmMetricsExcel,
|
||
type LlmMetricsFilters,
|
||
} from "@/core/llm-metrics/api";
|
||
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTheme } from "@/contexts/ThemeContext";
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
type StatusFilter = "all" | "success" | "error";
|
||
|
||
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
|
||
// Prefer the resolved email's local part (sent by the backend). Fall back to
|
||
// the raw UUID only when no account record exists for the recorded user_id —
|
||
// showing a UUID is uglier but preserves the link to who triggered the call.
|
||
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
|
||
const id = (row.user_id ?? "").trim();
|
||
return id || "—";
|
||
}
|
||
|
||
function formatDate(value?: string | null): string {
|
||
if (!value) return "—";
|
||
try {
|
||
return new Date(value).toLocaleString();
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function formatNumber(value?: number | null, fractionDigits = 0): string {
|
||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||
return value.toLocaleString(undefined, {
|
||
minimumFractionDigits: fractionDigits,
|
||
maximumFractionDigits: fractionDigits,
|
||
});
|
||
}
|
||
|
||
function formatDuration(value?: number | null): string {
|
||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||
if (value === 0) return "0 ms";
|
||
if (value < 1000) return `${value} ms`;
|
||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||
const minutes = Math.floor(value / 60_000);
|
||
const seconds = Math.round((value % 60_000) / 1000);
|
||
return `${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
function speedColor(tps?: number | null): string {
|
||
if (tps == null) return "#9ca3af";
|
||
if (tps >= 50) return "#16a34a";
|
||
if (tps >= 30) return "#65a30d";
|
||
if (tps >= 15) return "#eab308";
|
||
if (tps >= 5) return "#f97316";
|
||
return "#ef4444";
|
||
}
|
||
|
||
/** The original LLM-call metrics view, now one tab of the metrics page. */
|
||
function LlmMetricsPanel() {
|
||
const { theme } = useTheme();
|
||
const [page, setPage] = useState(0);
|
||
const [model, setModel] = useState("");
|
||
const [user, setUser] = useState("");
|
||
const [status, setStatus] = useState<StatusFilter>("all");
|
||
const [since, setSince] = useState("");
|
||
const [until, setUntil] = useState("");
|
||
const [exporting, setExporting] = useState(false);
|
||
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
|
||
const successToastDarkOptions = {
|
||
classNames: {
|
||
toast:
|
||
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
|
||
title: "!text-[#FFFFFF]",
|
||
description: "!text-[#FFFFFF]",
|
||
},
|
||
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
|
||
} as const;
|
||
|
||
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
|
||
const errorToastDarkOptions = {
|
||
classNames: {
|
||
toast:
|
||
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
|
||
title: "!text-[#FFFFFF]",
|
||
description: "!text-[#FFFFFF]",
|
||
},
|
||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||
} as const;
|
||
const darkToastOpts =
|
||
theme === "dark"
|
||
? { success: successToastDarkOptions, error: errorToastDarkOptions }
|
||
: { success: undefined, error: undefined };
|
||
|
||
const filters = useMemo<LlmMetricsFilters>(() => {
|
||
const out: LlmMetricsFilters = {};
|
||
if (model.trim()) out.model_name = model.trim();
|
||
if (user.trim()) out.user_name = user.trim();
|
||
if (status !== "all") out.status = status;
|
||
if (since) out.since = new Date(since).toISOString();
|
||
if (until) out.until = new Date(until).toISOString();
|
||
return out;
|
||
}, [model, user, status, since, until]);
|
||
|
||
const query = useMemo(
|
||
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
|
||
[filters, page],
|
||
);
|
||
|
||
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
|
||
|
||
const handleExport = async () => {
|
||
setExporting(true);
|
||
try {
|
||
await downloadLlmMetricsExcel(filters);
|
||
toast.success("已导出为 Excel",darkToastOpts.success);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setModel("");
|
||
setUser("");
|
||
setStatus("all");
|
||
setSince("");
|
||
setUntil("");
|
||
setPage(0);
|
||
};
|
||
|
||
const items = data?.items ?? [];
|
||
const total = data?.total ?? 0;
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
|
||
// Hide columns that are empty for every visible row. Keeps the table dense
|
||
// when older records lack a tokens breakdown — better to drop the column
|
||
// than fill the page with placeholder dashes.
|
||
const visibleColumns = useMemo(() => {
|
||
type Col = {
|
||
id: string;
|
||
header: string;
|
||
align?: "left" | "right";
|
||
alwaysShow?: boolean;
|
||
hasValue: (row: typeof items[number]) => boolean;
|
||
render: (row: typeof items[number]) => React.ReactNode;
|
||
title?: (row: typeof items[number]) => string | undefined;
|
||
className?: string;
|
||
};
|
||
const columns: Col[] = [
|
||
{
|
||
id: "time",
|
||
header: "时间",
|
||
alwaysShow: true,
|
||
hasValue: () => true,
|
||
render: (row) => formatDate(row.created_at),
|
||
},
|
||
{
|
||
id: "user",
|
||
header: "用户",
|
||
alwaysShow: true,
|
||
hasValue: () => true,
|
||
render: (row) => displayUser(row),
|
||
title: (row) => row.user_id ?? undefined,
|
||
},
|
||
{
|
||
id: "model",
|
||
header: "模型",
|
||
alwaysShow: true,
|
||
hasValue: () => true,
|
||
render: (row) => row.model_name ?? "—",
|
||
},
|
||
{
|
||
id: "agent",
|
||
header: "Agent",
|
||
hasValue: (row) => Boolean(row.agent_name),
|
||
render: (row) => row.agent_name ?? "—",
|
||
},
|
||
{
|
||
id: "input",
|
||
header: "输入",
|
||
align: "right",
|
||
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
|
||
render: (row) => formatNumber(row.input_tokens),
|
||
className: "tabular-nums",
|
||
},
|
||
{
|
||
id: "output",
|
||
header: "输出",
|
||
align: "right",
|
||
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
|
||
render: (row) => formatNumber(row.output_tokens),
|
||
className: "tabular-nums",
|
||
},
|
||
{
|
||
id: "total",
|
||
header: "总 Token",
|
||
align: "right",
|
||
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
|
||
render: (row) => formatNumber(row.total_tokens),
|
||
className: "tabular-nums",
|
||
},
|
||
{
|
||
id: "duration",
|
||
header: "耗时",
|
||
align: "right",
|
||
alwaysShow: true,
|
||
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
|
||
render: (row) => formatDuration(row.duration_ms),
|
||
title: (row) => `${row.duration_ms} ms`,
|
||
className: "tabular-nums",
|
||
},
|
||
{
|
||
id: "tokens_per_sec",
|
||
header: "tok/s",
|
||
align: "right",
|
||
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
|
||
render: (row) =>
|
||
row.tokens_per_sec != null ? (
|
||
<span
|
||
className="font-mono tabular-nums"
|
||
style={{ color: speedColor(row.tokens_per_sec) }}
|
||
>
|
||
{row.tokens_per_sec.toFixed(1)}
|
||
</span>
|
||
) : (
|
||
"—"
|
||
),
|
||
},
|
||
{
|
||
id: "status",
|
||
header: "状态",
|
||
alwaysShow: true,
|
||
hasValue: () => true,
|
||
render: (row) => (
|
||
<Badge
|
||
variant={row.status === "success" ? "default" : "destructive"}
|
||
>
|
||
{row.status === "success" ? "成功" : "失败"}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
id: "error",
|
||
header: "错误",
|
||
hasValue: (row) => Boolean(row.error_type || row.error_message),
|
||
render: (row) => row.error_type ?? row.error_message ?? "—",
|
||
title: (row) => row.error_message ?? undefined,
|
||
className: "text-muted-foreground max-w-[260px] truncate",
|
||
},
|
||
];
|
||
return columns.filter(
|
||
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
|
||
);
|
||
}, [items]);
|
||
|
||
const colSpan = visibleColumns.length || 1;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-lg font-semibold">大模型调用统计</h2>
|
||
<p className="text-muted-foreground mt-1 text-sm">
|
||
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
|
||
后台无条件记录,与前端速度指示器开关无关。
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleExport} disabled={exporting}>
|
||
{exporting ? (
|
||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||
) : (
|
||
<Download className="mr-1 size-4" />
|
||
)}
|
||
导出 Excel
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||
<div className="flex flex-col gap-1 ">
|
||
<label className="text-muted-foreground text-xs dark:text-white">模型</label>
|
||
<Input
|
||
value={model}
|
||
placeholder="按模型名筛选"
|
||
onChange={(e) => {
|
||
setModel(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
|
||
<Input
|
||
value={user}
|
||
placeholder="按用户名模糊搜索(@ 前部分)"
|
||
onChange={(e) => {
|
||
setUser(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
|
||
<Select
|
||
value={status}
|
||
onValueChange={(value) => {
|
||
setStatus(value as StatusFilter);
|
||
setPage(0);
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
<SelectItem value="success">成功</SelectItem>
|
||
<SelectItem value="error">失败</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
|
||
<Input
|
||
type="datetime-local"
|
||
value={since}
|
||
onChange={(e) => {
|
||
setSince(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
|
||
<Input
|
||
type="datetime-local"
|
||
value={until}
|
||
onChange={(e) => {
|
||
setUntil(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-5 flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||
清空筛选
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] ">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[1080px] text-sm">
|
||
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
|
||
<tr>
|
||
{visibleColumns.map((col) => (
|
||
<Th
|
||
key={col.id}
|
||
className={col.align === "right" ? "text-right" : undefined}
|
||
>
|
||
{col.header}
|
||
</Th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading && (
|
||
<tr>
|
||
<td colSpan={colSpan} className="py-10 text-center">
|
||
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!isLoading && error && (
|
||
<tr>
|
||
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
|
||
加载失败:{error.message}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!isLoading && !error && items.length === 0 && (
|
||
<tr>
|
||
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
|
||
暂无符合条件的调用记录。
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{items.map((row) => (
|
||
<tr key={row.id} className="border-t">
|
||
{visibleColumns.map((col) => (
|
||
<Td
|
||
key={col.id}
|
||
className={cn(
|
||
col.align === "right" && "text-right",
|
||
col.className,
|
||
)}
|
||
title={col.title?.(row)}
|
||
>
|
||
{col.render(row)}
|
||
</Td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
|
||
<span className="text-muted-foreground">
|
||
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={page <= 0 || isLoading}
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<span className="text-muted-foreground">
|
||
第 {page + 1} / {totalPages} 页
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={page + 1 >= totalPages || isLoading}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Th({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<th
|
||
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
|
||
>
|
||
{children}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
function Td({
|
||
children,
|
||
className,
|
||
title,
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
title?: string;
|
||
}) {
|
||
return (
|
||
<td
|
||
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
|
||
title={title}
|
||
>
|
||
{children}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Admin metrics page. Two tabs: LLM provider calls and tool / skill calls —
|
||
* the latter surfaces retrieval/IP-block/rate-limit failures.
|
||
*/
|
||
export default function LlmMetricsPage() {
|
||
const [tab, setTab] = useState<"llm" | "tool">("llm");
|
||
return (
|
||
<div className="flex size-full min-h-0 w-full">
|
||
<ScrollArea className="min-h-0 flex-1">
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
|
||
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
|
||
<TabsList>
|
||
<TabsTrigger value="llm">大模型调用</TabsTrigger>
|
||
<TabsTrigger value="tool">工具调用</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
);
|
||
}
|