improve admin usage analytics

This commit is contained in:
wuyang6
2026-06-12 11:44:48 +08:00
parent 26e886fb57
commit dbd279f333
2 changed files with 470 additions and 36 deletions
+216 -13
View File
@@ -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;
}