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':
|
||||
|
||||
Reference in New Issue
Block a user