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
+254 -23
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
import csv
from datetime import datetime, timedelta
import sys
import hashlib
import shlex
@@ -26,7 +27,7 @@ import venv
from dataclasses import dataclass, field, replace
from pathlib import Path
from threading import Lock, RLock
from typing import Any, Callable
from typing import Any, Callable, Iterable
from urllib import error, request
from urllib.parse import quote, unquote, urlparse
from uuid import uuid4
@@ -1510,14 +1511,14 @@ def create_app(state: AgentState) -> FastAPI:
raise HTTPException(status_code=401, detail='管理员账号或密码错误')
@app.get('/api/admin/summary')
async def admin_summary(token: str) -> dict[str, Any]:
async def admin_summary(token: str, period: str = 'this_month') -> dict[str, Any]:
_require_admin_token(token)
return _build_admin_summary(state)
return _build_admin_summary(state, period=period)
@app.get('/api/admin/accounts')
async def admin_accounts(token: str) -> list[dict[str, Any]]:
async def admin_accounts(token: str, period: str = 'this_month') -> list[dict[str, Any]]:
_require_admin_token(token)
return _list_admin_accounts(state)
return _list_admin_accounts(state, period=period)
@app.post('/api/admin/accounts')
async def admin_create_account(
@@ -4774,22 +4775,76 @@ def _hash_admin_created_password(password: str = '123456') -> str:
return f'{salt}:{digest}'
def _build_admin_summary(state: AgentState) -> dict[str, Any]:
accounts = _list_admin_accounts(state)
ADMIN_PERIOD_LABELS = {
'this_month': '本月',
'last_month': '上月',
'last_7_days': '近7天',
'last_30_days': '近30天',
'all': '全部',
}
def _admin_period_bounds(period: str) -> tuple[str, str, float | None, float | None]:
key = period if period in ADMIN_PERIOD_LABELS else 'this_month'
now = datetime.now().astimezone()
if key == 'all':
return key, ADMIN_PERIOD_LABELS[key], None, None
if key == 'last_7_days':
start = now - timedelta(days=7)
return key, ADMIN_PERIOD_LABELS[key], start.timestamp(), None
if key == 'last_30_days':
start = now - timedelta(days=30)
return key, ADMIN_PERIOD_LABELS[key], start.timestamp(), None
this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if key == 'last_month':
last_month_end = this_month_start
last_month_start = (this_month_start - timedelta(days=1)).replace(day=1)
return (
key,
ADMIN_PERIOD_LABELS[key],
last_month_start.timestamp(),
last_month_end.timestamp(),
)
return key, ADMIN_PERIOD_LABELS[key], this_month_start.timestamp(), None
def _admin_period_payload(period: str) -> dict[str, Any]:
key, label, start, end = _admin_period_bounds(period)
return {
'key': key,
'label': label,
'start': start,
'end': end,
}
def _build_admin_summary(state: AgentState, *, period: str = 'this_month') -> dict[str, Any]:
accounts = _list_admin_accounts(state, period=period)
totals = {
'accounts': len(accounts),
'sessions': sum(item['session_count'] for item in accounts),
'visible_sessions': sum(item.get('visible_session_count', 0) for item in accounts),
'child_sessions': sum(item.get('child_session_count', 0) for item in accounts),
'tool_calls': sum(item['tool_calls'] for item in accounts),
'tokens': sum(item['total_tokens'] for item in accounts),
}
return {
'period': _admin_period_payload(period),
'totals': totals,
'daily_series': _merge_admin_daily_series(
item.get('daily_series', []) for item in accounts
),
'memory_queue': state.memory_manager.queue_snapshot(),
'accounts': accounts[:20],
}
def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
def _list_admin_accounts(
state: AgentState,
*,
period: str = 'this_month',
) -> list[dict[str, Any]]:
period_key, _period_label, start_ts, end_ts = _admin_period_bounds(period)
users_payload = _load_users_file(state)
known_users = {
str(item.get('id') or item.get('username') or '').strip()
@@ -4806,13 +4861,19 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
if accounts_root.exists()
else set()
)
account_ids = sorted({item for item in known_users | directory_users if item})
linux_workspace_users = _linux_workspace_account_ids() if _linux_accounts_enabled() else set()
account_ids = sorted({
item
for item in known_users | directory_users | linux_workspace_users
if item
})
result: list[dict[str, Any]] = []
for account_id in account_ids:
base = accounts_root / account_id
sessions_dir = base / 'sessions'
session_files = _iter_session_files(sessions_dir)
bases = _admin_account_bases(state, account_id)
session_files = _iter_admin_session_files(bases)
session_count = 0
visible_session_count = 0
child_session_count = 0
tool_calls = 0
total_tokens = 0
input_tokens = 0
@@ -4820,31 +4881,67 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
reasoning_tokens = 0
latest_mtime = 0.0
models: dict[str, int] = {}
daily_buckets: dict[str, dict[str, Any]] = {}
for path in session_files:
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
continue
mtime = _session_mtime(path)
if start_ts is not None and mtime < start_ts:
continue
if end_ts is not None and mtime >= end_ts:
continue
session_metadata = (
data.get('session_metadata')
if isinstance(data.get('session_metadata'), dict)
else {}
)
is_child = session_metadata.get('visibility') == 'child'
session_count += 1
tool_calls += int(data.get('tool_calls') or 0)
if is_child:
child_session_count += 1
else:
visible_session_count += 1
row_tool_calls = int(data.get('tool_calls') or 0)
tool_calls += row_tool_calls
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
input_tokens += int(usage.get('input_tokens') or 0)
output_tokens += int(usage.get('output_tokens') or 0)
reasoning_tokens += int(usage.get('reasoning_tokens') or 0)
total_tokens += int(
row_input_tokens = int(usage.get('input_tokens') or 0)
row_output_tokens = int(usage.get('output_tokens') or 0)
row_reasoning_tokens = int(usage.get('reasoning_tokens') or 0)
row_total_tokens = int(
usage.get('total_tokens')
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
)
input_tokens += row_input_tokens
output_tokens += row_output_tokens
reasoning_tokens += row_reasoning_tokens
total_tokens += row_total_tokens
model_config = data.get('model_config') if isinstance(data.get('model_config'), dict) else {}
model = str(model_config.get('model') or data.get('model') or 'unknown')
models[model] = models.get(model, 0) + 1
latest_mtime = max(latest_mtime, _session_mtime(path))
memory_dir = base / 'memory'
latest_mtime = max(latest_mtime, mtime)
_admin_add_daily_bucket(
daily_buckets,
mtime=mtime,
sessions=1,
visible_sessions=0 if is_child else 1,
child_sessions=1 if is_child else 0,
tool_calls=row_tool_calls,
input_tokens=row_input_tokens,
output_tokens=row_output_tokens,
reasoning_tokens=row_reasoning_tokens,
total_tokens=row_total_tokens,
)
memory_dirs = [base / 'memory' for base in bases]
result.append(
{
'account_id': account_id,
'registered': account_id in known_users,
'period': period_key,
'session_count': session_count,
'visible_session_count': visible_session_count,
'child_session_count': child_session_count,
'tool_calls': tool_calls,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
@@ -4858,15 +4955,149 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
),
'latest_session_at': latest_mtime,
'models': models,
'user_memory_lines': _count_optional_lines(memory_dir / USER_MEMORY_FILENAME),
'skill_memory_count': len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
else 0,
'daily_series': _admin_daily_series(daily_buckets),
'user_memory_lines': max(
(
_count_optional_lines(memory_dir / USER_MEMORY_FILENAME)
for memory_dir in memory_dirs
),
default=0,
),
'skill_memory_count': max(
(
len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
else 0
for memory_dir in memory_dirs
),
default=0,
),
}
)
return sorted(result, key=lambda item: item['latest_session_at'], reverse=True)
def _linux_workspace_account_ids() -> set[str]:
home = Path('/home')
if not home.exists():
return set()
result: set[str] = set()
for path in home.iterdir():
if path.is_dir() and (path / LINUX_ACCOUNT_WORKSPACE_NAME).is_dir():
result.add(path.name)
return result
def _admin_account_bases(state: AgentState, account_id: str) -> list[Path]:
candidates = [
state.account_paths(account_id)['base'],
_accounts_root(state) / account_id,
]
bases: list[Path] = []
seen: set[str] = set()
for base in candidates:
key = str(base.resolve(strict=False))
if key in seen:
continue
seen.add(key)
bases.append(base)
return bases
def _iter_admin_session_files(bases: list[Path]) -> list[Path]:
by_session_id: dict[str, Path] = {}
for base in bases:
for path in _iter_session_files(base / 'sessions'):
session_id = _session_id_from_path(path)
existing = by_session_id.get(session_id)
if existing is None or _session_mtime(path) >= _session_mtime(existing):
by_session_id[session_id] = path
return list(by_session_id.values())
def _admin_day_key(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).astimezone().strftime('%Y-%m-%d')
def _admin_add_daily_bucket(
buckets: dict[str, dict[str, Any]],
*,
mtime: float,
sessions: int,
visible_sessions: int,
child_sessions: int,
tool_calls: int,
input_tokens: int,
output_tokens: int,
reasoning_tokens: int,
total_tokens: int,
) -> None:
key = _admin_day_key(mtime)
bucket = buckets.setdefault(
key,
{
'date': key,
'sessions': 0,
'visible_sessions': 0,
'child_sessions': 0,
'tool_calls': 0,
'input_tokens': 0,
'output_tokens': 0,
'reasoning_tokens': 0,
'tokens': 0,
},
)
bucket['sessions'] += sessions
bucket['visible_sessions'] += visible_sessions
bucket['child_sessions'] += child_sessions
bucket['tool_calls'] += tool_calls
bucket['input_tokens'] += input_tokens
bucket['output_tokens'] += output_tokens
bucket['reasoning_tokens'] += reasoning_tokens
bucket['tokens'] += total_tokens
def _admin_daily_series(buckets: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
return [buckets[key] for key in sorted(buckets)]
def _merge_admin_daily_series(
series_items: Iterable[list[dict[str, Any]]],
) -> list[dict[str, Any]]:
buckets: dict[str, dict[str, Any]] = {}
for series in series_items:
for item in series:
date = str(item.get('date') or '')
if not date:
continue
bucket = buckets.setdefault(
date,
{
'date': date,
'sessions': 0,
'visible_sessions': 0,
'child_sessions': 0,
'tool_calls': 0,
'input_tokens': 0,
'output_tokens': 0,
'reasoning_tokens': 0,
'tokens': 0,
},
)
for key in (
'sessions',
'visible_sessions',
'child_sessions',
'tool_calls',
'input_tokens',
'output_tokens',
'reasoning_tokens',
'tokens',
):
bucket[key] += int(item.get(key) or 0)
return _admin_daily_series(buckets)
def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
safe_id = _safe_account_id(account_id)
if safe_id == 'default':
+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;
}