feat(admin): 后台目录导航 + 按模型/各用户用量时间筛选排序 + 存储分页 + 导出 PDF + bump 0.11.0

- 左侧目录(sticky,点击平滑滚动 + scrollspy 高亮,窄屏转横向 chip);各区 scroll-margin-top 避开顶栏
- 按模型 / 各用户用量拆为独立端点,带 range(all/7d/30d)+ sort(cost/tokens);
  各用户用量含零用量用户(时间条件放 JOIN ON,避免被 cutoff 挤掉)
- 存储分页(/v1/admin/storage/users);各用户用量分页;overview 瘦身为固定指标(runtime/tasks/users/总用量+近7d),独立表自管 range/sort/page
- 导出 PDF:客户端 window.print()(零依赖),填充隐藏报告 DOM + @media print 版式;列表取前 10
- 文档同步 DESIGN §7.3 / PROGRESS / RUN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-12 10:44:47 +08:00
parent 81da2f6f55
commit f12df1bd82
7 changed files with 416 additions and 158 deletions

View File

@ -306,7 +306,7 @@ done {}
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户 - `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403) - `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
- `GET /v1/me` — 返 `{user_id, role}`(role 走 DB 查),dev SPA 据此决定显不显"管理"入口 - `GET /v1/me` — 返 `{user_id, role}`(role 走 DB 查),dev SPA 据此决定显不显"管理"入口
- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返监控总览(runtime/tasks/users/usage 总用量/storage);`/v1/admin/usage/users?page=&page_size=` 分页返各用户 token 用量。独立页 `/static/admin.html`。后续续挂建用户/改角色/配置等管理动作 - `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-12(admin 管理后台:users.role + require_admin + /v1/admin/overview + 独立 admin.html 监控页) 最后更新:2026-06-12(admin 管理后台 + 目录/筛选排序/分页/导出 PDF:users.role + require_admin + /v1/admin/* + 独立 admin.html)
--- ---
@ -28,7 +28,15 @@
- 诊断脚本落盘可复用:`scripts/diag_run_python_empty.py`(扫最近 task 的报错形态分桶)、`scripts/diag_run_python_trace.py`(回溯每条报错配对的 assistant 参数)。 - 诊断脚本落盘可复用:`scripts/diag_run_python_empty.py`(扫最近 task 的报错形态分桶)、`scripts/diag_run_python_trace.py`(回溯每条报错配对的 assistant 参数)。
- 验证:`tests/test_context_compaction.py` 改 2 条旧"压参数"断言为"原样保留"+ 去除已删统计键;全量 120 tests OK。bump 0.10.0 → 0.10.1。 - 验证:`tests/test_context_compaction.py` 改 2 条旧"压参数"断言为"原样保留"+ 去除已删统计键;全量 120 tests OK。bump 0.10.0 → 0.10.1。
### 2026-06-12 ### 2026-06-12(下午)admin 后台增强:目录 + 筛选排序 + 分页 + 导出 PDF
- **目录(TOC)+ 平滑滚动**:admin.html 左侧加 sticky 目录(运行态/任务/用户与用量/按模型/各用户用量/存储),点击 `scrollIntoView` 平滑滚到对应区(`.anchor { scroll-margin-top }` 避开 sticky 顶栏);IntersectionObserver 高亮当前区;窄屏目录变顶部横向 chip 条。
- **按模型 / 各用户用量:时间筛选 + 排序**:两表从 overview bundle 拆成独立端点 `GET /v1/admin/usage/models?range=&sort=`、`GET /v1/admin/usage/users?range=&sort=&page=&page_size=`。range = all/7d/30d(`_range_cutoff`);sort = cost(按成本)/ tokens(按用量=输入+输出)。**各用户用量含零用量用户**故时间条件放 JOIN ON(非 WHERE),否则带 cutoff 会把零用量用户挤掉。前端每表一组 range/sort 下拉,改筛选即重拉(用户表回第 0 页);热力色按当前排序维度上色。
- **存储分页**:`GET /v1/admin/storage/users?page=&page_size=`(bytes desc + user_id 兜底),前端独立翻页;overview 不再含 storage/by_model(只留 runtime/tasks/users/usage 总用量+近7d趋势,固定形态供轮询)。三个独立表各自 fetch、自管 range/sort/page,overview tick 顺手刷新但不丢状态。
- **导出 PDF(客户端打印)**:顶栏「导出 PDF」→ 现取 overview + models(all/cost)+ users(all/cost top10)+ storage(top10)+ /healthz 版本,填充隐藏的 `#print-report``window.print()`;`@media print` 只显报告、`@page` 边距、表格描边版式。**零依赖**(不引 jsPDF / 不走服务端 soffice)、中文走浏览器字体、版式完全可控;**列表只取前 10**(符合需求)。报告版式:抬头(标题/生成时间/版本)→ 运行态 → 任务 → 用户 → 用量总览 → 近7天 → 按模型 Top10 → 各用户用量 Top10 → 存储 Top10。
- 验证:TestClient 跑通 models(range all=6/7d=4/30d=6、sort cost/tokens)、users(range+sort+分页)、storage(分页 42 行);overview 已不含 by_model/storage;admin.js `node --check` 通过。bump 0.10.1 → 0.11.0。
### 2026-06-12(上午)
- **admin 管理后台(角色鉴权 + 独立监控页,可扩展为管理动作总入口)**:此前只有共享口令 `ZCBOT_ADMIN_TOKEN`(仅用于发用户),无"管理员角色"概念,运维指标只打 stdout(`[stats]`)无界面。本次落地按角色的 admin 区:① **schema**:`users` 加 `role` 列(`user`/`admin`,`server_default='user'`,migration 0009 只加列不动现有数据);② **鉴权**:`make_require_admin(cfg)` 先验 JWT(同 `require_user`)再查 `users.role=='admin'`,否则 403——**role 走 DB 查不进 JWT**,改完下次请求即时生效、老 token 不重签;③ **端点**:`web/admin.py` 的 `register_admin_routes``GET /v1/admin/overview`(整组 `Depends(require_admin)`),一次返回 runtime(active_runs/max_workers/sse_subs/rss_peak,读 app.state,与 `_stats_logger` 同源)/ tasks(按 status+run_status 计数)/ users(总数+近7d活跃)/ usage(全局总用量+近7d按天+按模型)/ storage(各用户 bytes/file_count+配额)五段,全 GROUP BY 无 N+1;另挂 `GET /v1/admin/usage/users?page=&page_size=` 分页返**各用户 token 用量**(全表 LEFT JOIN usage_events 含零用量用户,cost desc,稳定排序兜底 user_id;cost 全 kind、token/缓存命中仅 chat,与总用量同源)——前端独立翻页、不随 overview 轮询丢页码;④ **前端**:独立单页 `web/static/admin.html`+`js/admin.js`(复用 localStorage `zcbot.token` 与 format 工具,不挂主应用模块图),纯数字卡片+表格不画图、**阈值/热力色差**(active_runs 逼近 max_workers 变橙/红、磁盘按配额占比变色、cost 列相对热力底色)、**响应式**(窄屏竖排)、默 10s 轮询(切后台暂停);401/403 给明确提示+回控制台链接;⑤ **入口**:`/v1/me` 返 `{user_id, role}`,dev SPA `enterApp` 拉一次,admin 才显顶栏"管理"链接(`/static/admin.html`);⑥ **建用户带 role**:`POST /v1/auth/admin/create_user` + 登录页弹框加角色下拉,`main.py user add --role` / 新增 `main.py user role --email X --role admin` 改角色。**命名取舍**:先按 inspect/dashboard 摇摆,最终定 **admin**——这页会长出建用户/改角色/配置(磁盘配额等)管理动作,admin 既盖"看"又盖"管"、且与 `require_admin`/`role='admin'`/`/v1/auth/admin/*` 一脉相承;监控总览只是其第一个 tab,后续在 `web/admin.py` 续挂 `/v1/admin/users`、`/v1/admin/config`。已用 TestClient 验:admin→200、非 admin→403、无 token→401;五段聚合对真实数据跑通。 - **admin 管理后台(角色鉴权 + 独立监控页,可扩展为管理动作总入口)**:此前只有共享口令 `ZCBOT_ADMIN_TOKEN`(仅用于发用户),无"管理员角色"概念,运维指标只打 stdout(`[stats]`)无界面。本次落地按角色的 admin 区:① **schema**:`users` 加 `role` 列(`user`/`admin`,`server_default='user'`,migration 0009 只加列不动现有数据);② **鉴权**:`make_require_admin(cfg)` 先验 JWT(同 `require_user`)再查 `users.role=='admin'`,否则 403——**role 走 DB 查不进 JWT**,改完下次请求即时生效、老 token 不重签;③ **端点**:`web/admin.py` 的 `register_admin_routes``GET /v1/admin/overview`(整组 `Depends(require_admin)`),一次返回 runtime(active_runs/max_workers/sse_subs/rss_peak,读 app.state,与 `_stats_logger` 同源)/ tasks(按 status+run_status 计数)/ users(总数+近7d活跃)/ usage(全局总用量+近7d按天+按模型)/ storage(各用户 bytes/file_count+配额)五段,全 GROUP BY 无 N+1;另挂 `GET /v1/admin/usage/users?page=&page_size=` 分页返**各用户 token 用量**(全表 LEFT JOIN usage_events 含零用量用户,cost desc,稳定排序兜底 user_id;cost 全 kind、token/缓存命中仅 chat,与总用量同源)——前端独立翻页、不随 overview 轮询丢页码;④ **前端**:独立单页 `web/static/admin.html`+`js/admin.js`(复用 localStorage `zcbot.token` 与 format 工具,不挂主应用模块图),纯数字卡片+表格不画图、**阈值/热力色差**(active_runs 逼近 max_workers 变橙/红、磁盘按配额占比变色、cost 列相对热力底色)、**响应式**(窄屏竖排)、默 10s 轮询(切后台暂停);401/403 给明确提示+回控制台链接;⑤ **入口**:`/v1/me` 返 `{user_id, role}`,dev SPA `enterApp` 拉一次,admin 才显顶栏"管理"链接(`/static/admin.html`);⑥ **建用户带 role**:`POST /v1/auth/admin/create_user` + 登录页弹框加角色下拉,`main.py user add --role` / 新增 `main.py user role --email X --role admin` 改角色。**命名取舍**:先按 inspect/dashboard 摇摆,最终定 **admin**——这页会长出建用户/改角色/配置(磁盘配额等)管理动作,admin 既盖"看"又盖"管"、且与 `require_admin`/`role='admin'`/`/v1/auth/admin/*` 一脉相承;监控总览只是其第一个 tab,后续在 `web/admin.py` 续挂 `/v1/admin/users`、`/v1/admin/config`。已用 TestClient 验:admin→200、非 admin→403、无 token→401;五段聚合对真实数据跑通。

2
RUN.md
View File

@ -50,7 +50,7 @@
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(监控总览,走 `GET /v1/admin/overview`,非 admin 403)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。 - **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。
--- ---

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.10.1" __version__ = "0.11.0"

View File

@ -17,7 +17,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from sqlalchemy import BigInteger, Numeric, cast, func, select from sqlalchemy import BigInteger, and_, cast, func, select
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import Task, UsageEvent, User, UserDiskUsage from core.storage.models import Task, UsageEvent, User, UserDiskUsage
@ -37,6 +37,15 @@ def _rss_peak_mb():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def _range_cutoff(now: datetime, range_key: str):
"""时间筛选 → cutoff datetime(或 None=全部)。range_key: all / 7d / 30d。"""
if range_key == "7d":
return now - timedelta(days=7)
if range_key == "30d":
return now - timedelta(days=30)
return None # all / 未知 → 不筛
def _runtime_section(app: FastAPI) -> dict: def _runtime_section(app: FastAPI) -> dict:
"""实时运行态:从 app.state 读内存,无 DB。 """实时运行态:从 app.state 读内存,无 DB。
@ -83,10 +92,10 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict:
def _usage_section(s: Any, cutoff_7d: datetime) -> dict: def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
"""token / 成本聚合:全局合计 + 近 7d 按天 + 按模型 + top 用户 """token / 成本聚合(放进 overview,固定形态):全局合计(all-time)+ 近 7d 按天趋势
chat token 取自 usage_events.units JSONB(tokens_in/out/cache_hit_tokens);cost_cny 按模型 / 各用户用量已拆成独立带筛选排序的端点(_models_usage / _user_usage_page),
kind 合计 _usage_aggregates 同源,缓存命中率分母一致( 100%) 不在此 bundlechat token 取自 usage_events.units JSONB;cost_cny kind 合计
""" """
chat = UsageEvent.kind == "chat" chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger) tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
@ -133,8 +142,30 @@ def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
).all() ).all()
] ]
# 按模型(all-time;cost desc) return {"total": total, "by_day_7d": by_day}
by_model = [
def _models_usage(s: Any, cutoff, sort: str) -> list:
"""按模型用量(支持时间筛选 + 排序)。sort: cost(按成本)/ tokens(按用量=输入+输出)。
cutoff=None 即全部;cost kind 合计,token chat模型集合从 usage_events 现取
("全模型"基线),故时间条件直接进 WHERE
"""
chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
tin_sum = func.coalesce(func.sum(tin).filter(chat), 0)
tout_sum = func.coalesce(func.sum(tout).filter(chat), 0)
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
q = select(
UsageEvent.model_profile, cost_sum, tin_sum, tout_sum, func.count(),
)
if cutoff is not None:
q = q.where(UsageEvent.created_at >= cutoff)
q = q.group_by(UsageEvent.model_profile).order_by(order, UsageEvent.model_profile)
return [
{ {
"model_profile": mp, "model_profile": mp,
"cost_cny": float(c or 0), "cost_cny": float(c or 0),
@ -142,39 +173,30 @@ def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
"tokens_out": int(to or 0), "tokens_out": int(to or 0),
"n_events": int(n or 0), "n_events": int(n or 0),
} }
for mp, c, ti, to, n in s.execute( for mp, c, ti, to, n in s.execute(q).all()
select(
UsageEvent.model_profile,
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
func.coalesce(func.sum(tin).filter(chat), 0),
func.coalesce(func.sum(tout).filter(chat), 0),
func.count(),
)
.group_by(UsageEvent.model_profile)
.order_by(func.coalesce(func.sum(UsageEvent.cost_cny), 0).desc())
).all()
] ]
return {
"total": total,
"by_day_7d": by_day,
"by_model": by_model,
}
def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> dict:
"""分页的各用户 token 用量(时间筛选 + 排序),含零用量用户(LEFT JOIN users)。
def _user_usage_page(s: Any, page: int, page_size: int) -> dict: `各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0);
"""分页的各用户 token 用量(cost desc),含零用量用户(LEFT JOIN users)。 时间筛选放 JOIN ON( WHERE),否则带 cutoff 时会把零用量用户挤掉
sort: cost(按成本)/ tokens(按用量=输入+输出);+ user_id 兜底稳定分页
`各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0) cost kind 合计;token/cache_hit chat返回 {page, page_size, total_users, rows}
cost kind 合计;token/cache_hit chat( _usage_aggregates / total 同源)
排序 cost desc + user_id 兜底(稳定分页,避免大量 0 值并列时跨页错位)
返回 {page, page_size, total_users, rows:[...]}
""" """
chat = UsageEvent.kind == "chat" chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger) tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger) tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
hit = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger) hit = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger)
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0) cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
tin_sum = func.coalesce(func.sum(tin).filter(chat), 0)
tout_sum = func.coalesce(func.sum(tout).filter(chat), 0)
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
join_cond = UsageEvent.user_id == User.user_id
if cutoff is not None:
join_cond = and_(join_cond, UsageEvent.created_at >= cutoff)
total_users = s.execute(select(func.count()).select_from(User)).scalar_one() total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
rows = [ rows = [
@ -190,18 +212,14 @@ def _user_usage_page(s: Any, page: int, page_size: int) -> dict:
} }
for uid, email, role, c, ti, to, h, n in s.execute( for uid, email, role, c, ti, to, h, n in s.execute(
select( select(
User.user_id, User.user_id, User.email, User.role,
User.email, cost_sum, tin_sum, tout_sum,
User.role,
cost_sum,
func.coalesce(func.sum(tin).filter(chat), 0),
func.coalesce(func.sum(tout).filter(chat), 0),
func.coalesce(func.sum(hit).filter(chat), 0), func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id), func.count(UsageEvent.event_id),
) )
.join(UsageEvent, UsageEvent.user_id == User.user_id, isouter=True) .join(UsageEvent, join_cond, isouter=True)
.group_by(User.user_id, User.email, User.role) .group_by(User.user_id, User.email, User.role)
.order_by(cost_sum.desc(), User.user_id) .order_by(order, User.user_id)
.limit(page_size) .limit(page_size)
.offset(page * page_size) .offset(page * page_size)
).all() ).all()
@ -209,12 +227,16 @@ def _user_usage_page(s: Any, page: int, page_size: int) -> dict:
return {"page": page, "page_size": page_size, "total_users": total_users, "rows": rows} return {"page": page, "page_size": page_size, "total_users": total_users, "rows": rows}
def _storage_section(s: Any) -> dict: def _storage_page(s: Any, page: int, page_size: int) -> dict:
"""各用户磁盘用量(user_disk_usage join email),bytes desc;附 per-user 配额。""" """分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。
数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数
"""
from core.agent_builder import load_config from core.agent_builder import load_config
from core.storage.disk_quota import parse_bytes from core.storage.disk_quota import parse_bytes
quota = parse_bytes((load_config().get("quotas") or {}).get("disk_bytes_per_user")) quota = parse_bytes((load_config().get("quotas") or {}).get("disk_bytes_per_user"))
total = s.execute(select(func.count()).select_from(UserDiskUsage)).scalar_one()
rows = [ rows = [
{ {
"user_id": str(uid), "user_id": str(uid),
@ -232,10 +254,15 @@ def _storage_section(s: Any) -> dict:
UserDiskUsage.scanned_at, UserDiskUsage.scanned_at,
) )
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True) .join(User, User.user_id == UserDiskUsage.user_id, isouter=True)
.order_by(UserDiskUsage.bytes_used.desc()) .order_by(UserDiskUsage.bytes_used.desc(), UserDiskUsage.user_id)
.limit(page_size)
.offset(page * page_size)
).all() ).all()
] ]
return {"quota_bytes": quota, "users": rows} return {
"page": page, "page_size": page_size, "total": total,
"quota_bytes": quota, "rows": rows,
}
def register_admin_routes(app: FastAPI, require_admin) -> None: def register_admin_routes(app: FastAPI, require_admin) -> None:
@ -243,7 +270,10 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
@app.get("/v1/admin/overview", tags=["admin"]) @app.get("/v1/admin/overview", tags=["admin"])
def admin_overview(user_id: UUID = Depends(require_admin)): def admin_overview(user_id: UUID = Depends(require_admin)):
"""管理总览:一次返回全部 section,供 /static/admin.html 轮询。admin-only。""" """管理总览(固定形态,供轮询):runtime/tasks/users/usage 总用量+近7d趋势。admin-only。
按模型 / 各用户用量 / 存储 是带筛选/分页的独立端点,不在此 bundle
"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7) cutoff_7d = now - timedelta(days=7)
with session_scope() as s: with session_scope() as s:
@ -253,19 +283,45 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
"tasks": _tasks_section(s), "tasks": _tasks_section(s),
"users": _users_section(s, cutoff_7d), "users": _users_section(s, cutoff_7d),
"usage": _usage_section(s, cutoff_7d), "usage": _usage_section(s, cutoff_7d),
"storage": _storage_section(s), }
@app.get("/v1/admin/usage/models", tags=["admin"])
def admin_usage_models(
range: str = "all", sort: str = "cost", user_id: UUID = Depends(require_admin)
):
"""按模型用量。range: all/7d/30d;sort: cost/tokens。admin-only。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
return {
"range": range, "sort": sort,
"rows": _models_usage(s, _range_cutoff(now, range), sort),
} }
@app.get("/v1/admin/usage/users", tags=["admin"]) @app.get("/v1/admin/usage/users", tags=["admin"])
def admin_usage_users( def admin_usage_users(
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin) page: int = 0, page_size: int = 20, range: str = "all", sort: str = "cost",
user_id: UUID = Depends(require_admin),
): ):
"""各用户 token 用量(分页,cost desc)。admin-only。 """各用户 token 用量(分页 + 时间筛选 + 排序)。admin-only。
page 0-based;page_size 夹到 [1,100]含零用量用户(全表 LEFT JOIN) page 0-based;page_size 夹到 [1,100];range all/7d/30d;sort cost/tokens
前端独立于 overview 轮询管理本表分页;总用量在 overview.usage.total 含零用量用户(全表 LEFT JOIN);总用量在 overview.usage.total
""" """
page = max(0, page) page = max(0, page)
page_size = min(100, max(1, page_size)) page_size = min(100, max(1, page_size))
now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
return _user_usage_page(s, page, page_size) d = _user_usage_page(s, page, page_size, _range_cutoff(now, range), sort)
d["range"] = range
d["sort"] = sort
return d
@app.get("/v1/admin/storage/users", tags=["admin"])
def admin_storage_users(
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin)
):
"""各用户磁盘用量(分页,bytes desc)。admin-only。page 0-based;page_size [1,100]。"""
page = max(0, page)
page_size = min(100, max(1, page_size))
with session_scope() as s:
return _storage_page(s, page, page_size)

View File

@ -43,6 +43,26 @@
.msg { padding: 40px 16px; text-align: center; color: var(--muted); } .msg { padding: 40px 16px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); } .msg a { color: var(--accent); }
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */
#layout { display: grid; grid-template-columns: 132px 1fr; gap: 18px; align-items: start; }
#toc { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; }
#toc a {
color: var(--muted); text-decoration: none; font-size: 13px; padding: 6px 10px;
border-radius: var(--r-md); border-left: 2px solid transparent;
}
#toc a:hover { background: var(--accent-soft); color: var(--accent); }
#toc a.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.anchor { scroll-margin-top: 64px; } /* 滚动定位避开 sticky 顶栏 */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; }
.ctrl select {
font-size: 12px; padding: 3px 6px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text);
}
.sublabel { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.card { .card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
@ -94,6 +114,32 @@
main { padding: 10px; } main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.stat .v { font-size: 18px; } .stat .v { font-size: 18px; }
/* 窄屏:目录变顶部横向 chip 条 */
#layout { grid-template-columns: 1fr; gap: 10px; }
#toc {
flex-direction: row; overflow-x: auto; gap: 6px; top: 52px;
background: var(--bg); padding: 6px 0; z-index: 5;
}
#toc a { white-space: nowrap; border-left: none; border: 1px solid var(--border); }
#toc a.active { border-color: var(--accent); }
.anchor { scroll-margin-top: 96px; }
}
/* 屏幕上隐藏打印报告;打印时只显它 */
#print-report { display: none; }
@media print {
header, #layout, .msg { display: none !important; }
body { background: #fff; }
#print-report { display: block; color: #000; }
#print-report h1 { font-size: 18px; margin: 0 0 4px; }
#print-report h2 { font-size: 13px; margin: 14px 0 4px; border-bottom: 1px solid #999; padding-bottom: 2px; }
#print-report .rpt-meta { font-size: 11px; color: #555; margin-bottom: 8px; }
#print-report .rpt-kv { font-size: 12px; margin: 2px 0 4px; }
table.rpt { width: 100%; border-collapse: collapse; font-size: 11px; margin: 4px 0 6px; }
table.rpt th, table.rpt td { border: 1px solid #bbb; padding: 3px 6px; text-align: right; }
table.rpt th:first-child, table.rpt td:first-child { text-align: left; }
table.rpt th { background: #f0f0f0; }
@page { margin: 14mm; }
} }
</style> </style>
</head> </head>
@ -105,11 +151,14 @@
<div class="spacer"></div> <div class="spacer"></div>
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label> <label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新</button> <button id="refresh">刷新</button>
<button id="export">导出 PDF</button>
<a href="/static/dev.html">← 返回控制台</a> <a href="/static/dev.html">← 返回控制台</a>
</header> </header>
<main id="main"> <main id="main">
<div class="msg" id="boot">加载中…</div> <div class="msg" id="boot">加载中…</div>
</main> </main>
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
<div id="print-report"></div>
<script type="module" src="/static/js/admin.js"></script> <script type="module" src="/static/js/admin.js"></script>
</body> </body>
</html> </html>

View File

@ -1,19 +1,29 @@
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 // zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图, // 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。 // 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。 // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js"; import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000; const REFRESH_MS = 10000;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
];
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || ""; const token = () => localStorage.getItem(LS_TOKEN) || "";
let timer = null; let timer = null;
let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询 // 各表独立状态(不随 overview 轮询重置)
let modelRange = "all", modelSort = "cost";
let userRange = "all", userSort = "cost", userPage = 0;
let storagePage = 0;
// ───── 格式化 ───── // ───── 格式化 ─────
function fmtCNY(n) { function fmtCNY(n) {
@ -27,12 +37,22 @@ function tint(value, max) {
const a = Math.min(1, value / max) * 0.30; const a = Math.min(1, value / max) * 0.30;
return `background: rgba(192,57,43,${a.toFixed(3)});`; return `background: rgba(192,57,43,${a.toFixed(3)});`;
} }
// 阈值类:ratio>=1 危险,>=0.8 警告。
function levelClass(ratio) { function levelClass(ratio) {
if (ratio >= 1) return "danger"; if (ratio >= 1) return "danger";
if (ratio >= 0.8) return "warn"; if (ratio >= 0.8) return "warn";
return ""; return "";
} }
// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。
function ctrlHTML(prefix, range, sort) {
const opt = (cur, list) => list.map(
([v, l]) => `<option value="${v}" ${v === cur ? "selected" : ""}>${l}</option>`
).join("");
return `<div class="ctrl">`
+ `<select id="${prefix}-range">${opt(range, RANGE_OPTS)}</select>`
+ `<select id="${prefix}-sort">${opt(sort, SORT_OPTS)}</select>`
+ `</div>`;
}
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ───── // ───── 渲染各 section ─────
function statCard(k, v, sub, cls) { function statCard(k, v, sub, cls) {
@ -45,11 +65,10 @@ function renderRuntime(r) {
const active = r.active_runs || 0; const active = r.active_runs || 0;
const max = r.max_workers || 0; const max = r.max_workers || 0;
const ratio = max ? active / max : 0; const ratio = max ? active / max : 0;
const cls = levelClass(ratio);
const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : ""; const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : "";
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—"; const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
return `<div class="card"><h2>实时运行态</h2><div class="grid">` return `<div class="card"><h2>实时运行态</h2><div class="grid">`
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls) + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio))
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接") + statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
+ statCard("内存峰值", rss, "进程 RSS high-water") + statCard("内存峰值", rss, "进程 RSS high-water")
+ `</div></div>`; + `</div></div>`;
@ -67,8 +86,8 @@ function renderTasks(t) {
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`; return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
}).join("") || `<span class="empty">无</span>`; }).join("") || `<span class="empty">无</span>`;
return `<div class="card"><h2>任务(共 ${t.total || 0}</h2>` return `<div class="card"><h2>任务(共 ${t.total || 0}</h2>`
+ `<div style="margin-bottom:10px;"><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">status</div><div class="chips">${statusChips}</div></div>` + `<div style="margin-bottom:10px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
+ `<div><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">run_status</div><div class="chips">${runChips}</div></div>` + `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
+ `</div>`; + `</div>`;
} }
@ -84,39 +103,46 @@ function renderUsersAndUsage(users, usage) {
} }
function renderByDay(rows) { function renderByDay(rows) {
if (!rows || !rows.length) return `<div class="card"><h2>近 7 天用量</h2><div class="empty">无数据</div></div>`; rows = rows || [];
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => `<tr>` const body = rows.map(r => `<tr>`
+ `<td>${escapeHtml(r.date)}</td>` + `<td>${escapeHtml(r.date)}</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>` + `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_in)}</td>` + `<td class="num">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>` + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `</tr>`).join(""); + `</tr>`).join("") || `<tr><td colspan="4" class="empty">无数据</td></tr>`;
return `<div class="card"><h2>近 7 天用量(按天)</h2><div class="scroll-x"><table>` return `<div class="card"><h2>近 7 天用量(按天)</h2><div class="scroll-x"><table>`
+ `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>` + `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`; + `<tbody>${body}</tbody></table></div></div>`;
} }
function renderByModel(rows) { // 按模型(时间筛选 + 排序)。d = {range, sort, rows}
if (!rows || !rows.length) return `<div class="card"><h2>按模型</h2><div class="empty">无数据</div></div>`; function renderModels(d) {
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); const rows = d.rows || [];
const body = rows.map(r => `<tr>` const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTok = Math.max(0, ...rows.map(r => (r.tokens_in || 0) + (r.tokens_out || 0)));
const byTok = d.sort === "tokens";
const body = rows.map(r => {
const tok = (r.tokens_in || 0) + (r.tokens_out || 0);
return `<tr>`
+ `<td class="email">${escapeHtml(r.model_profile || "—")}</td>` + `<td class="email">${escapeHtml(r.model_profile || "—")}</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>` + `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_in)}</td>` + `<td class="num bar-cell" style="${byTok ? tint(tok, maxTok) : ""}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>` + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${r.n_events || 0}</td>` + `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`).join(""); + `</tr>`;
return `<div class="card"><h2>按模型all-time</h2><div class="scroll-x"><table>` }).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
$("s-models").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>按模型(${rangeLabel(d.range)}</h2>${ctrlHTML("m", d.range, d.sort)}</div>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>` + `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`; + `<tbody>${body}</tbody></table></div></div>`;
$("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); };
$("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); };
} }
// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick // 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows
// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。
function renderUserUsage(d) { function renderUserUsage(d) {
const c = $("user-usage");
if (!c) return;
const rows = d.rows || []; const rows = d.rows || [];
const total = d.total_users || 0; const total = d.total_users || 0;
const size = d.page_size || PAGE_SIZE; const size = d.page_size || PAGE_SIZE;
@ -126,43 +152,47 @@ function renderUserUsage(d) {
const to = Math.min(total, (page + 1) * size); const to = Math.min(total, (page + 1) * size);
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0)); const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0)); const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
const byTok = d.sort === "tokens";
const body = rows.map(r => { const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0; const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return `<tr>` return `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}` + `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>` + (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>` + `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${tint(r.tokens_in, maxTin)}">${fmtTokens(r.tokens_in)}</td>` + `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>` + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>` + `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>` + `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="6" class="empty">无数据</td></tr>`; }).join("") || `<tr><td colspan="6" class="empty">无数据</td></tr>`;
c.innerHTML = `<div class="card"><h2>各用户用量按成本all-time</h2>` $("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>` + `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>` + `<tbody>${body}</tbody></table></div>`
+ `<div class="pager">` + pagerHTML("uu", page, maxPage, from, to, total)
+ `<button id="uu-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>` + `</div>`;
+ `<span class="pginfo">${from}${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>` $("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
+ `<button id="uu-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>` $("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); };
+ `</div></div>`; wirePager("uu", page, maxPage, (p) => loadUserUsage(p));
const prev = $("uu-prev"), next = $("uu-next");
if (prev) prev.onclick = () => loadUserUsage(userPage - 1);
if (next) next.onclick = () => loadUserUsage(userPage + 1);
} }
function renderStorage(st) { // 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
const quota = st.quota_bytes; function renderStorage(d) {
const rows = st.users || []; const quota = d.quota_bytes;
const rows = d.rows || [];
const total = d.total || 0;
const size = d.page_size || PAGE_SIZE;
const page = d.page || 0;
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
const from = total ? page * size + 1 : 0;
const to = Math.min(total, (page + 1) * size);
const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限"; const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
if (!rows.length) return `<div class="card"><h2>存储用量(${quotaLabel}</h2><div class="empty">无数据</div></div>`; const maxUsed = Math.max(0, ...rows.map(r => r.bytes_used || 0));
const maxUsed = Math.max(...rows.map(r => r.bytes_used || 0));
const body = rows.map(r => { const body = rows.map(r => {
const ratio = quota && quota > 0 ? r.bytes_used / quota : 0; const ratio = quota && quota > 0 ? r.bytes_used / quota : 0;
const cls = levelClass(ratio); const cls = levelClass(ratio);
const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—"; const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—";
// 有配额时按配额占比上色(逼近上限变橙/红);无配额时按相对最大值热力上色
const cellStyle = quota && quota > 0 const cellStyle = quota && quota > 0
? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);" ? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);"
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "") : cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
@ -174,91 +204,205 @@ function renderStorage(st) {
+ `<td class="num">${r.file_count || 0}</td>` + `<td class="num">${r.file_count || 0}</td>`
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>` + `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
+ `</tr>`; + `</tr>`;
}).join(""); }).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
return `<div class="card"><h2>存储用量(${quotaLabel}</h2><div class="scroll-x"><table>` $("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>` + `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`; + `<tbody>${body}</tbody></table></div>`
+ pagerHTML("st", page, maxPage, from, to, total)
+ `</div>`;
wirePager("st", page, maxPage, (p) => loadStorage(p));
} }
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表, function pagerHTML(prefix, page, maxPage, from, to, total) {
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。 return `<div class="pager">`
+ `<button id="${prefix}-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
+ `<span class="pginfo">${from}${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
+ `<button id="${prefix}-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
+ `</div>`;
}
function wirePager(prefix, page, maxPage, go) {
const prev = $(`${prefix}-prev`), next = $(`${prefix}-next`);
if (prev) prev.onclick = () => go(page - 1);
if (next) next.onclick = () => go(page + 1);
}
// ───── 骨架 + 目录 ─────
function ensureSkeleton() { function ensureSkeleton() {
if ($("metrics")) return; if ($("layout")) return;
$("main").innerHTML = `<div id="metrics"></div><div id="user-usage"></div>`; $("main").innerHTML = `<div id="layout">`
+ `<nav id="toc">` + SECTIONS.map(([id, label]) =>
`<a href="#${id}" data-target="${id}">${label}</a>`).join("") + `</nav>`
+ `<div id="content">` + SECTIONS.map(([id]) =>
`<div id="${id}" class="anchor"></div>`).join("") + `</div>`
+ `</div>`;
document.querySelectorAll("#toc a").forEach(a => {
a.onclick = (e) => {
e.preventDefault();
const el = $(a.dataset.target);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
});
setupScrollSpy();
}
// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可)
function setupScrollSpy() {
const links = {};
document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; });
const obs = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (!en.isIntersecting) return;
Object.values(links).forEach(l => l.classList.remove("active"));
if (links[en.target.id]) links[en.target.id].classList.add("active");
});
}, { rootMargin: "-70px 0px -65% 0px", threshold: 0 });
SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); });
} }
function renderMetrics(d) { function renderMetrics(d) {
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("metrics").innerHTML = $("s-runtime").innerHTML = renderRuntime(d.runtime || {});
renderRuntime(d.runtime || {}) $("s-tasks").innerHTML = renderTasks(d.tasks || {});
+ renderTasks(d.tasks || {}) $("s-usage").innerHTML =
+ renderUsersAndUsage(d.users || {}, d.usage || {}) renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d) + renderByDay((d.usage || {}).by_day_7d);
+ renderByModel((d.usage || {}).by_model)
+ renderStorage(d.storage || {});
} }
function showMsg(html) { function showMsg(html) {
$("main").innerHTML = `<div class="msg">${html}</div>`; // 清骨架,错误态独占 $("main").innerHTML = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
} }
// 处理鉴权/网络错误:命中返 true(调用方据此中止)。 // ───── 拉数据 ─────
function handleAuthError(r) { // 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
if (r.status === 401) { async function apiGet(path) {
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
stopAuto(); return true;
}
if (r.status === 403) {
showMsg(`无权限管理后台仅限管理员admin访问。<br/>` +
`<a href="/static/dev.html">返回控制台</a>`);
stopAuto(); return true;
}
return false;
}
// ───── 各用户用量分页 ─────
async function loadUserUsage(page) {
const t = token();
if (!t) return;
page = Math.max(0, page);
try {
const r = await fetch(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}`, {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) return;
const d = await r.json();
userPage = d.page || 0; // 以服务端回的页码为准(夹紧后)
renderUserUsage(d);
} catch (e) { /* 静默:overview 那边会报总错 */ }
}
// ───── 拉 overview ─────
async function refresh() {
const t = token(); const t = token();
if (!t) { if (!t) {
showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`); showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
stopAuto(); stopAuto();
return; throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
if (r.status === 401) {
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
stopAuto();
throw Object.assign(new Error("401"), { code: "auth" });
}
if (r.status === 403) {
showMsg(`无权限管理后台仅限管理员admin访问。<br/><a href="/static/dev.html">返回控制台</a>`);
stopAuto();
throw Object.assign(new Error("403"), { code: "auth" });
} }
try {
const r = await fetch("/v1/admin/overview", {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
showMsg(`加载失败:${escapeHtml(d.detail || (r.status + ""))}`); throw new Error(d.detail || String(r.status));
return;
} }
return r.json();
}
async function loadModels() {
try {
renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
} catch (e) { /* auth 已提示;其它静默,overview 那边兜底 */ }
}
async function loadUserUsage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
userPage = d.page || 0;
renderUserUsage(d);
} catch (e) { /* 同上 */ }
}
async function loadStorage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0;
renderStorage(d);
} catch (e) { /* 同上 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新三个独立表(保持各自状态)
async function refresh() {
try {
const d = await apiGet("/v1/admin/overview");
ensureSkeleton(); ensureSkeleton();
renderMetrics(await r.json()); renderMetrics(d);
loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码) loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
} catch (e) { } catch (e) {
showMsg(`加载失败:${escapeHtml(e.message || String(e))}`); if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
} }
} }
// ───── 导出 PDF(客户端打印;列表取前 10)─────
async function exportPdf() {
const btn = $("export");
btn.disabled = true; const old = btn.textContent; btn.textContent = "生成中…";
try {
const [ov, models, users, storage, health] = await Promise.all([
apiGet("/v1/admin/overview"),
apiGet("/v1/admin/usage/models?range=all&sort=cost"),
apiGet("/v1/admin/usage/users?range=all&sort=cost&page=0&page_size=10"),
apiGet("/v1/admin/storage/users?page=0&page_size=10"),
fetch("/healthz").then(r => r.json()).catch(() => ({})),
]);
buildReport(ov, models, users, storage, health);
window.print();
} catch (e) {
if (e.code !== "auth") alert("导出失败:" + (e.message || String(e)));
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function buildReport(ov, models, users, storage, health) {
const rt = ov.runtime || {}, tk = ov.tasks || {}, us = ov.users || {}, u = (ov.usage || {}).total || {};
const byDay = (ov.usage || {}).by_day_7d || [];
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
const tbl = (headers, body) =>
`<table class="rpt"><thead><tr>${headers.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table>`;
const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";
const dayBody = byDay.map(r => `<tr><td>${escapeHtml(r.date)}</td><td>${fmtCNY(r.cost_cny)}</td>`
+ `<td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td></tr>`).join("")
|| `<tr><td colspan="4">无数据</td></tr>`;
const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
$("print-report").innerHTML =
`<h1>zcbot 管理后台报告</h1>`
+ `<div class="rpt-meta">生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
+ `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10</div>`
+ `<h2>实时运行态</h2>`
+ `<div class="rpt-kv">活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}`
+ ` &nbsp;|&nbsp; SSE 订阅 ${rt.sse_subs || 0}`
+ ` &nbsp;|&nbsp; 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}</div>`
+ `<h2>任务(共 ${tk.total || 0}</h2>`
+ `<div class="rpt-kv">status${dist(tk.by_status)}<br/>run_status${dist(tk.by_run_status)}</div>`
+ `<h2>用户</h2><div class="rpt-kv">总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}</div>`
+ `<h2>用量总览all-time</h2>`
+ `<div class="rpt-kv">总成本 ${fmtCNY(u.cost_cny)} &nbsp;|&nbsp; 输入 ${fmtTokens(u.tokens_in)}`
+ ` &nbsp;|&nbsp; 输出 ${fmtTokens(u.tokens_out)} &nbsp;|&nbsp; 缓存命中 ${hitRate}%`
+ ` &nbsp;|&nbsp; 事件 ${u.n_events || 0}</div>`
+ `<h2>近 7 天用量(按天)</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
+ `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `<h2>各用户用量all-timeTop 10</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10</h2>`
+ tbl(["用户", "已用", "占配额", "文件数"], stBody);
}
// ───── 自动刷新 ───── // ───── 自动刷新 ─────
function startAuto() { function startAuto() {
stopAuto(); stopAuto();
@ -269,6 +413,7 @@ function stopAuto() {
} }
$("refresh").onclick = refresh; $("refresh").onclick = refresh;
$("export").onclick = exportPdf;
$("auto-refresh").onchange = startAuto; $("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {