improve admin usage analytics
This commit is contained in:
+254
-23
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import csv
|
import csv
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import sys
|
import sys
|
||||||
import hashlib
|
import hashlib
|
||||||
import shlex
|
import shlex
|
||||||
@@ -26,7 +27,7 @@ import venv
|
|||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock, RLock
|
from threading import Lock, RLock
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
from urllib.parse import quote, unquote, urlparse
|
from urllib.parse import quote, unquote, urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -1510,14 +1511,14 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
raise HTTPException(status_code=401, detail='管理员账号或密码错误')
|
raise HTTPException(status_code=401, detail='管理员账号或密码错误')
|
||||||
|
|
||||||
@app.get('/api/admin/summary')
|
@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)
|
_require_admin_token(token)
|
||||||
return _build_admin_summary(state)
|
return _build_admin_summary(state, period=period)
|
||||||
|
|
||||||
@app.get('/api/admin/accounts')
|
@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)
|
_require_admin_token(token)
|
||||||
return _list_admin_accounts(state)
|
return _list_admin_accounts(state, period=period)
|
||||||
|
|
||||||
@app.post('/api/admin/accounts')
|
@app.post('/api/admin/accounts')
|
||||||
async def admin_create_account(
|
async def admin_create_account(
|
||||||
@@ -4774,22 +4775,76 @@ def _hash_admin_created_password(password: str = '123456') -> str:
|
|||||||
return f'{salt}:{digest}'
|
return f'{salt}:{digest}'
|
||||||
|
|
||||||
|
|
||||||
def _build_admin_summary(state: AgentState) -> dict[str, Any]:
|
ADMIN_PERIOD_LABELS = {
|
||||||
accounts = _list_admin_accounts(state)
|
'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 = {
|
totals = {
|
||||||
'accounts': len(accounts),
|
'accounts': len(accounts),
|
||||||
'sessions': sum(item['session_count'] for item in 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),
|
'tool_calls': sum(item['tool_calls'] for item in accounts),
|
||||||
'tokens': sum(item['total_tokens'] for item in accounts),
|
'tokens': sum(item['total_tokens'] for item in accounts),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
'period': _admin_period_payload(period),
|
||||||
'totals': totals,
|
'totals': totals,
|
||||||
|
'daily_series': _merge_admin_daily_series(
|
||||||
|
item.get('daily_series', []) for item in accounts
|
||||||
|
),
|
||||||
'memory_queue': state.memory_manager.queue_snapshot(),
|
'memory_queue': state.memory_manager.queue_snapshot(),
|
||||||
'accounts': accounts[:20],
|
'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)
|
users_payload = _load_users_file(state)
|
||||||
known_users = {
|
known_users = {
|
||||||
str(item.get('id') or item.get('username') or '').strip()
|
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()
|
if accounts_root.exists()
|
||||||
else set()
|
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]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for account_id in account_ids:
|
for account_id in account_ids:
|
||||||
base = accounts_root / account_id
|
bases = _admin_account_bases(state, account_id)
|
||||||
sessions_dir = base / 'sessions'
|
session_files = _iter_admin_session_files(bases)
|
||||||
session_files = _iter_session_files(sessions_dir)
|
|
||||||
session_count = 0
|
session_count = 0
|
||||||
|
visible_session_count = 0
|
||||||
|
child_session_count = 0
|
||||||
tool_calls = 0
|
tool_calls = 0
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
input_tokens = 0
|
input_tokens = 0
|
||||||
@@ -4820,31 +4881,67 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
|
|||||||
reasoning_tokens = 0
|
reasoning_tokens = 0
|
||||||
latest_mtime = 0.0
|
latest_mtime = 0.0
|
||||||
models: dict[str, int] = {}
|
models: dict[str, int] = {}
|
||||||
|
daily_buckets: dict[str, dict[str, Any]] = {}
|
||||||
for path in session_files:
|
for path in session_files:
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding='utf-8'))
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
continue
|
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
|
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 {}
|
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
|
||||||
input_tokens += int(usage.get('input_tokens') or 0)
|
row_input_tokens = int(usage.get('input_tokens') or 0)
|
||||||
output_tokens += int(usage.get('output_tokens') or 0)
|
row_output_tokens = int(usage.get('output_tokens') or 0)
|
||||||
reasoning_tokens += int(usage.get('reasoning_tokens') or 0)
|
row_reasoning_tokens = int(usage.get('reasoning_tokens') or 0)
|
||||||
total_tokens += int(
|
row_total_tokens = int(
|
||||||
usage.get('total_tokens')
|
usage.get('total_tokens')
|
||||||
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
|
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_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')
|
model = str(model_config.get('model') or data.get('model') or 'unknown')
|
||||||
models[model] = models.get(model, 0) + 1
|
models[model] = models.get(model, 0) + 1
|
||||||
latest_mtime = max(latest_mtime, _session_mtime(path))
|
latest_mtime = max(latest_mtime, mtime)
|
||||||
memory_dir = base / 'memory'
|
_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(
|
result.append(
|
||||||
{
|
{
|
||||||
'account_id': account_id,
|
'account_id': account_id,
|
||||||
'registered': account_id in known_users,
|
'registered': account_id in known_users,
|
||||||
|
'period': period_key,
|
||||||
'session_count': session_count,
|
'session_count': session_count,
|
||||||
|
'visible_session_count': visible_session_count,
|
||||||
|
'child_session_count': child_session_count,
|
||||||
'tool_calls': tool_calls,
|
'tool_calls': tool_calls,
|
||||||
'input_tokens': input_tokens,
|
'input_tokens': input_tokens,
|
||||||
'output_tokens': output_tokens,
|
'output_tokens': output_tokens,
|
||||||
@@ -4858,15 +4955,149 @@ def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
|
|||||||
),
|
),
|
||||||
'latest_session_at': latest_mtime,
|
'latest_session_at': latest_mtime,
|
||||||
'models': models,
|
'models': models,
|
||||||
'user_memory_lines': _count_optional_lines(memory_dir / USER_MEMORY_FILENAME),
|
'daily_series': _admin_daily_series(daily_buckets),
|
||||||
'skill_memory_count': len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
|
'user_memory_lines': max(
|
||||||
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
|
(
|
||||||
else 0,
|
_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)
|
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]:
|
def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||||
safe_id = _safe_account_id(account_id)
|
safe_id = _safe_account_id(account_id)
|
||||||
if safe_id == 'default':
|
if safe_id == 'default':
|
||||||
|
|||||||
+216
-13
@@ -13,20 +13,53 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type AdminSummary = {
|
type AdminSummary = {
|
||||||
|
period?: PeriodInfo;
|
||||||
totals?: {
|
totals?: {
|
||||||
accounts: number;
|
accounts: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
|
visible_sessions?: number;
|
||||||
|
child_sessions?: number;
|
||||||
tool_calls: number;
|
tool_calls: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
};
|
};
|
||||||
|
daily_series?: DailyPoint[];
|
||||||
memory_queue?: MemoryQueue;
|
memory_queue?: MemoryQueue;
|
||||||
accounts?: AccountRow[];
|
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 = {
|
type AccountRow = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
registered: boolean;
|
registered: boolean;
|
||||||
|
period?: PeriodKey;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
|
visible_session_count?: number;
|
||||||
|
child_session_count?: number;
|
||||||
tool_calls: number;
|
tool_calls: number;
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
@@ -36,6 +69,7 @@ type AccountRow = {
|
|||||||
average_tool_calls_per_session: number;
|
average_tool_calls_per_session: number;
|
||||||
latest_session_at: number;
|
latest_session_at: number;
|
||||||
models: Record<string, number>;
|
models: Record<string, number>;
|
||||||
|
daily_series?: DailyPoint[];
|
||||||
user_memory_lines: number;
|
user_memory_lines: number;
|
||||||
skill_memory_count: number;
|
skill_memory_count: number;
|
||||||
};
|
};
|
||||||
@@ -71,6 +105,13 @@ type MemoryEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TOKEN_KEY = "zk-admin-token";
|
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() {
|
export default function AdminPage() {
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
@@ -159,6 +200,7 @@ function AdminDashboard({
|
|||||||
const [events, setEvents] = useState<MemoryEvent[]>([]);
|
const [events, setEvents] = useState<MemoryEvent[]>([]);
|
||||||
const [newAccountId, setNewAccountId] = useState("");
|
const [newAccountId, setNewAccountId] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
|
const [period, setPeriod] = useState<PeriodKey>("this_month");
|
||||||
|
|
||||||
const selectedAccount = useMemo(
|
const selectedAccount = useMemo(
|
||||||
() => accounts.find((item) => item.account_id === selectedAccountId),
|
() => accounts.find((item) => item.account_id === selectedAccountId),
|
||||||
@@ -168,7 +210,7 @@ function AdminDashboard({
|
|||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 首次进入后台时加载一次即可,后续由刷新按钮触发。
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 首次进入后台时加载一次即可,后续由刷新按钮触发。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, [period]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 账号切换时加载对应记忆;函数体依赖当前 token。
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 账号切换时加载对应记忆;函数体依赖当前 token。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,9 +219,10 @@ function AdminDashboard({
|
|||||||
}, [selectedAccountId]);
|
}, [selectedAccountId]);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
const query = `period=${encodeURIComponent(period)}`;
|
||||||
const [summaryPayload, accountPayload] = await Promise.all([
|
const [summaryPayload, accountPayload] = await Promise.all([
|
||||||
adminFetch<AdminSummary>(token, "/summary"),
|
adminFetch<AdminSummary>(token, `/summary?${query}`),
|
||||||
adminFetch<AccountRow[]>(token, "/accounts"),
|
adminFetch<AccountRow[]>(token, `/accounts?${query}`),
|
||||||
]);
|
]);
|
||||||
setSummary(summaryPayload);
|
setSummary(summaryPayload);
|
||||||
setAccounts(accountPayload);
|
setAccounts(accountPayload);
|
||||||
@@ -258,10 +301,48 @@ function AdminDashboard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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?.accounts ?? 0} />
|
||||||
<Metric title="会话" value={summary?.totals?.sessions ?? 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} />
|
<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
|
<Metric
|
||||||
title="Token"
|
title="Token"
|
||||||
value={formatNumber(summary?.totals?.tokens ?? 0)}
|
value={formatNumber(summary?.totals?.tokens ?? 0)}
|
||||||
@@ -305,7 +386,7 @@ function AdminDashboard({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-muted-foreground text-xs">
|
<span className="mt-1 text-muted-foreground text-xs">
|
||||||
{account.tool_calls} 工具 ·{" "}
|
{account.session_count} 会话 · {account.tool_calls} 工具 ·{" "}
|
||||||
{formatNumber(account.total_tokens)} tokens
|
{formatNumber(account.total_tokens)} tokens
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -316,6 +397,14 @@ function AdminDashboard({
|
|||||||
<section className="grid gap-4">
|
<section className="grid gap-4">
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<Metric title="账号" value={selectedAccount?.account_id ?? "-"} />
|
<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
|
<Metric
|
||||||
title="用户记忆行"
|
title="用户记忆行"
|
||||||
value={selectedAccount?.user_memory_lines ?? 0}
|
value={selectedAccount?.user_memory_lines ?? 0}
|
||||||
@@ -324,14 +413,6 @@ function AdminDashboard({
|
|||||||
title="Skill 记忆"
|
title="Skill 记忆"
|
||||||
value={selectedAccount?.skill_memory_count ?? 0}
|
value={selectedAccount?.skill_memory_count ?? 0}
|
||||||
/>
|
/>
|
||||||
<Metric
|
|
||||||
title="队列 pending"
|
|
||||||
value={
|
|
||||||
summary?.memory_queue?.accounts.find(
|
|
||||||
(item) => item.account_id === selectedAccountId,
|
|
||||||
)?.pending ?? 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
@@ -398,6 +479,24 @@ function AdminDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</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 xl:grid-cols-[360px_1fr]">
|
||||||
<div className="grid gap-4 content-start">
|
<div className="grid gap-4 content-start">
|
||||||
<div className="rounded-lg border bg-card">
|
<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>(
|
async function adminFetch<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -540,3 +737,9 @@ function formatTime(value?: number) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(value * 1000));
|
}).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