improve admin usage analytics
This commit is contained in:
+254
-23
@@ -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
@@ -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