diff --git a/backend/api/server.py b/backend/api/server.py index e51e3ed..b815ee0 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -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': diff --git a/frontend/app/app/admin/page.tsx b/frontend/app/app/admin/page.tsx index 00696fb..2074230 100644 --- a/frontend/app/app/admin/page.tsx +++ b/frontend/app/app/admin/page.tsx @@ -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; + 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([]); const [newAccountId, setNewAccountId] = useState(""); const [notice, setNotice] = useState(""); + const [period, setPeriod] = useState("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(token, "/summary"), - adminFetch(token, "/accounts"), + adminFetch(token, `/summary?${query}`), + adminFetch(token, `/accounts?${query}`), ]); setSummary(summaryPayload); setAccounts(accountPayload); @@ -258,10 +301,48 @@ function AdminDashboard({ ) : null} -
+
+ {PERIOD_OPTIONS.map((option) => ( + + ))} + + 当前统计:{summary?.period?.label ?? "本月"} + +
+ +
+ + +
+
+ + + - {account.tool_calls} 工具 ·{" "} + {account.session_count} 会话 · {account.tool_calls} 工具 ·{" "} {formatNumber(account.total_tokens)} tokens @@ -316,6 +397,14 @@ function AdminDashboard({
+ + - item.account_id === selectedAccountId, - )?.pending ?? 0 - } - />
@@ -398,6 +479,24 @@ function AdminDashboard({
+
+ + + +
+
@@ -503,6 +602,104 @@ function SmallStat({ ); } +function TrendCard({ + title, + data, + valueKey, +}: { + title: string; + data: DailyPoint[]; + valueKey: keyof Pick; +}) { + const points = data.filter((item) => item.date); + const latest = points.at(-1)?.[valueKey] ?? 0; + return ( +
+
+
{title}
+
+ {formatNumber(Number(latest))} +
+
+ +
+ ); +} + +function Sparkline({ + data, + valueKey, +}: { + data: DailyPoint[]; + valueKey: keyof Pick; +}) { + const width = 280; + const height = 72; + const padX = 8; + const padY = 10; + if (!data.length) { + return ( +
+ 暂无数据 +
+ ); + } + 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 ( +
+ + + + {coords.map(([x, y], index) => { + const point = data[index]; + return ( + + ); + })} + +
+ {formatShortDate(data[0]?.date)} + {formatShortDate(data.at(-1)?.date)} +
+
+ ); +} + async function adminFetch( 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; +}