improve admin usage analytics
This commit is contained in:
+216
-13
@@ -13,20 +13,53 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type AdminSummary = {
|
||||
period?: PeriodInfo;
|
||||
totals?: {
|
||||
accounts: number;
|
||||
sessions: number;
|
||||
visible_sessions?: number;
|
||||
child_sessions?: number;
|
||||
tool_calls: number;
|
||||
tokens: number;
|
||||
};
|
||||
daily_series?: DailyPoint[];
|
||||
memory_queue?: MemoryQueue;
|
||||
accounts?: AccountRow[];
|
||||
};
|
||||
|
||||
type PeriodKey =
|
||||
| "this_month"
|
||||
| "last_month"
|
||||
| "last_7_days"
|
||||
| "last_30_days"
|
||||
| "all";
|
||||
|
||||
type PeriodInfo = {
|
||||
key: PeriodKey;
|
||||
label: string;
|
||||
start?: number | null;
|
||||
end?: number | null;
|
||||
};
|
||||
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
sessions: number;
|
||||
visible_sessions?: number;
|
||||
child_sessions?: number;
|
||||
tool_calls: number;
|
||||
tokens: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
};
|
||||
|
||||
type AccountRow = {
|
||||
account_id: string;
|
||||
registered: boolean;
|
||||
period?: PeriodKey;
|
||||
session_count: number;
|
||||
visible_session_count?: number;
|
||||
child_session_count?: number;
|
||||
tool_calls: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -36,6 +69,7 @@ type AccountRow = {
|
||||
average_tool_calls_per_session: number;
|
||||
latest_session_at: number;
|
||||
models: Record<string, number>;
|
||||
daily_series?: DailyPoint[];
|
||||
user_memory_lines: number;
|
||||
skill_memory_count: number;
|
||||
};
|
||||
@@ -71,6 +105,13 @@ type MemoryEvent = {
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "zk-admin-token";
|
||||
const PERIOD_OPTIONS: Array<{ key: PeriodKey; label: string }> = [
|
||||
{ key: "this_month", label: "本月" },
|
||||
{ key: "last_month", label: "上月" },
|
||||
{ key: "last_7_days", label: "近7天" },
|
||||
{ key: "last_30_days", label: "近30天" },
|
||||
{ key: "all", label: "全部" },
|
||||
];
|
||||
|
||||
export default function AdminPage() {
|
||||
const [token, setToken] = useState("");
|
||||
@@ -159,6 +200,7 @@ function AdminDashboard({
|
||||
const [events, setEvents] = useState<MemoryEvent[]>([]);
|
||||
const [newAccountId, setNewAccountId] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [period, setPeriod] = useState<PeriodKey>("this_month");
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((item) => item.account_id === selectedAccountId),
|
||||
@@ -168,7 +210,7 @@ function AdminDashboard({
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 首次进入后台时加载一次即可,后续由刷新按钮触发。
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
}, [period]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 账号切换时加载对应记忆;函数体依赖当前 token。
|
||||
useEffect(() => {
|
||||
@@ -177,9 +219,10 @@ function AdminDashboard({
|
||||
}, [selectedAccountId]);
|
||||
|
||||
async function refresh() {
|
||||
const query = `period=${encodeURIComponent(period)}`;
|
||||
const [summaryPayload, accountPayload] = await Promise.all([
|
||||
adminFetch<AdminSummary>(token, "/summary"),
|
||||
adminFetch<AccountRow[]>(token, "/accounts"),
|
||||
adminFetch<AdminSummary>(token, `/summary?${query}`),
|
||||
adminFetch<AccountRow[]>(token, `/accounts?${query}`),
|
||||
]);
|
||||
setSummary(summaryPayload);
|
||||
setAccounts(accountPayload);
|
||||
@@ -258,10 +301,48 @@ function AdminDashboard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={period === option.key ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriod(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
当前统计:{summary?.period?.label ?? "本月"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<Metric title="用户" value={summary?.totals?.accounts ?? 0} />
|
||||
<Metric title="会话" value={summary?.totals?.sessions ?? 0} />
|
||||
<Metric
|
||||
title="主会话"
|
||||
value={summary?.totals?.visible_sessions ?? 0}
|
||||
/>
|
||||
<Metric title="子会话" value={summary?.totals?.child_sessions ?? 0} />
|
||||
<Metric title="工具调用" value={summary?.totals?.tool_calls ?? 0} />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_1fr_240px]">
|
||||
<TrendCard
|
||||
title="每日会话"
|
||||
data={summary?.daily_series ?? []}
|
||||
valueKey="sessions"
|
||||
/>
|
||||
<TrendCard
|
||||
title="每日工具调用"
|
||||
data={summary?.daily_series ?? []}
|
||||
valueKey="tool_calls"
|
||||
/>
|
||||
<TrendCard
|
||||
title="每日 Token"
|
||||
data={summary?.daily_series ?? []}
|
||||
valueKey="tokens"
|
||||
/>
|
||||
<Metric
|
||||
title="Token"
|
||||
value={formatNumber(summary?.totals?.tokens ?? 0)}
|
||||
@@ -305,7 +386,7 @@ function AdminDashboard({
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 text-muted-foreground text-xs">
|
||||
{account.tool_calls} 工具 ·{" "}
|
||||
{account.session_count} 会话 · {account.tool_calls} 工具 ·{" "}
|
||||
{formatNumber(account.total_tokens)} tokens
|
||||
</span>
|
||||
</button>
|
||||
@@ -316,6 +397,14 @@ function AdminDashboard({
|
||||
<section className="grid gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<Metric title="账号" value={selectedAccount?.account_id ?? "-"} />
|
||||
<Metric
|
||||
title="本周期会话"
|
||||
value={selectedAccount?.session_count ?? 0}
|
||||
/>
|
||||
<Metric
|
||||
title="主/子会话"
|
||||
value={`${selectedAccount?.visible_session_count ?? 0}/${selectedAccount?.child_session_count ?? 0}`}
|
||||
/>
|
||||
<Metric
|
||||
title="用户记忆行"
|
||||
value={selectedAccount?.user_memory_lines ?? 0}
|
||||
@@ -324,14 +413,6 @@ function AdminDashboard({
|
||||
title="Skill 记忆"
|
||||
value={selectedAccount?.skill_memory_count ?? 0}
|
||||
/>
|
||||
<Metric
|
||||
title="队列 pending"
|
||||
value={
|
||||
summary?.memory_queue?.accounts.find(
|
||||
(item) => item.account_id === selectedAccountId,
|
||||
)?.pending ?? 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
@@ -398,6 +479,24 @@ function AdminDashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<TrendCard
|
||||
title="当前账号会话"
|
||||
data={selectedAccount?.daily_series ?? []}
|
||||
valueKey="sessions"
|
||||
/>
|
||||
<TrendCard
|
||||
title="当前账号工具调用"
|
||||
data={selectedAccount?.daily_series ?? []}
|
||||
valueKey="tool_calls"
|
||||
/>
|
||||
<TrendCard
|
||||
title="当前账号 Token"
|
||||
data={selectedAccount?.daily_series ?? []}
|
||||
valueKey="tokens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<div className="grid gap-4 content-start">
|
||||
<div className="rounded-lg border bg-card">
|
||||
@@ -503,6 +602,104 @@ function SmallStat({
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCard({
|
||||
title,
|
||||
data,
|
||||
valueKey,
|
||||
}: {
|
||||
title: string;
|
||||
data: DailyPoint[];
|
||||
valueKey: keyof Pick<DailyPoint, "sessions" | "tool_calls" | "tokens">;
|
||||
}) {
|
||||
const points = data.filter((item) => item.date);
|
||||
const latest = points.at(-1)?.[valueKey] ?? 0;
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-muted-foreground text-xs">{title}</div>
|
||||
<div className="font-medium text-sm">
|
||||
{formatNumber(Number(latest))}
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline data={points} valueKey={valueKey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
data,
|
||||
valueKey,
|
||||
}: {
|
||||
data: DailyPoint[];
|
||||
valueKey: keyof Pick<DailyPoint, "sessions" | "tool_calls" | "tokens">;
|
||||
}) {
|
||||
const width = 280;
|
||||
const height = 72;
|
||||
const padX = 8;
|
||||
const padY = 10;
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="mt-2 flex h-[72px] items-center justify-center rounded-md bg-muted/30 text-muted-foreground text-xs">
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const values = data.map((item) => Number(item[valueKey] ?? 0));
|
||||
const max = Math.max(...values, 1);
|
||||
const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0;
|
||||
const coords = values.map((value, index) => {
|
||||
const x = padX + step * index;
|
||||
const y = height - padY - (value / max) * (height - padY * 2);
|
||||
return [x, y] as const;
|
||||
});
|
||||
const path = coords
|
||||
.map(
|
||||
([x, y], index) =>
|
||||
`${index === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-[72px] w-full rounded-md bg-muted/20"
|
||||
role="img"
|
||||
aria-label="趋势折线"
|
||||
>
|
||||
<path
|
||||
d={`M${padX},${height - padY}H${width - padX}`}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{coords.map(([x, y], index) => {
|
||||
const point = data[index];
|
||||
return (
|
||||
<circle
|
||||
key={point?.date ?? `${x}-${y}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="2"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-1 flex justify-between text-muted-foreground text-[11px]">
|
||||
<span>{formatShortDate(data[0]?.date)}</span>
|
||||
<span>{formatShortDate(data.at(-1)?.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function adminFetch<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
@@ -540,3 +737,9 @@ function formatTime(value?: number) {
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
function formatShortDate(value?: string) {
|
||||
if (!value) return "";
|
||||
const parts = value.split("-");
|
||||
return parts.length === 3 ? `${parts[1]}/${parts[2]}` : value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user