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':