feat(admin): 角色化管理后台 + 分页各用户用量 + bump 0.9.0
- users 加 role 列(user/admin,migration 0009);make_require_admin 按 DB role gate(不进 JWT,改完即时生效) - /v1/admin/overview 监控总览:runtime(并发/线程池/SSE/RSS)+ tasks + users + usage 总用量 + storage - /v1/admin/usage/users 分页各用户 token 用量(全表 LEFT JOIN 含零用量,cost desc,稳定排序) - /v1/me 返 role;登录/建用户响应带 role;main.py user role / user add --role;建用户弹框加角色下拉 - 独立页 web/static/admin.html + js/admin.js(阈值/热力色差、响应式、10s 轮询、独立翻页);dev SPA admin 才显"管理"入口 - 文档同步:DESIGN §7.3/§7.4、PROGRESS、RUN Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
44be5753f7
commit
ef611b0666
11
DESIGN.md
11
DESIGN.md
|
|
@ -305,8 +305,10 @@ done {}
|
|||
- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)
|
||||
- `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`。后续续挂建用户/改角色/配置等管理动作
|
||||
|
||||
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`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。
|
||||
|
||||
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
|
||||
|
||||
|
|
@ -315,11 +317,16 @@ done {}
|
|||
### 7.4 存储:Postgres + 本地文件系统
|
||||
|
||||
```sql
|
||||
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at)
|
||||
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
|
||||
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
|
||||
created_at)
|
||||
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
|
||||
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
|
||||
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
|
||||
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
|
||||
-- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签);
|
||||
-- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_TOKEN
|
||||
-- (发用户共享口令)正交,互不相干
|
||||
|
||||
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
|
||||
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-11(用户私有 skill:多来源 registry + save_skill/fork_skill + skill-creator)
|
||||
最后更新:2026-06-12(admin 管理后台:users.role + require_admin + /v1/admin/overview + 独立 admin.html 监控页)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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;五段聚合对真实数据跑通。
|
||||
|
||||
### 2026-06-11
|
||||
|
||||
- **版本号机制(单一事实源 + 前端展示)**:此前只有 `web/app.py` 写死 `version="0.8"`(仅进 OpenAPI 文档,前端拿不到)。改为 `core/__init__.py` 的 `__version__`(当前 `0.8.0`)作唯一来源 → FastAPI `version`、`/healthz` 返回 `{"status":"ok","version":..}`、前端左栏底部展示全引它,**改版本只动这一行**。前端 `main.js` boot 时无条件 fetch `/healthz`(auth 豁免,embed/未登录都拿得到)填进 `#app-version`,**钉在右侧文件面板底部存储条(`.storage-foot`)最左、带细分隔线、垂直居中**(纯展示不可点;随存储条一起显隐)。**不放顶栏**:embed 模式桌面端整层 header 被 CSS 隐藏,顶栏点不到;**也不放左栏**:左栏底部留给后续按钮。CLAUDE.md「文档维护」段已加规矩:每次 commit/push bump `__version__`(patch=修复/重构/调参/skill、minor=成批新功能/对外行为变化、major=1.0 发版)。
|
||||
|
|
|
|||
15
RUN.md
15
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||
|
||||
最后更新:2026-06-03(默认镜像源改清华 pip+apt / 腾讯 npm —— 腾讯 PyPI 给过损坏 litellm wheel,npmmirror 访问不稳;workspace 落数据盘改 bind mount,撤 ZCBOT_WORKSPACE_DIR env)
|
||||
最后更新:2026-06-12(admin 角色 + /static/admin.html 管理后台:user role CLI / 建用户带 --role / 顶栏"管理"入口)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -49,7 +49,8 @@
|
|||
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
|
||||
- **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`,0005 UNIQUE(email)):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 互不相干。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -99,14 +100,20 @@ python -m venv .venv
|
|||
# 发用户(两条路径,任选其一)
|
||||
# a) CLI:
|
||||
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
|
||||
# → [ok] user added email=alice@example.com user_id=<uuid>
|
||||
# → [ok] user added email=alice@example.com role=user user_id=<uuid>
|
||||
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
|
||||
# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。
|
||||
# 弹窗输入 email/密码/管理员口令/角色,POST /v1/auth/admin/create_user 落库。
|
||||
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
|
||||
|
||||
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
|
||||
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
|
||||
|
||||
# 角色:user(默认)/ admin。admin 才能开顶栏"管理"入口 → /static/admin.html 管理后台
|
||||
# (监控总览:运行态/用量/任务/用户/存储)。建用户时带 --role,或事后改:
|
||||
.venv/Scripts/python.exe main.py user add --email ops@x.com --password "s3cret" --role admin
|
||||
.venv/Scripts/python.exe main.py user role --email alice@example.com --role admin
|
||||
# → [ok] role set email=alice@example.com role=admin user_id=<uuid>
|
||||
|
||||
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
|
||||
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
|
||||
# psql> DELETE FROM users WHERE email='alice@example.com';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.8.1"
|
||||
__version__ = "0.9.0"
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ class User(Base):
|
|||
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
# 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。
|
||||
# 提管理员:main.py user role --email X --role admin。
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""users.role 列(admin 管理后台访问控制).
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-06-12
|
||||
|
||||
给 users 加 role 列(user / admin),给现有所有行默认 'user';/v1/admin/* 监控端点
|
||||
走 make_require_admin gate,只放 role='admin' 的用户。提管理员:
|
||||
`.venv/Scripts/python.exe main.py user role --email X --role admin`。
|
||||
|
||||
只加列、不动现有数据(开发期测试数据保留);server_default='user' 让历史行自动回填。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0009"
|
||||
down_revision: Union[str, None] = "0008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("role", sa.Text(), nullable=False, server_default="user"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "role")
|
||||
32
main.py
32
main.py
|
|
@ -150,8 +150,11 @@ def user() -> None:
|
|||
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
|
||||
@click.option("--user-id", default=None,
|
||||
help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
|
||||
def user_add(email: str, password: str, user_id: str) -> None:
|
||||
"""新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。
|
||||
@click.option("--role", "role", default="user",
|
||||
type=click.Choice(["user", "admin"]), show_default=True,
|
||||
help="admin 可访问 /static/admin.html 管理后台;之后也可 user role 改")
|
||||
def user_add(email: str, password: str, user_id: str, role: str) -> None:
|
||||
"""新建用户:bcrypt(password) → INSERT users(email,password_hash,role[,user_id])。
|
||||
|
||||
email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接
|
||||
`DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。
|
||||
|
|
@ -169,11 +172,32 @@ def user_add(email: str, password: str, user_id: str) -> None:
|
|||
sys.exit(2)
|
||||
|
||||
try:
|
||||
uid, e = create_user(email=email, password=password, user_id=uid_arg)
|
||||
uid, e = create_user(email=email, password=password, user_id=uid_arg, role=role)
|
||||
except UserCreateError as ex:
|
||||
click.echo(f"[err] {ex.message}", err=True)
|
||||
sys.exit(2)
|
||||
click.echo(f"[ok] user added email={e} user_id={uid}")
|
||||
click.echo(f"[ok] user added email={e} role={role} user_id={uid}")
|
||||
|
||||
|
||||
@user.command("role")
|
||||
@click.option("--email", required=True, help="目标用户登录邮箱")
|
||||
@click.option("--role", "role", required=True,
|
||||
type=click.Choice(["user", "admin"]),
|
||||
help="user(普通)/ admin(可访问 /static/admin.html 管理后台)")
|
||||
def user_role(email: str, role: str) -> None:
|
||||
"""改用户角色:UPDATE users SET role=... WHERE email=...。
|
||||
|
||||
admin 才能访问 /v1/admin/* 与 /static/admin.html。改完下次请求立即生效
|
||||
(role 走 DB 查,不进 JWT,老 token 无需重签)。email 查无此人 → 退出 2。
|
||||
"""
|
||||
from web.auth import UserCreateError, set_user_role
|
||||
|
||||
try:
|
||||
uid, e = set_user_role(email=email, role=role)
|
||||
except UserCreateError as ex:
|
||||
click.echo(f"[err] {ex.message}", err=True)
|
||||
sys.exit(2)
|
||||
click.echo(f"[ok] role set email={e} role={role} user_id={uid}")
|
||||
|
||||
|
||||
# ─────────────── Web 服务 ───────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
"""管理后台端点(admin-only):/v1/admin/*。
|
||||
|
||||
`register_admin_routes(app, require_admin)` 在 create_app 内调用,把管理路由挂上去,
|
||||
整组走 `Depends(require_admin)`(JWT 有效 + users.role=='admin',否则 403)。
|
||||
|
||||
第一版只有总览(监控指标):单个 `GET /v1/admin/overview` 一次返回全部 section
|
||||
(runtime / tasks / users / usage / storage),前端定时轮询这一个端点即可。runtime 读
|
||||
app.state 内存(轻);其余走 DB 聚合(GROUP BY,无 N+1)。指标只读、不落库。
|
||||
|
||||
后续管理动作(建用户 / 改角色 / 配置磁盘配额等)在此模块续挂 /v1/admin/users、
|
||||
/v1/admin/config 等,前端 admin.html 加 tab。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy import BigInteger, Numeric, cast, func, select
|
||||
|
||||
from core.storage import session_scope
|
||||
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
|
||||
|
||||
from .broker import broker
|
||||
|
||||
try:
|
||||
import resource # Unix only;Windows dev 无此模块,RSS 监控降级跳过
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
resource = None
|
||||
|
||||
|
||||
def _rss_peak_mb():
|
||||
"""进程峰值 RSS(MB)。Linux 走 ru_maxrss(KB);Windows dev 返 None(降级)。"""
|
||||
if resource is None:
|
||||
return None
|
||||
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
||||
|
||||
|
||||
def _runtime_section(app: FastAPI) -> dict:
|
||||
"""实时运行态:从 app.state 读内存,无 DB。
|
||||
|
||||
active_runs 逼近 max_workers 即线程池排队(新 run 的 SSE 会卡)—— 前端据此变色。
|
||||
"""
|
||||
inflight = getattr(app.state, "inflight", None)
|
||||
active = len(inflight) if inflight is not None else 0
|
||||
max_workers = getattr(app.state, "run_max_workers", None)
|
||||
return {
|
||||
"active_runs": active,
|
||||
"max_workers": max_workers,
|
||||
"sse_subs": broker.total_subscribers(),
|
||||
"rss_peak_mb": _rss_peak_mb(),
|
||||
}
|
||||
|
||||
|
||||
def _tasks_section(s: Any) -> dict:
|
||||
"""task 计数:总数 + 按 status + 按 run_status 分布。"""
|
||||
total = s.execute(select(func.count()).select_from(Task)).scalar_one()
|
||||
by_status = {
|
||||
st: n
|
||||
for st, n in s.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
).all()
|
||||
}
|
||||
by_run_status = {
|
||||
st: n
|
||||
for st, n in s.execute(
|
||||
select(Task.run_status, func.count()).group_by(Task.run_status)
|
||||
).all()
|
||||
}
|
||||
return {"total": total, "by_status": by_status, "by_run_status": by_run_status}
|
||||
|
||||
|
||||
def _users_section(s: Any, cutoff_7d: datetime) -> dict:
|
||||
"""用户:总数 + 近 7d 有用量事件的活跃用户数。"""
|
||||
total = s.execute(select(func.count()).select_from(User)).scalar_one()
|
||||
active_7d = s.execute(
|
||||
select(func.count(func.distinct(UsageEvent.user_id))).where(
|
||||
UsageEvent.created_at >= cutoff_7d
|
||||
)
|
||||
).scalar_one()
|
||||
return {"total": total, "active_7d": active_7d}
|
||||
|
||||
|
||||
def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
|
||||
"""token / 成本聚合:全局合计 + 近 7d 按天 + 按模型 + top 用户。
|
||||
|
||||
chat token 取自 usage_events.units JSONB(tokens_in/out/cache_hit_tokens);cost_cny
|
||||
全 kind 合计。与 _usage_aggregates 同源,缓存命中率分母一致(恒 ≤100%)。
|
||||
"""
|
||||
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)
|
||||
|
||||
# 全局合计(all-time)
|
||||
g = s.execute(
|
||||
select(
|
||||
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.coalesce(func.sum(hit).filter(chat), 0),
|
||||
func.count(),
|
||||
)
|
||||
).one()
|
||||
total = {
|
||||
"cost_cny": float(g[0] or 0),
|
||||
"tokens_in": int(g[1] or 0),
|
||||
"tokens_out": int(g[2] or 0),
|
||||
"tokens_cache_hit": int(g[3] or 0),
|
||||
"n_events": int(g[4] or 0),
|
||||
}
|
||||
|
||||
# 近 7d 按天(date 截断;前端画成条/数字均可)
|
||||
day = func.date(UsageEvent.created_at)
|
||||
by_day = [
|
||||
{
|
||||
"date": str(d),
|
||||
"cost_cny": float(c or 0),
|
||||
"tokens_in": int(ti or 0),
|
||||
"tokens_out": int(to or 0),
|
||||
}
|
||||
for d, c, ti, to in s.execute(
|
||||
select(
|
||||
day,
|
||||
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
|
||||
func.coalesce(func.sum(tin).filter(chat), 0),
|
||||
func.coalesce(func.sum(tout).filter(chat), 0),
|
||||
)
|
||||
.where(UsageEvent.created_at >= cutoff_7d)
|
||||
.group_by(day)
|
||||
.order_by(day)
|
||||
).all()
|
||||
]
|
||||
|
||||
# 按模型(all-time;cost desc)
|
||||
by_model = [
|
||||
{
|
||||
"model_profile": mp,
|
||||
"cost_cny": float(c or 0),
|
||||
"tokens_in": int(ti or 0),
|
||||
"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()
|
||||
]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_day_7d": by_day,
|
||||
"by_model": by_model,
|
||||
}
|
||||
|
||||
|
||||
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:[...]}。
|
||||
"""
|
||||
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)
|
||||
|
||||
total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
|
||||
rows = [
|
||||
{
|
||||
"user_id": str(uid),
|
||||
"email": email or "",
|
||||
"role": role or "user",
|
||||
"cost_cny": float(c or 0),
|
||||
"tokens_in": int(ti or 0),
|
||||
"tokens_out": int(to or 0),
|
||||
"tokens_cache_hit": int(h or 0),
|
||||
"n_events": int(n or 0),
|
||||
}
|
||||
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),
|
||||
func.coalesce(func.sum(hit).filter(chat), 0),
|
||||
func.count(UsageEvent.event_id),
|
||||
)
|
||||
.join(UsageEvent, UsageEvent.user_id == User.user_id, isouter=True)
|
||||
.group_by(User.user_id, User.email, User.role)
|
||||
.order_by(cost_sum.desc(), User.user_id)
|
||||
.limit(page_size)
|
||||
.offset(page * page_size)
|
||||
).all()
|
||||
]
|
||||
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 配额。"""
|
||||
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"))
|
||||
rows = [
|
||||
{
|
||||
"user_id": str(uid),
|
||||
"email": email or "",
|
||||
"bytes_used": int(b or 0),
|
||||
"file_count": int(fc or 0),
|
||||
"scanned_at": scanned.isoformat() if scanned else None,
|
||||
}
|
||||
for uid, email, b, fc, scanned in s.execute(
|
||||
select(
|
||||
UserDiskUsage.user_id,
|
||||
User.email,
|
||||
UserDiskUsage.bytes_used,
|
||||
UserDiskUsage.file_count,
|
||||
UserDiskUsage.scanned_at,
|
||||
)
|
||||
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True)
|
||||
.order_by(UserDiskUsage.bytes_used.desc())
|
||||
).all()
|
||||
]
|
||||
return {"quota_bytes": quota, "users": rows}
|
||||
|
||||
|
||||
def register_admin_routes(app: FastAPI, require_admin) -> None:
|
||||
"""把 /v1/admin/* 管理路由挂到 app 上,整组走 require_admin gate。"""
|
||||
|
||||
@app.get("/v1/admin/overview", tags=["admin"])
|
||||
def admin_overview(user_id: UUID = Depends(require_admin)):
|
||||
"""管理总览:一次返回全部 section,供 /static/admin.html 轮询。admin-only。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_7d = now - timedelta(days=7)
|
||||
with session_scope() as s:
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"runtime": _runtime_section(app),
|
||||
"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/users", tags=["admin"])
|
||||
def admin_usage_users(
|
||||
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin)
|
||||
):
|
||||
"""各用户 token 用量(分页,cost desc)。admin-only。
|
||||
|
||||
page 0-based;page_size 夹到 [1,100]。含零用量用户(全表 LEFT JOIN)。
|
||||
前端独立于 overview 轮询管理本表分页;总用量在 overview.usage.total。
|
||||
"""
|
||||
page = max(0, page)
|
||||
page_size = min(100, max(1, page_size))
|
||||
with session_scope() as s:
|
||||
return _user_usage_page(s, page, page_size)
|
||||
26
web/app.py
26
web/app.py
|
|
@ -51,10 +51,13 @@ from .auth import (
|
|||
change_password,
|
||||
create_user,
|
||||
ensure_user_row,
|
||||
get_user_role,
|
||||
make_require_admin,
|
||||
make_require_user,
|
||||
mint_token,
|
||||
resolve_user_by_email,
|
||||
)
|
||||
from .admin import register_admin_routes
|
||||
from .broker import broker
|
||||
from .sinks import WebEventSink
|
||||
from .static_files import NoCacheStaticFiles
|
||||
|
|
@ -542,6 +545,7 @@ class AdminCreateUserRequest(BaseModel):
|
|||
email: str
|
||||
password: str
|
||||
admin_token: str
|
||||
role: str = "user" # 'user' / 'admin';admin 可访问 /static/admin.html 管理后台
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
|
|
@ -559,6 +563,7 @@ def create_app() -> FastAPI:
|
|||
# fail-fast:env 缺失直接抛,不裸跑无密
|
||||
auth_cfg = AuthConfig.from_env()
|
||||
require_user = make_require_user(auth_cfg)
|
||||
require_admin = make_require_admin(auth_cfg)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -817,6 +822,15 @@ def create_app() -> FastAPI:
|
|||
def healthz():
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
@app.get("/v1/me", tags=["misc"])
|
||||
def me(user_id: UUID = Depends(require_user)):
|
||||
"""当前登录用户身份(JWT → user_id → DB role)。
|
||||
|
||||
前端 dev SPA 用 localStorage 里的 token 恢复会话时调一次,据 role=='admin'
|
||||
决定显不显"管理"入口(/static/admin.html)。role 走 DB 查,改完即时生效。
|
||||
"""
|
||||
return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"}
|
||||
|
||||
@app.get("/v1/models", tags=["misc"])
|
||||
def list_models(user_id: UUID = Depends(require_user)):
|
||||
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。
|
||||
|
|
@ -941,14 +955,16 @@ def create_app() -> FastAPI:
|
|||
if body.admin_token != auth_cfg.admin_token:
|
||||
raise HTTPException(403, "invalid admin_token")
|
||||
try:
|
||||
uid, email = create_user(email=body.email, password=body.password)
|
||||
uid, email = create_user(
|
||||
email=body.email, password=body.password, role=body.role
|
||||
)
|
||||
except UserCreateError as ex:
|
||||
if ex.code in ("invalid_email", "weak_password"):
|
||||
if ex.code in ("invalid_email", "weak_password", "invalid_role"):
|
||||
raise HTTPException(400, ex.message)
|
||||
if ex.code == "email_taken":
|
||||
raise HTTPException(409, "email already exists")
|
||||
raise HTTPException(500, f"create_user failed: {ex.message}")
|
||||
return {"user_id": str(uid), "email": email}
|
||||
return {"user_id": str(uid), "email": email, "role": body.role}
|
||||
|
||||
@app.post("/v1/auth/login_password", tags=["auth"])
|
||||
def login_password(body: PasswordLoginRequest):
|
||||
|
|
@ -970,6 +986,7 @@ def create_app() -> FastAPI:
|
|||
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
||||
"user_id": str(uid),
|
||||
"email": email,
|
||||
"role": get_user_role(uid) or "user",
|
||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||
}
|
||||
|
||||
|
|
@ -2189,4 +2206,7 @@ def create_app() -> FastAPI:
|
|||
background=BackgroundTask(tmp_path.unlink, missing_ok=True),
|
||||
)
|
||||
|
||||
# ───────────── 管理后台(admin-only)─────────────
|
||||
register_admin_routes(app, require_admin)
|
||||
|
||||
return app
|
||||
|
|
|
|||
73
web/auth.py
73
web/auth.py
|
|
@ -160,12 +160,14 @@ class UserCreateError(Exception):
|
|||
self.message = message
|
||||
|
||||
|
||||
def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tuple[UUID, str]:
|
||||
def create_user(
|
||||
email: str, password: str, user_id: Optional[UUID] = None, role: str = "user"
|
||||
) -> tuple[UUID, str]:
|
||||
"""新建用户:bcrypt(password) + INSERT users。
|
||||
|
||||
校验:email 含 @ + 非空;password ≥ 6 字符。`user_id` 不传 → 随机 UUID4。
|
||||
冲突:email UNIQUE / user_id PK 撞 → `UserCreateError('email_taken' | 'db_error')`。
|
||||
返回 `(user_id, normalized_email)`,供调用方记 log / 提示。
|
||||
校验:email 含 @ + 非空;password ≥ 6 字符;role ∈ {'user','admin'}。`user_id` 不传
|
||||
→ 随机 UUID4。冲突:email UNIQUE / user_id PK 撞 → `UserCreateError('email_taken'
|
||||
| 'db_error')`。返回 `(user_id, normalized_email)`,供调用方记 log / 提示。
|
||||
"""
|
||||
from uuid import uuid4 as _uuid4
|
||||
|
||||
|
|
@ -176,10 +178,13 @@ def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tu
|
|||
raise UserCreateError("invalid_email", f"email 不合法: {email!r}")
|
||||
if not password or len(password) < 6:
|
||||
raise UserCreateError("weak_password", "password 至少 6 字符")
|
||||
r = (role or "user").strip().lower()
|
||||
if r not in ("user", "admin"):
|
||||
raise UserCreateError("invalid_role", f"role 必须是 user / admin,收到 {role!r}")
|
||||
uid = user_id or _uuid4()
|
||||
try:
|
||||
with session_scope() as s:
|
||||
s.add(User(user_id=uid, email=e, password_hash=hash_password(password)))
|
||||
s.add(User(user_id=uid, email=e, password_hash=hash_password(password), role=r))
|
||||
except IntegrityError as ex:
|
||||
# email UNIQUE 撞最常见;user_id PK 撞理论上几乎不可能(uuid4)但也归一到 email_taken
|
||||
# 之外 — 这里只对 email 报 409 语义,其他 DB 异常归 db_error
|
||||
|
|
@ -214,6 +219,39 @@ def change_password(user_id: UUID, old_password: str, new_password: str) -> None
|
|||
user.password_hash = hash_password(new_password)
|
||||
|
||||
|
||||
def get_user_role(user_id: UUID) -> Optional[str]:
|
||||
"""查 users.role。user_id 无对应行返 None;有行返 'user' / 'admin' 等。
|
||||
|
||||
单次 SELECT,不缓存 —— 改 role 下次请求立即生效(make_require_admin 每请求查一次,
|
||||
管理员端点流量低,无需放进 JWT,也避免老 token 带过期 role)。
|
||||
"""
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
select(User.role).where(User.user_id == user_id)
|
||||
).first()
|
||||
return row[0] if row is not None else None
|
||||
|
||||
|
||||
def set_user_role(email: str, role: str) -> tuple[UUID, str]:
|
||||
"""按 email 改 users.role。返 `(user_id, normalized_email)`。
|
||||
|
||||
错误归一到 `UserCreateError.code`:
|
||||
- 'invalid_role' — role 不在 {'user', 'admin'}
|
||||
- 'user_not_found' — email 查无此人
|
||||
成功在 session_scope 内一次 commit,下次请求立即生效。
|
||||
"""
|
||||
r = (role or "").strip().lower()
|
||||
if r not in ("user", "admin"):
|
||||
raise UserCreateError("invalid_role", f"role 必须是 user / admin,收到 {role!r}")
|
||||
e = (email or "").strip().lower()
|
||||
with session_scope() as s:
|
||||
user = s.execute(select(User).where(User.email == e)).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise UserCreateError("user_not_found", f"email 查无此人: {email!r}")
|
||||
user.role = r
|
||||
return user.user_id, e
|
||||
|
||||
|
||||
def ensure_user_row(user_id: UUID) -> None:
|
||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
||||
|
||||
|
|
@ -255,16 +293,41 @@ def make_require_user(cfg: AuthConfig):
|
|||
return require_user
|
||||
|
||||
|
||||
def make_require_admin(cfg: AuthConfig):
|
||||
"""工厂:返回一个 Depends 函数,先验 JWT(同 require_user)再查 users.role=='admin'。
|
||||
|
||||
用于 /v1/admin/* 管理端点:
|
||||
require_admin = make_require_admin(cfg)
|
||||
@app.get("/v1/admin/...", )
|
||||
def route(user_id: UUID = Depends(require_admin)): ...
|
||||
非 admin → 403(token 有效但无权限);DB 查 role(不放进 JWT),改 role 立即生效。
|
||||
"""
|
||||
_require_user = make_require_user(cfg)
|
||||
|
||||
async def require_admin(
|
||||
creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
|
||||
) -> UUID:
|
||||
user_id = await _require_user(creds)
|
||||
if get_user_role(user_id) != "admin":
|
||||
raise HTTPException(403, "admin role required")
|
||||
return user_id
|
||||
|
||||
return require_admin
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthConfig",
|
||||
"UserCreateError",
|
||||
"change_password",
|
||||
"create_user",
|
||||
"ensure_user_row",
|
||||
"get_user_role",
|
||||
"hash_password",
|
||||
"make_require_admin",
|
||||
"make_require_user",
|
||||
"mint_token",
|
||||
"resolve_user_by_email",
|
||||
"set_user_role",
|
||||
"verify_password",
|
||||
"verify_token",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>zcbot 管理后台</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f7f7; --panel: #ffffff; --border: #e3e3e3; --border-soft: #ececec;
|
||||
--text: #222; --muted: #888; --accent: #c0392b; --accent-soft: #fde9e7;
|
||||
--ok: #2e7d32; --warn: #c87f0a; --danger: #c0392b;
|
||||
--r-md: 4px; --r-lg: 8px;
|
||||
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text); background: var(--bg); font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 10; background: #fff;
|
||||
border-bottom: 1px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 16px; flex-wrap: wrap;
|
||||
}
|
||||
header .logo {
|
||||
width: 24px; height: 24px; border-radius: var(--r-md);
|
||||
background: linear-gradient(135deg, var(--accent), #8e2a20);
|
||||
color: #fff; font-weight: 700; font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
header .title { font-weight: 600; font-size: 15px; }
|
||||
header .spacer { flex: 1; }
|
||||
header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); }
|
||||
header a, header button {
|
||||
font-size: 12px; color: var(--accent); text-decoration: none;
|
||||
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
|
||||
background: #fff; cursor: pointer;
|
||||
}
|
||||
header a:hover, header button:hover { background: var(--accent-soft); }
|
||||
header label.auto { color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
|
||||
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
||||
.msg { padding: 40px 16px; text-align: center; color: var(--muted); }
|
||||
.msg a { color: var(--accent); }
|
||||
|
||||
.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);
|
||||
padding: 14px 16px; margin-bottom: 14px;
|
||||
}
|
||||
.card h2 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .3px; }
|
||||
|
||||
/* 大数字 stat 块 */
|
||||
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 12px 14px; }
|
||||
.stat .k { color: var(--muted); font-size: 12px; }
|
||||
.stat .v { font-size: 22px; font-weight: 600; font-family: var(--mono); margin-top: 4px; }
|
||||
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.stat.warn { border-color: var(--warn); background: #fff8ec; }
|
||||
.stat.warn .v { color: var(--warn); }
|
||||
.stat.danger { border-color: var(--danger); background: var(--accent-soft); }
|
||||
.stat.danger .v { color: var(--danger); }
|
||||
|
||||
/* chips(状态分布) */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
font-size: 12px; padding: 3px 9px; border-radius: 999px;
|
||||
background: #f1f1f1; color: var(--text); font-family: var(--mono);
|
||||
}
|
||||
.chip b { font-weight: 700; }
|
||||
.chip.err { background: var(--accent-soft); color: var(--danger); }
|
||||
.chip.run { background: #e7f3ff; color: #1565c0; }
|
||||
.chip.ok { background: #e8f5e9; color: var(--ok); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
td.num { font-family: var(--mono); }
|
||||
td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bar-cell { position: relative; }
|
||||
.scroll-x { overflow-x: auto; }
|
||||
.empty { color: var(--muted); padding: 8px; text-align: center; }
|
||||
|
||||
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; }
|
||||
.pager button {
|
||||
font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
background: #fff; cursor: pointer; color: var(--text);
|
||||
}
|
||||
.pager button:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||
.pager button:disabled { opacity: .45; cursor: default; }
|
||||
.pager .pginfo { color: var(--muted); font-size: 12px; font-family: var(--mono); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main { padding: 10px; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
.stat .v { font-size: 18px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Z</div>
|
||||
<div class="title">zcbot 管理后台</div>
|
||||
<span class="meta" id="gen-at"></span>
|
||||
<div class="spacer"></div>
|
||||
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
|
||||
<button id="refresh">刷新</button>
|
||||
<a href="/static/dev.html">← 返回控制台</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<div class="msg" id="boot">加载中…</div>
|
||||
</main>
|
||||
<script type="module" src="/static/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -170,7 +170,7 @@
|
|||
display: block; margin-top: 10px; margin-bottom: 4px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
#admin-modal input {
|
||||
#admin-modal input, #admin-modal select {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--r-md);
|
||||
border: 1px solid var(--border); background: #fafafa;
|
||||
}
|
||||
|
|
@ -341,6 +341,11 @@
|
|||
header .title { font-weight: 600; font-size: 15px; letter-spacing: .2px; }
|
||||
header .who { color: var(--muted); font-size: 12px; font-family: var(--mono); }
|
||||
header .spacer { flex: 1; }
|
||||
#hd-admin {
|
||||
text-decoration: none; color: var(--accent); font-size: 12px;
|
||||
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
|
||||
}
|
||||
#hd-admin:hover { background: var(--accent-soft); }
|
||||
|
||||
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
|
||||
/* 左 pane:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */
|
||||
|
|
@ -1054,6 +1059,11 @@
|
|||
<input id="ad-password" type="password" autocomplete="new-password" placeholder="≥ 6 字符" />
|
||||
<label for="ad-token">管理员口令</label>
|
||||
<input id="ad-token" type="password" autocomplete="off" placeholder="$ZCBOT_ADMIN_TOKEN env 值" />
|
||||
<label for="ad-role">角色</label>
|
||||
<select id="ad-role">
|
||||
<option value="user" selected>user(普通用户)</option>
|
||||
<option value="admin">admin(可访问监控页)</option>
|
||||
</select>
|
||||
<div class="err" id="ad-err"></div>
|
||||
<div class="actions">
|
||||
<button id="ad-cancel">取消</button>
|
||||
|
|
@ -1115,6 +1125,8 @@
|
|||
</div>
|
||||
<div class="who" id="hd-who"></div>
|
||||
<div class="spacer"></div>
|
||||
<a id="hd-admin" href="/static/admin.html" target="_blank" rel="noopener"
|
||||
title="管理后台(仅管理员)" style="display:none;">管理</a>
|
||||
<button id="hd-chpw" title="修改登录密码">改密码</button>
|
||||
<button id="hd-logout">退出登录</button>
|
||||
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
|
||||
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图,
|
||||
// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。
|
||||
// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。
|
||||
// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点。
|
||||
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
|
||||
|
||||
const LS_TOKEN = "zcbot.token";
|
||||
const REFRESH_MS = 10000;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const token = () => localStorage.getItem(LS_TOKEN) || "";
|
||||
|
||||
let timer = null;
|
||||
let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询
|
||||
|
||||
// ───── 格式化 ─────
|
||||
function fmtCNY(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 0.01 && n > 0) return "¥" + n.toFixed(4);
|
||||
return "¥" + n.toFixed(2);
|
||||
}
|
||||
// 相对热力底色:value 占 max 越高,accent 底色越深(占用多 → 有色差)。
|
||||
function tint(value, max) {
|
||||
if (!max || max <= 0 || !value || value <= 0) return "";
|
||||
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 "";
|
||||
}
|
||||
|
||||
// ───── 渲染各 section ─────
|
||||
function statCard(k, v, sub, cls) {
|
||||
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
|
||||
+ `<div class="v">${v}</div>`
|
||||
+ (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
|
||||
}
|
||||
|
||||
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 `<div class="card"><h2>实时运行态</h2><div class="grid">`
|
||||
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls)
|
||||
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
|
||||
+ statCard("内存峰值", rss, "进程 RSS high-water")
|
||||
+ `</div></div>`;
|
||||
}
|
||||
|
||||
function renderTasks(t) {
|
||||
const order = ["active", "completed", "abandoned"];
|
||||
const statusChips = Object.entries(t.by_status || {})
|
||||
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
|
||||
.map(([k, n]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
|
||||
.join("") || `<span class="empty">无</span>`;
|
||||
const runChips = Object.entries(t.by_run_status || {})
|
||||
.map(([k, n]) => {
|
||||
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
|
||||
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
|
||||
}).join("") || `<span class="empty">无</span>`;
|
||||
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><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">run_status</div><div class="chips">${runChips}</div></div>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
function renderUsersAndUsage(users, usage) {
|
||||
const u = usage.total || {};
|
||||
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
|
||||
return `<div class="card"><h2>用户与用量总览(all-time)</h2><div class="grid">`
|
||||
+ statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`)
|
||||
+ statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`)
|
||||
+ statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`)
|
||||
+ statCard("输出 token", fmtTokens(u.tokens_out), "")
|
||||
+ `</div></div>`;
|
||||
}
|
||||
|
||||
function renderByDay(rows) {
|
||||
if (!rows || !rows.length) return `<div class="card"><h2>近 7 天用量</h2><div class="empty">无数据</div></div>`;
|
||||
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
|
||||
const body = rows.map(r => `<tr>`
|
||||
+ `<td>${escapeHtml(r.date)}</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_out)}</td>`
|
||||
+ `</tr>`).join("");
|
||||
return `<div class="card"><h2>近 7 天用量(按天)</h2><div class="scroll-x"><table>`
|
||||
+ `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>`
|
||||
+ `<tbody>${body}</tbody></table></div></div>`;
|
||||
}
|
||||
|
||||
function renderByModel(rows) {
|
||||
if (!rows || !rows.length) return `<div class="card"><h2>按模型</h2><div class="empty">无数据</div></div>`;
|
||||
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
|
||||
const body = rows.map(r => `<tr>`
|
||||
+ `<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">${fmtTokens(r.tokens_in)}</td>`
|
||||
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
|
||||
+ `<td class="num">${r.n_events || 0}</td>`
|
||||
+ `</tr>`).join("");
|
||||
return `<div class="card"><h2>按模型(all-time)</h2><div class="scroll-x"><table>`
|
||||
+ `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
|
||||
+ `<tbody>${body}</tbody></table></div></div>`;
|
||||
}
|
||||
|
||||
// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick
|
||||
// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。
|
||||
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;
|
||||
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 maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
|
||||
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
|
||||
const body = rows.map(r => {
|
||||
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
|
||||
return `<tr>`
|
||||
+ `<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>`
|
||||
+ `<td class="num bar-cell" style="${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">${fmtTokens(r.tokens_out)}</td>`
|
||||
+ `<td class="num">${hitRate}%</td>`
|
||||
+ `<td class="num">${r.n_events || 0}</td>`
|
||||
+ `</tr>`;
|
||||
}).join("") || `<tr><td colspan="6" class="empty">无数据</td></tr>`;
|
||||
c.innerHTML = `<div class="card"><h2>各用户用量(按成本,all-time)</h2>`
|
||||
+ `<div class="scroll-x"><table>`
|
||||
+ `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
|
||||
+ `<tbody>${body}</tbody></table></div>`
|
||||
+ `<div class="pager">`
|
||||
+ `<button id="uu-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
|
||||
+ `<span class="pginfo">${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
|
||||
+ `<button id="uu-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
|
||||
+ `</div></div>`;
|
||||
const prev = $("uu-prev"), next = $("uu-next");
|
||||
if (prev) prev.onclick = () => loadUserUsage(userPage - 1);
|
||||
if (next) next.onclick = () => loadUserUsage(userPage + 1);
|
||||
}
|
||||
|
||||
function renderStorage(st) {
|
||||
const quota = st.quota_bytes;
|
||||
const rows = st.users || [];
|
||||
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(...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);" : "")
|
||||
: tint(r.bytes_used, maxUsed);
|
||||
return `<tr>`
|
||||
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>`
|
||||
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
|
||||
+ `<td class="num">${pctTxt}</td>`
|
||||
+ `<td class="num">${r.file_count || 0}</td>`
|
||||
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
|
||||
+ `</tr>`;
|
||||
}).join("");
|
||||
return `<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>`
|
||||
+ `<tbody>${body}</tbody></table></div></div>`;
|
||||
}
|
||||
|
||||
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表,
|
||||
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。
|
||||
function ensureSkeleton() {
|
||||
if ($("metrics")) return;
|
||||
$("main").innerHTML = `<div id="metrics"></div><div id="user-usage"></div>`;
|
||||
}
|
||||
|
||||
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 || {});
|
||||
}
|
||||
|
||||
function showMsg(html) {
|
||||
$("main").innerHTML = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
|
||||
}
|
||||
|
||||
// 处理鉴权/网络错误:命中返 true(调用方据此中止)。
|
||||
function handleAuthError(r) {
|
||||
if (r.status === 401) {
|
||||
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();
|
||||
if (!t) {
|
||||
showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
|
||||
stopAuto();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
ensureSkeleton();
|
||||
renderMetrics(await r.json());
|
||||
loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码)
|
||||
} catch (e) {
|
||||
showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── 自动刷新 ─────
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
function stopAuto() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
$("refresh").onclick = refresh;
|
||||
$("auto-refresh").onchange = startAuto;
|
||||
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) stopAuto();
|
||||
else { refresh(); startAuto(); }
|
||||
});
|
||||
|
||||
refresh();
|
||||
startAuto();
|
||||
|
|
@ -111,6 +111,7 @@ function openAdminModal() {
|
|||
$("ad-email").value = "";
|
||||
$("ad-password").value = "";
|
||||
$("ad-token").value = "";
|
||||
$("ad-role").value = "user";
|
||||
$("ad-err").textContent = "";
|
||||
$("admin-modal").classList.add("show");
|
||||
$("ad-email").focus();
|
||||
|
|
@ -133,6 +134,7 @@ async function doAdminAdd() {
|
|||
const email = $("ad-email").value.trim();
|
||||
const password = $("ad-password").value;
|
||||
const admin_token = $("ad-token").value;
|
||||
const role = $("ad-role").value;
|
||||
if (!email || !password || !admin_token) {
|
||||
$("ad-err").textContent = "请填邮箱、密码、管理员口令";
|
||||
return;
|
||||
|
|
@ -144,7 +146,7 @@ async function doAdminAdd() {
|
|||
try {
|
||||
const r = await fetch("/v1/auth/admin/create_user", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, admin_token }),
|
||||
body: JSON.stringify({ email, password, admin_token, role }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
|
|
@ -158,7 +160,7 @@ async function doAdminAdd() {
|
|||
$("li-password").value = "";
|
||||
$("li-password").focus();
|
||||
$("li-err").style.color = "var(--muted)"; // 临时降级为提示色
|
||||
$("li-err").textContent = `已创建 ${data.email},请登录`;
|
||||
$("li-err").textContent = `已创建 ${data.email}(${data.role || role}),请登录`;
|
||||
setTimeout(() => { $("li-err").style.color = ""; }, 4000);
|
||||
} catch (e) {
|
||||
$("ad-err").textContent = e.message;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ export function enterApp() {
|
|||
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
|
||||
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项)
|
||||
loadStorage(); // 顶栏存储用量(后台扫描快照,非实时)
|
||||
loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html)
|
||||
}
|
||||
|
||||
// 当前用户角色:/v1/me 返 {user_id, role}。admin → 显顶栏「管理」链接。
|
||||
// 失败静默(入口是增量功能,拉不到就当普通用户,不挡主流程)。
|
||||
async function loadRole() {
|
||||
const link = $("hd-admin");
|
||||
if (!link) return;
|
||||
try {
|
||||
const me = await api("GET", "/v1/me");
|
||||
link.style.display = (me && me.role === "admin") ? "" : "none";
|
||||
} catch (e) { link.style.display = "none"; }
|
||||
}
|
||||
|
||||
// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描,
|
||||
|
|
|
|||
Loading…
Reference in New Issue