diff --git a/DESIGN.md b/DESIGN.md index 3db95ee..46a838a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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/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/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 ` 走所有 /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。 diff --git a/PROGRESS.md b/PROGRESS.md index bd4b015..2628001 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 参数)。 - 验证:`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;五段聚合对真实数据跑通。 diff --git a/RUN.md b/RUN.md index cfa93f8..a8d6a81 100644 --- a/RUN.md +++ b/RUN.md @@ -50,7 +50,7 @@ - **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))"`。 - **用户管理**(`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 互不相干。 --- diff --git a/core/__init__.py b/core/__init__.py index dec8e57..4284e3e 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.10.1" +__version__ = "0.11.0" diff --git a/web/admin.py b/web/admin.py index 64cebc4..e4e8271 100644 --- a/web/admin.py +++ b/web/admin.py @@ -17,7 +17,7 @@ from typing import Any from uuid import UUID 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.models import Task, UsageEvent, User, UserDiskUsage @@ -37,6 +37,15 @@ def _rss_peak_mb(): 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: """实时运行态:从 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: - """token / 成本聚合:全局合计 + 近 7d 按天 + 按模型 + top 用户。 + """token / 成本聚合(放进 overview,固定形态):全局合计(all-time)+ 近 7d 按天趋势。 - chat token 取自 usage_events.units JSONB(tokens_in/out/cache_hit_tokens);cost_cny - 全 kind 合计。与 _usage_aggregates 同源,缓存命中率分母一致(恒 ≤100%)。 + 按模型 / 各用户用量已拆成独立带筛选排序的端点(_models_usage / _user_usage_page), + 不在此 bundle。chat token 取自 usage_events.units JSONB;cost_cny 全 kind 合计。 """ chat = UsageEvent.kind == "chat" tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger) @@ -133,8 +142,30 @@ def _usage_section(s: Any, cutoff_7d: datetime) -> dict: ).all() ] - # 按模型(all-time;cost desc) - by_model = [ + return {"total": total, "by_day_7d": by_day} + + +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, "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), "n_events": int(n or 0), } - for mp, c, ti, to, n in s.execute( - 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() + for mp, c, ti, to, n in s.execute(q).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: - """分页的各用户 token 用量(cost desc),含零用量用户(LEFT JOIN users)。 - - `各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0)。 - cost 全 kind 合计;token/cache_hit 仅 chat(与 _usage_aggregates / total 同源)。 - 排序 cost desc + user_id 兜底(稳定分页,避免大量 0 值并列时跨页错位)。 - 返回 {page, page_size, total_users, rows:[...]}。 + `各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0); + 时间筛选放 JOIN ON(非 WHERE),否则带 cutoff 时会把零用量用户挤掉。 + sort: cost(按成本)/ tokens(按用量=输入+输出);+ user_id 兜底稳定分页。 + cost 全 kind 合计;token/cache_hit 仅 chat。返回 {page, page_size, total_users, rows}。 """ chat = UsageEvent.kind == "chat" tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger) tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger) hit = cast(UsageEvent.units["cache_hit_tokens"].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() + + 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() 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( select( - User.user_id, - User.email, - User.role, - cost_sum, - func.coalesce(func.sum(tin).filter(chat), 0), - func.coalesce(func.sum(tout).filter(chat), 0), + User.user_id, User.email, User.role, + cost_sum, tin_sum, tout_sum, func.coalesce(func.sum(hit).filter(chat), 0), 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) - .order_by(cost_sum.desc(), User.user_id) + .order_by(order, User.user_id) .limit(page_size) .offset(page * page_size) ).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} -def _storage_section(s: Any) -> dict: - """各用户磁盘用量(user_disk_usage join email),bytes desc;附 per-user 配额。""" +def _storage_page(s: Any, page: int, page_size: int) -> dict: + """分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。 + + 数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数。 + """ from core.agent_builder import load_config from core.storage.disk_quota import parse_bytes 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 = [ { "user_id": str(uid), @@ -232,10 +254,15 @@ def _storage_section(s: Any) -> dict: UserDiskUsage.scanned_at, ) .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() ] - 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: @@ -243,7 +270,10 @@ def register_admin_routes(app: FastAPI, require_admin) -> None: @app.get("/v1/admin/overview", tags=["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) cutoff_7d = now - timedelta(days=7) with session_scope() as s: @@ -253,19 +283,45 @@ def register_admin_routes(app: FastAPI, require_admin) -> None: "tasks": _tasks_section(s), "users": _users_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"]) 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)。 - 前端独立于 overview 轮询管理本表分页;总用量在 overview.usage.total。 + page 0-based;page_size 夹到 [1,100];range all/7d/30d;sort cost/tokens。 + 含零用量用户(全表 LEFT JOIN);总用量在 overview.usage.total。 """ page = max(0, page) page_size = min(100, max(1, page_size)) + now = datetime.now(timezone.utc) 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) diff --git a/web/static/admin.html b/web/static/admin.html index d517ef6..790a3ae 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -43,6 +43,26 @@ .msg { padding: 40px 16px; text-align: center; color: var(--muted); } .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)); } .card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); @@ -94,6 +114,32 @@ main { padding: 10px; } .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .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; } } @@ -105,11 +151,14 @@
+ ← 返回控制台
加载中…
+ + diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 53f2d48..4e42f27 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -1,19 +1,29 @@ // zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 -// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图, -// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。 -// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。 -// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点。 +// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。 +// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询; +// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 +// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js"; const LS_TOKEN = "zcbot.token"; const REFRESH_MS = 10000; 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 token = () => localStorage.getItem(LS_TOKEN) || ""; 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) { @@ -27,12 +37,22 @@ function tint(value, max) { const a = Math.min(1, value / max) * 0.30; return `background: rgba(192,57,43,${a.toFixed(3)});`; } -// 阈值类:ratio>=1 危险,>=0.8 警告。 function levelClass(ratio) { if (ratio >= 1) return "danger"; if (ratio >= 0.8) return "warn"; return ""; } +// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。 +function ctrlHTML(prefix, range, sort) { + const opt = (cur, list) => list.map( + ([v, l]) => `` + ).join(""); + return `
` + + `` + + `` + + `
`; +} +function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; } // ───── 渲染各 section ───── function statCard(k, v, sub, cls) { @@ -45,11 +65,10 @@ function renderRuntime(r) { const active = r.active_runs || 0; const max = r.max_workers || 0; const ratio = max ? active / max : 0; - const cls = levelClass(ratio); const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : ""; const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—"; return `

实时运行态

` - + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls) + + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio)) + statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接") + statCard("内存峰值", rss, "进程 RSS high-water") + `
`; @@ -67,8 +86,8 @@ function renderTasks(t) { return `${escapeHtml(k)} ${n}`; }).join("") || ``; return `

任务(共 ${t.total || 0})

` - + `
status
${statusChips}
` - + `
run_status
${runChips}
` + + `
status
${statusChips}
` + + `
run_status
${runChips}
` + `
`; } @@ -84,39 +103,46 @@ function renderUsersAndUsage(users, usage) { } function renderByDay(rows) { - if (!rows || !rows.length) return `

近 7 天用量

无数据
`; - const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); + rows = rows || []; + const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0)); const body = rows.map(r => `` + `${escapeHtml(r.date)}` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` - + ``).join(""); + + ``).join("") || `无数据`; return `

近 7 天用量(按天)

` + `` + `${body}
日期成本输入输出
`; } -function renderByModel(rows) { - if (!rows || !rows.length) return `

按模型

无数据
`; - const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); - const body = rows.map(r => `` - + `${escapeHtml(r.model_profile || "—")}` - + `${fmtCNY(r.cost_cny)}` - + `${fmtTokens(r.tokens_in)}` - + `${fmtTokens(r.tokens_out)}` - + `${r.n_events || 0}` - + ``).join(""); - return `

按模型(all-time)

` +// 按模型(时间筛选 + 排序)。d = {range, sort, rows} +function renderModels(d) { + const rows = d.rows || []; + 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 `` + + `` + + `` + + `` + + `` + + `` + + ``; + }).join("") || ``; + $("s-models").innerHTML = `
` + + `

按模型(${rangeLabel(d.range)})

${ctrlHTML("m", d.range, d.sort)}
` + + `
${fmtCNY(r.cost_cny)}${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}${r.n_events || 0}
无数据
` + `` + `${body}
模型成本输入输出事件
`; + $("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); }; + $("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); }; } -// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick -// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。 +// 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows function renderUserUsage(d) { - const c = $("user-usage"); - if (!c) return; const rows = d.rows || []; const total = d.total_users || 0; const size = d.page_size || PAGE_SIZE; @@ -126,43 +152,47 @@ function renderUserUsage(d) { const to = Math.min(total, (page + 1) * size); 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 byTok = d.sort === "tokens"; const body = rows.map(r => { const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0; return `` + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + (r.role === "admin" ? ` admin` : "") + `` - + `${fmtCNY(r.cost_cny)}` - + `${fmtTokens(r.tokens_in)}` + + `${fmtCNY(r.cost_cny)}` + + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` + `${hitRate}%` + `${r.n_events || 0}` + ``; }).join("") || `无数据`; - c.innerHTML = `

各用户用量(按成本,all-time)

` + $("s-users").innerHTML = `
` + + `

各用户用量(${rangeLabel(d.range)})

${ctrlHTML("u", d.range, d.sort)}
` + `
` + `` + `${body}
用户成本输入输出缓存命中事件
` - + `
` - + `` - + `${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)` - + `` - + `
`; - const prev = $("uu-prev"), next = $("uu-next"); - if (prev) prev.onclick = () => loadUserUsage(userPage - 1); - if (next) next.onclick = () => loadUserUsage(userPage + 1); + + pagerHTML("uu", page, maxPage, from, to, total) + + `
`; + $("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); }; + $("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); }; + wirePager("uu", page, maxPage, (p) => loadUserUsage(p)); } -function renderStorage(st) { - const quota = st.quota_bytes; - const rows = st.users || []; +// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows +function renderStorage(d) { + 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)}/人` : "无配额上限"; - if (!rows.length) return `

存储用量(${quotaLabel})

无数据
`; - const maxUsed = Math.max(...rows.map(r => r.bytes_used || 0)); + const maxUsed = Math.max(0, ...rows.map(r => r.bytes_used || 0)); const body = rows.map(r => { const ratio = quota && quota > 0 ? r.bytes_used / quota : 0; const cls = levelClass(ratio); const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—"; - // 有配额时按配额占比上色(逼近上限变橙/红);无配额时按相对最大值热力上色 const cellStyle = quota && quota > 0 ? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);" : cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "") @@ -174,91 +204,205 @@ function renderStorage(st) { + `${r.file_count || 0}` + `${r.scanned_at ? fmtTime(r.scanned_at) : "—"}` + ``; - }).join(""); - return `

存储用量(${quotaLabel})

` + }).join("") || ``; + $("s-storage").innerHTML = `

存储用量(${quotaLabel})

` + + `
无数据
` + `` - + `${body}
用户已用占配额文件数扫描于
`; + + `${body}` + + pagerHTML("st", page, maxPage, from, to, total) + + ``; + wirePager("st", page, maxPage, (p) => loadStorage(p)); } -// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表, -// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。 +function pagerHTML(prefix, page, maxPage, from, to, total) { + return `
` + + `` + + `${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)` + + `` + + `
`; +} +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() { - if ($("metrics")) return; - $("main").innerHTML = `
`; + if ($("layout")) return; + $("main").innerHTML = `
` + + `` + + `
` + SECTIONS.map(([id]) => + `
`).join("") + `
` + + `
`; + 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) { $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; - $("metrics").innerHTML = - renderRuntime(d.runtime || {}) - + renderTasks(d.tasks || {}) - + renderUsersAndUsage(d.users || {}, d.usage || {}) - + renderByDay((d.usage || {}).by_day_7d) - + renderByModel((d.usage || {}).by_model) - + renderStorage(d.storage || {}); + $("s-runtime").innerHTML = renderRuntime(d.runtime || {}); + $("s-tasks").innerHTML = renderTasks(d.tasks || {}); + $("s-usage").innerHTML = + renderUsersAndUsage(d.users || {}, d.usage || {}) + + renderByDay((d.usage || {}).by_day_7d); } function showMsg(html) { $("main").innerHTML = `
${html}
`; // 清骨架,错误态独占 } -// 处理鉴权/网络错误:命中返 true(调用方据此中止)。 -function handleAuthError(r) { - if (r.status === 401) { - showMsg(`登录已失效。请回 控制台 重新登录。`); - stopAuto(); return true; - } - if (r.status === 403) { - showMsg(`无权限:管理后台仅限管理员(admin)访问。
` + - `返回控制台`); - 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() { +// ───── 拉数据 ───── +// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。 +async function apiGet(path) { const t = token(); if (!t) { showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`); 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(`登录已失效。请回 控制台 重新登录。`); + stopAuto(); + throw Object.assign(new Error("401"), { code: "auth" }); + } + if (r.status === 403) { + showMsg(`无权限:管理后台仅限管理员(admin)访问。
返回控制台`); + stopAuto(); + throw Object.assign(new Error("403"), { code: "auth" }); + } + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || String(r.status)); + } + return r.json(); +} + +async function loadModels() { try { - const r = await fetch("/v1/admin/overview", { - headers: { Authorization: "Bearer " + t }, - }); - if (handleAuthError(r)) return; - if (!r.ok) { - const d = await r.json().catch(() => ({})); - showMsg(`加载失败:${escapeHtml(d.detail || (r.status + ""))}`); - return; - } + 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(); - renderMetrics(await r.json()); - loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码) + renderMetrics(d); + loadModels(); + loadUserUsage(userPage); + loadStorage(storagePage); } 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) => + `${headers.map(h => ``).join("")}${body}
${h}
`; + const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无"; + + const dayBody = byDay.map(r => `${escapeHtml(r.date)}${fmtCNY(r.cost_cny)}` + + `${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}`).join("") + || `无数据`; + const modelBody = (models.rows || []).slice(0, 10).map(r => `${escapeHtml(r.model_profile || "—")}` + + `${fmtCNY(r.cost_cny)}${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}` + + `${r.n_events || 0}`).join("") || `无数据`; + const userBody = (users.rows || []).slice(0, 10).map(r => `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + + `${fmtCNY(r.cost_cny)}${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}` + + `${r.n_events || 0}`).join("") || `无数据`; + 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 `${escapeHtml(r.email || r.user_id.slice(0, 8))}${humanSize(r.bytes_used)}` + + `${pct}${r.file_count || 0}`; + }).join("") || `无数据`; + + $("print-report").innerHTML = + `

zcbot 管理后台报告

` + + `
生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}` + + `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10
` + + `

实时运行态

` + + `
活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}` + + `  |  SSE 订阅 ${rt.sse_subs || 0}` + + `  |  内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}
` + + `

任务(共 ${tk.total || 0})

` + + `
status:${dist(tk.by_status)}
run_status:${dist(tk.by_run_status)}
` + + `

用户

总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}
` + + `

用量总览(all-time)

` + + `
总成本 ${fmtCNY(u.cost_cny)}  |  输入 ${fmtTokens(u.tokens_in)}` + + `  |  输出 ${fmtTokens(u.tokens_out)}  |  缓存命中 ${hitRate}%` + + `  |  事件 ${u.n_events || 0}
` + + `

近 7 天用量(按天)

` + tbl(["日期", "成本", "输入", "输出"], dayBody) + + `

按模型(all-time,Top 10)

` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody) + + `

各用户用量(all-time,Top 10)

` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody) + + `

存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)

` + + tbl(["用户", "已用", "占配额", "文件数"], stBody); +} + // ───── 自动刷新 ───── function startAuto() { stopAuto(); @@ -269,6 +413,7 @@ function stopAuto() { } $("refresh").onclick = refresh; +$("export").onclick = exportPdf; $("auto-refresh").onchange = startAuto; // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) document.addEventListener("visibilitychange", () => {