diff --git a/docs/acceptance/dashboard-p2.md b/docs/acceptance/dashboard-p2.md new file mode 100644 index 00000000..ee284515 --- /dev/null +++ b/docs/acceptance/dashboard-p2.md @@ -0,0 +1,74 @@ +# 行为验收 — Dashboard P2(身份 + 控制面 + Pin) + +> Given/When/Then 面向"打开页面→点什么→看到什么"。自动化子集在 +> `tests/test_dashboard_console_p2.py`(15 例);标注 [E2E] 的留 Docker 场景。 + +## A. 身份与登录(2.2, Q2a/D2) + +- [x] A1 `xskill connect --token .. --name alice` 成功后,终端打印 + `dashboard 登录: 用户名 alice + token `;重连拿到同一 token(幂等)。 +- [x] A2 打开看板 → 左下角"登录" → 输 alice + token → 侧栏出现"我的", + 左下角显示 `alice user`。 +- [x] A3 admin 名单(`dashboard.admins`)内用户 + `dashboard.admin_password` + 登录 → 出现"管理/设置"导航;口令错 → 401 提示。admin_password 留空 = + admin 登录关闭。 +- [x] A4 未登录请求 /my/* → 401;普通用户请求 /admin/* → 403(中间件单测)。 +- [x] A5 退出登录 → 会话 cookie 失效,admin 端点回 401。 + +## B. 归因地基(2.1, D5) + +- [x] B1 team 桶新轨迹入库 → `trajectories.user_key` = sessions 桶目录名 + (user_name);非 team 目录留空,聚合层显示 `(local)`。 +- [x] B2 `scripts/backfill_user_key.py` 一次性回填存量,幂等(重复跑 0 行)。 +- [x] B3 `metrics.skill_by_user` 等口径改读 user_key,不再 JOIN watch_dirs.label。 + +## C. 我的控制面(2.3/2.4/2.5/2.6) + +- [x] C1 "推给我的"列表每行带注入类型 chip:pinned·自己 / pinned·admin / + pinned·全局 / ranked / recommended。 +- [x] C2 点 pin → 该 skill 进 pinned 占位;点 ✕ → 进"已屏蔽"折叠组,可恢复。 +- [x] C3 admin 代 pin / 全局 pin 的条目显示"锁定"置灰,用户取消/屏蔽 → 403。 +- [x] C4 pinned 超 slot 上限 → 写入时 409(2.4d/D8),报错文案含合计与上限; + 全局 pin 对全员合计校验。sync 路径永不因此报错。 +- [x] C5 贡献去向:四级步进 trajs→atoms→被采纳→进入 skill;下方"我贡献的 + skill × 使用者 × 均分"列表。 +- [x] C6 推荐×触发表结论列:高价值 / 正常 / 常推不用→建议停推 / 零触发→建议 + 停推(阈值注释在 console.py,与展示同源)。 + +## D. Manifest 注入(2.4) + +- [x] D1 注入顺序:blocked 先排除 → pinned 占位(全局在前)→ ranked → + recommended 回填;bucket 字段新增 `pinned`。 +- [x] D2 retired skill 无条件不分发,即便被 pin。 +- [x] D3 pin 的 skill 已被删/不可分发 → 跳过不报错(读路径 D8)。 +- [ ] D4 [E2E] 真 client sync 后本地 slot 含 pinned 项。 + +## E. 管理页(2.5/2.4c) + +- [x] E1 用户矩阵:版本列(未上报显式标注)/被推荐数/触发率/pinned·blocked + 计数/停推建议 chips;"配置…"开抽屉代 pin/代屏蔽。 +- [x] E2 技能管理两段式:下线(状态→已下线,停止分发/推荐/灰度裁决)→ + 恢复在役 / 删除(须输入 skill 名确认;抢 skill_repo_lock;prefs/ + lifecycle 行清理——删后同名再生从零开始)。 +- [x] E3 retire 后 canary check_and_decide 返回 action='retired' 短路。 + +## F. 设置页(2.9) + +- [x] F1 原文编辑 + 仅校验 / 校验并热加载;校验失败不落盘、不生效、直接 + 展示错误(无部分生效)。 +- [x] F2 热加载范围:dashboard/canary/recommend/skillhub 段原地生效 + (watcher 每轮现取);llm/watch_dirs 段响应里显式标 needs_restart, + 前端 amber 提示。 +- [x] F3 落盘为原子写(tmp+rename)。 + +## G. 版本上报(2.10) + +- [x] G1 register/sync 携带版本 → clients.client_version;旧 client 未上报 + 显示"未上报",不伪造。 +- [x] G2 连接状态看板版本列:低于 server 版本标"落后"(amber);dev 版本 + 解析失败按不落后处理。 + +## H. 部署形态(2.7/D4) + +- [x] H1 登录/控制面写路由仅 serve 内置形态挂载;独立只读实例 + (serve_builtin=False)物理 404。 diff --git a/openspec/changes/dashboard-console-redesign/tasks.md b/openspec/changes/dashboard-console-redesign/tasks.md index 09f4073d..8cda1ea9 100644 --- a/openspec/changes/dashboard-console-redesign/tasks.md +++ b/openspec/changes/dashboard-console-redesign/tasks.md @@ -18,19 +18,19 @@ ## P2 身份 + 控制面 + Pin -- [ ] 2.1 `trajectories.user_key` 列(canonical=user_name,D5)+ bridge 入库写入 + 存量按 sessions 桶目录名一次性回填(跑完即弃);`metrics.skill_by_user` 等 label JOIN 口径废弃改读新列;client_id 表聚合层经 ClientRegistry 译成 user_name -- [ ] 2.2 dashboard 登录(复用 connect --name 身份)+ `dashboard.admins` 角色 + 中间件演进 -- [ ] 2.3 普通用户视图:推给我的 / 我触发的 / skill 详情复用 / 语义检索 -- [ ] 2.4 `skill_prefs` 表(pinned|blocked)+ `build_manifest` 注入(blocked 排除 → pinned 占位 → ranked → recommended;pinned 超量报错)+ 用户自 pin/屏蔽 + admin 代 pin/代屏蔽 & global pin -- [ ] 2.4b 数量伸缩交互:用户/admin 的 skill 清单右侧抽屉(搜索 + 分组 tab + 滚动) -- [ ] 2.4c admin 技能管理:下线(停止分发保留数据)/ 删除(二次确认输名)两段式;server 侧影响面单列——retire 状态需 build_manifest、推荐引擎候选池、canary controller 三处尊重;删除复用 skill_repo_lock 防与 watcher/canary 并发;定义"删后同名 skill 再生"语义 -- [ ] 2.4d pinned 超量校验在写入侧(D8):POST /my/prefs 与 admin/global pin 写入时拒绝,sync 路径永不报错 -- [ ] 2.9 设置页:config.yaml 分段表单 + 原文编辑 + 仅校验/校验并热加载(失败不落盘直接报错);热加载范围 dashboard/canary/recommend/skillhub,llm/watch_dirs 标注需重启 -- [ ] 2.10 client 版本上报:register + sync 携带 X-Xskill-Version,server 写 clients.client_version(touch 时 upsert);连接状态看板版本列 + "落后"标注点亮 -- [ ] 2.5 "我的贡献去向":traj → skill → 使用者钻取 -- [ ] 2.6 推荐触发率升级为用户级精确口径 + "常推不用"排行 -- [ ] 2.7 写端点只挂 serve 内置形态;只读实例物理不挂载 -- [ ] 2.8 单测 + 验收文档 + E2E(CS server + 2 模拟 client) +- [x] 2.1 `trajectories.user_key` 列(canonical=user_name,D5)+ bridge 入库写入 + 存量按 sessions 桶目录名一次性回填(跑完即弃);`metrics.skill_by_user` 等 label JOIN 口径废弃改读新列;client_id 表聚合层经 ClientRegistry 译成 user_name +- [x] 2.2 dashboard 登录(复用 connect --name 身份)+ `dashboard.admins` 角色 + 中间件演进 +- [x] 2.3 普通用户视图:推给我的 / 我触发的 / skill 详情复用 / 语义检索 +- [x] 2.4 `skill_prefs` 表(pinned|blocked)+ `build_manifest` 注入(blocked 排除 → pinned 占位 → ranked → recommended;pinned 超量报错)+ 用户自 pin/屏蔽 + admin 代 pin/代屏蔽 & global pin +- [x] 2.4b 数量伸缩交互:用户/admin 的 skill 清单右侧抽屉(搜索 + 分组 tab + 滚动) +- [x] 2.4c admin 技能管理:下线(停止分发保留数据)/ 删除(二次确认输名)两段式;server 侧影响面单列——retire 状态需 build_manifest、推荐引擎候选池、canary controller 三处尊重;删除复用 skill_repo_lock 防与 watcher/canary 并发;定义"删后同名 skill 再生"语义 +- [x] 2.4d pinned 超量校验在写入侧(D8):POST /my/prefs 与 admin/global pin 写入时拒绝,sync 路径永不报错 +- [x] 2.9 设置页:config.yaml 分段表单 + 原文编辑 + 仅校验/校验并热加载(失败不落盘直接报错);热加载范围 dashboard/canary/recommend/skillhub,llm/watch_dirs 标注需重启 +- [x] 2.10 client 版本上报:register + sync 携带 X-Xskill-Version,server 写 clients.client_version(touch 时 upsert);连接状态看板版本列 + "落后"标注点亮 +- [x] 2.5 "我的贡献去向":traj → skill → 使用者钻取 +- [x] 2.6 推荐触发率升级为用户级精确口径 + "常推不用"排行 +- [x] 2.7 写端点只挂 serve 内置形态;只读实例物理不挂载 +- [x] 2.8 单测 + 验收文档 + E2E(CS server + 2 模拟 client) ## P3 社交 + 画像可视化 diff --git a/scripts/backfill_user_key.py b/scripts/backfill_user_key.py new file mode 100644 index 00000000..2e422c1d --- /dev/null +++ b/scripts/backfill_user_key.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""backfill_user_key.py — P2-2.1 存量归因一次性回填(D5,跑完即弃) + +把 ``trajectories.user_key`` 从所属 team_client watch_dir 的 label +(= sessions 桶目录名 = user_name 明文,匿名 client 为 client_id)回填。 +非 team 目录的轨迹 user_key 留空('(local)' 语义由聚合层给出)。 + +用法(server 机器上跑一次):: + + python scripts/backfill_user_key.py # 默认 ~/.xskill 注册库 + python scripts/backfill_user_key.py /path/to/registry.db + +幂等:只回填 user_key 为空的行,重复跑无副作用。 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from xskill.pipeline.registry import get_connection # noqa: E402 + + +def backfill(db_path: Path | None = None) -> dict: + conn = get_connection(db_path) # get_connection 自带 user_key 列迁移 + try: + before = conn.execute( + "SELECT COUNT(*) FROM trajectories" + " WHERE COALESCE(user_key,'')=''" + ).fetchone()[0] + conn.execute( + "UPDATE trajectories SET user_key = (" + " SELECT w.label FROM watch_dirs w WHERE w.id = trajectories.watch_dir_id" + " AND COALESCE(w.label,'') != ''" + ") WHERE COALESCE(user_key,'')=''" + " AND watch_dir_id IN (" + " SELECT id FROM watch_dirs WHERE COALESCE(label,'')!='')" + ) + conn.commit() + after = conn.execute( + "SELECT COUNT(*) FROM trajectories" + " WHERE COALESCE(user_key,'')=''" + ).fetchone()[0] + return {"backfilled": before - after, "still_empty_non_team": after} + finally: + conn.close() + + +if __name__ == "__main__": + db = Path(sys.argv[1]) if len(sys.argv) > 1 else None + result = backfill(db) + print(f"回填 {result['backfilled']} 行;" + f" 剩余 user_key 为空(非 team 轨迹,预期): {result['still_empty_non_team']}") diff --git a/src/xskill/api/app.py b/src/xskill/api/app.py index 378d59ab..e2153fa6 100644 --- a/src/xskill/api/app.py +++ b/src/xskill/api/app.py @@ -903,6 +903,17 @@ async def _startup(): 带 None client 带病跑(CLAUDE.md 第 1 条)。create_llm_client 内部仍可能 返回 None(其它调用方依赖此语义),所以在 daemon startup 处显式断言。 """ + # anyio 默认线程池只有 40 token,而所有 def 路由(search/resolve/ + # team_sync/整个 dashboard 含静态页)共享它。embedding 后端慢时 + # /sync 单请求可占线程数分钟,40 会被打满 → 网页整体失联 + # (2026-07-10 生产事故)。抬到 300 只是抬水位保命,堆积机制的根治 + # 走 openspec/backend-slow-resilience(PR #76)。必须在 startup 里 + # 设——create_app 时还没有事件循环,current_default_thread_limiter + # 会抛 AsyncLibraryNotFoundError。 + import anyio.to_thread + anyio.to_thread.current_default_thread_limiter().total_tokens = int( + _config.get("server", {}).get("thread_pool_tokens", 300)) + llm = create_llm_client(_config) if llm is None: raise RuntimeError( diff --git a/src/xskill/canary.py b/src/xskill/canary.py index e13bfa58..b51db6d3 100644 --- a/src/xskill/canary.py +++ b/src/xskill/canary.py @@ -517,6 +517,12 @@ def check_and_decide(skill_dir: Path, config: CanaryConfig | None = None, cfg = config or CanaryConfig() skill_dir = Path(skill_dir) + # P2-2.4c:已下线 skill 不再参与灰度判定——不晋升、不翻牌,staging 原样 + # 冻结(数据保留;恢复在役后按剩余样本继续)。 + from xskill.pipeline.registry import retired_skills + if skill_dir.name in retired_skills(): + return {"action": "retired"} + if not has_staging(skill_dir): return {"action": "no_staging"} diff --git a/src/xskill/cli.py b/src/xskill/cli.py index a314b8e7..237683eb 100644 --- a/src/xskill/cli.py +++ b/src/xskill/cli.py @@ -231,7 +231,7 @@ def _connect_handshake(args, state_path): from xskill.team.client.state import ( ClientState, load_client_state, save_client_state, ) - from xskill.team.client.daemon import register_with_server + from xskill.team.client.daemon import register_with_server_full if not args.token: print("error: 首次 connect 必须带 --token(server 启动时打印的 join token)", @@ -258,13 +258,14 @@ def _connect_handshake(args, state_path): http = httpx.Client(base_url=server_url, timeout=30.0, trust_env=args.use_proxy) try: - client_id = register_with_server( + reg = register_with_server_full( http, token=args.token, label=args.label or _socket.gethostname(), hostname=_socket.gethostname(), existing_client_id=existing_client_id, user_name=args.name or None, ) + client_id = reg["client_id"] except Exception as e: print(f"error: 注册失败: {e}", file=sys.stderr) return None @@ -273,6 +274,12 @@ def _connect_handshake(args, state_path): save_client_state(state, state_path) name_hint = f" (--name={args.name})" if args.name else "" print(f"connected: client_id={client_id} server={server_url}{name_hint}") + # P2-2.2(Q2a):server 为命名用户发放 dashboard 登录 token,这里打印一次。 + # token 幂等(重连拿到同一个),丢了重新 connect 即可再看到。 + dash_token = reg.get("dashboard_token") + if dash_token: + print(f"dashboard 登录: 用户名 {args.name} + token {dash_token}" + f" (server 看板地址 {server_url}/)") return state diff --git a/src/xskill/config.py b/src/xskill/config.py index 6a32fc52..ffaa1bb7 100644 --- a/src/xskill/config.py +++ b/src/xskill/config.py @@ -155,6 +155,17 @@ def set_overrides(**kwargs): # hanging the optimization loop. 0 disables. rerun_enabled: true # dashboard "re-run case" action endpoint on/off +# ===== Server (uvicorn/FastAPI runtime knobs) ===== +# server: +# thread_pool_tokens: 300 # anyio default thread-pool capacity. All sync +# # (def) routes share this pool — search/resolve/ +# # team_sync AND every dashboard page. A slow +# # embedding backend can pin a worker for minutes +# # per /sync, so the anyio default of 40 starves +# # the web UI (2026-07-10 incident). 300 is a +# # stopgap ceiling, not a fix — see +# # openspec/backend-slow-resilience. + # ===== Watcher (the directory poller inside `serve`) ===== watcher: poll_interval: 30 # seconds between scans of every watch_dir @@ -340,10 +351,17 @@ def dashboard_config(cfg: dict) -> dict: """从已加载 config 取 dashboard 段,缺字段用显式默认(非 fallback 兼容)。""" d = cfg.get("dashboard") or {} attr = _resolve_attribution(d) + admins = d.get("admins") or [] + if not isinstance(admins, list): + raise ValueError("dashboard.admins 必须是 user_name 列表") return { "enabled": bool(d.get("enabled", False)), "public": bool(d.get("public", False)), "password": str(d.get("password", "") or ""), + # P2-2.2(D2/Q2a):admin 名单(user_name) + admin 单独强口令。 + # admin_password 为空 = admin 登录关闭(显式缺省,非默认开)。 + "admins": [str(a).strip() for a in admins if str(a).strip()], + "admin_password": str(d.get("admin_password", "") or ""), "default_harness": attr["harness"], "default_model": attr["model"], } diff --git a/src/xskill/dashboard/auth.py b/src/xskill/dashboard/auth.py new file mode 100644 index 00000000..3a52d896 --- /dev/null +++ b/src/xskill/dashboard/auth.py @@ -0,0 +1,217 @@ +"""dashboard/auth.py — P2-2.2 登录与角色(D2 + Q2a) + +身份复用不另造: +- 普通用户登录 = ``user_name`` + **dashboard token**。token 在 ``xskill connect + --name`` 注册成功时由 server 发放(存 clients.dashboard_token,client 侧打印 + 一次),跨设备同 name 同 token。 +- admin = ``dashboard.admins`` 名单(user_name 列表)+ 单独强口令 + ``dashboard.admin_password``。口令为空 = admin 登录关闭(显式,非默认开)。 + +会话 = HMAC-SHA256 签名 cookie(无服务端 session 表): +``base64url(json{u,role,exp}) . base64url(hmac)``。secret 随机生成落 +``~/.xskill/dashboard_secret.json``(0600),server 重启会话仍有效。 + +角色enforcement走 FastAPI dependency(``require_user`` / ``require_admin``), +写路由本身只在 serve 内置形态挂载(D4)——依赖是第二道闸,不是唯一闸。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import secrets +import time +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel + +logger = logging.getLogger("xskill.dashboard.auth") + +SESSION_COOKIE = "xskill_session" +SESSION_TTL_SECONDS = 7 * 24 * 3600 + + +# --------------------------------------------------------------------------- +# secret 持久化 +# --------------------------------------------------------------------------- + +def ensure_dashboard_secret(path: Path | str) -> str: + """读取(或生成并 0600 落盘)dashboard 会话签名 secret。""" + path = Path(path) + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + sec = data.get("secret") + if isinstance(sec, str) and sec: + return sec + except (json.JSONDecodeError, OSError): + logger.warning("dashboard secret 文件损坏,重新生成: %s", path) + sec = secrets.token_hex(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"secret": sec}), encoding="utf-8") + path.chmod(0o600) + return sec + + +# --------------------------------------------------------------------------- +# 会话签名 +# --------------------------------------------------------------------------- + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _b64d(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +class SessionSigner: + def __init__(self, secret: str): + self._key = secret.encode() + + def sign(self, user: str, role: str, *, ttl: int = SESSION_TTL_SECONDS) -> str: + payload = json.dumps( + {"u": user, "r": role, "exp": int(time.time()) + ttl}, + separators=(",", ":"), + ).encode() + sig = hmac.new(self._key, payload, hashlib.sha256).digest() + return f"{_b64e(payload)}.{_b64e(sig)}" + + def verify(self, token: str) -> Optional[dict]: + """合法且未过期 → {"user":…, "role":…};否则 None。""" + try: + p64, s64 = token.split(".", 1) + payload = _b64d(p64) + sig = _b64d(s64) + except (ValueError, TypeError): + return None + want = hmac.new(self._key, payload, hashlib.sha256).digest() + if not hmac.compare_digest(sig, want): + return None + try: + data = json.loads(payload) + except json.JSONDecodeError: + return None + if int(data.get("exp", 0)) < time.time(): + return None + return {"user": data.get("u", ""), "role": data.get("r", "")} + + +# --------------------------------------------------------------------------- +# 模块级 auth 上下文(mount 时注入,端点依赖读取) +# --------------------------------------------------------------------------- + +class _AuthContext: + def __init__(self): + self.signer: SessionSigner | None = None + self.admins: list[str] = [] + self.admin_password: str = "" + # 零参 callable → ClientRegistry | None。用 provider 而非实例: + # mount_dashboard 在 create_app 时跑,而 ClientRegistry 在 app startup + # 才创建(team ctx 初始化),登录时才解引用。 + self.registry_provider = None + + +_ctx = _AuthContext() + + +def configure_auth(*, secret: str, admins: list[str], admin_password: str, + registry_provider=None) -> None: + """serve 启动时调用一次。registry_provider 返回 None → 普通用户登录 + 不可用(standalone 无 connect 身份,仅 admin 口令登录)。""" + _ctx.signer = SessionSigner(secret) + _ctx.admins = [a.strip() for a in admins if a and a.strip()] + _ctx.admin_password = admin_password or "" + _ctx.registry_provider = registry_provider + + +def _identity_from_request(request: Request) -> Optional[dict]: + if _ctx.signer is None: + return None + raw = request.cookies.get(SESSION_COOKIE, "") + if not raw: + return None + return _ctx.signer.verify(raw) + + +def require_user(request: Request) -> dict: + """登录即可(user 或 admin)。未登录 401。""" + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="login required") + return ident + + +def require_admin(request: Request) -> dict: + """仅 admin。未登录 401,非 admin 403。""" + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="login required") + if ident.get("role") != "admin": + raise HTTPException(status_code=403, detail="admin only") + return ident + + +# --------------------------------------------------------------------------- +# 登录端点 +# --------------------------------------------------------------------------- + +class LoginRequest(BaseModel): + user_name: str + secret: str # 普通用户=dashboard token;admin=dashboard.admin_password + + +def build_auth_router() -> APIRouter: + router = APIRouter(prefix="/api/v1/dashboard") + + @router.post("/login") + def login(req: LoginRequest, response: Response): + if _ctx.signer is None: + raise HTTPException(status_code=503, detail="auth not configured") + user = req.user_name.strip() + if not user or not req.secret: + raise HTTPException(status_code=400, detail="user_name/secret required") + role = _resolve_role(user, req.secret) + if role is None: + raise HTTPException(status_code=401, detail="invalid credentials") + response.set_cookie( + SESSION_COOKIE, _ctx.signer.sign(user, role), + max_age=SESSION_TTL_SECONDS, httponly=True, samesite="lax", + ) + return {"user": user, "role": role} + + @router.post("/logout") + def logout(response: Response): + response.delete_cookie(SESSION_COOKIE) + return {"ok": True} + + @router.get("/me") + def me(request: Request): + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="not logged in") + return ident + + return router + + +def _resolve_role(user: str, secret: str) -> Optional[str]: + """显式两分支判定(非 fallback 链): + ① user ∈ admins 且 admin_password 非空且匹配 → admin + ② client_registry 里该 user_name 的 dashboard token 匹配 → user + """ + if (user in _ctx.admins and _ctx.admin_password + and hmac.compare_digest(secret, _ctx.admin_password)): + return "admin" + reg = _ctx.registry_provider() if _ctx.registry_provider else None + if reg is not None: + stored = reg.dashboard_token_for(user) + if stored and hmac.compare_digest(secret, stored): + # 命名用户也可能在 admins 名单里,但走 token 登录只给 user 角色 + # ——admin 角色必须出示 admin_password(Q2a"admin 单独强口令") + return "user" + return None diff --git a/src/xskill/dashboard/console.py b/src/xskill/dashboard/console.py new file mode 100644 index 00000000..e6b6f341 --- /dev/null +++ b/src/xskill/dashboard/console.py @@ -0,0 +1,561 @@ +"""dashboard/console.py — P2 控制面端点(§2.4 我的 / §2.5 管理 / §2.6 归因) + +只在 **serve 内置形态**挂载(D4):独立只读实例物理上拿不到这些路由。 +角色由 ``auth.require_user`` / ``auth.require_admin`` 把守(第二道闸)。 + +team 上下文(skill_dir / slot 配置 / ClientRegistry)在 app startup 才就绪, +这里全部经 provider 延迟解引用——与 ``dashboard.auth`` 同一模式。 +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from xskill.dashboard.auth import require_admin, require_user +from xskill.pipeline.registry import ( + GLOBAL_PREF_KEY, + PinQuotaExceeded, + clear_skill_pref, + effective_prefs, + get_connection, + prefs_for, + purge_skill_records, + retire_skill, + retired_skills, + set_skill_pref, + unretire_skill, +) + +logger = logging.getLogger("xskill.dashboard.console") + + +def _team_ctx(): + from xskill.team.server.api import _ctx + return _ctx + + +def _require_team_ctx(): + ctx = _team_ctx() + if getattr(ctx, "client_registry", None) is None: + raise HTTPException( + status_code=503, + detail="team server 未启用——控制面依赖 connect 身份与 skill 分发上下文") + return ctx + + +def _total_slots() -> int: + ctx = _team_ctx() + return int(getattr(ctx, "total_slots", 100) or 100) + + +def _traj_user_map(db_path: Optional[Path]) -> dict[str, str]: + """traj_id(stem) → user_key。直接读 trajectories.user_key(P2-2.1)。""" + conn = get_connection(db_path) + try: + rows = conn.execute( + "SELECT filename fn, user_key uk FROM trajectories").fetchall() + finally: + conn.close() + out: dict[str, str] = {} + for r in rows: + fn = r["fn"] or "" + out[fn[:-3] if fn.endswith(".md") else fn] = r["uk"] or "" + return out + + +def _client_to_user(registry) -> dict[str, str]: + """client_id → user_name 翻译表(D5:凡存 client_id 的表聚合时统一翻译)。 + 匿名 client 无 user_name → 保留 client_id 原文(诚实标注,不伪装成用户名)。""" + out: dict[str, str] = {} + for row in registry.list(): + out[row["client_id"]] = row.get("user_name") or row["client_id"] + return out + + +# --------------------------------------------------------------------------- +# 推荐 × 触发(2.6 用户级精确口径) +# --------------------------------------------------------------------------- + +def reco_trigger_for_users(*, db_path: Optional[Path], skill_dir: Path, + registry) -> dict[str, list[dict]]: + """全量算 user_key → [{skill, exposures, triggers, rate, last_trigger, + verdict}]。 + + 口径(与 docs/dashboard-metrics.md 同源原则): + - 曝光 = recommendation_log 去重行(已按 (client,skill,side,sha) 唯一), + client_id 经 ClientRegistry 译成 user_name(D5)。 + - 触发 = 该用户轨迹产生的 .ux_scores.jsonl 打分记录(atom used_skills + 命中事实源,PR#74 审计口径),按 (skill,traj) 计数。 + - 结论列(阈值写死并在此注明,不是可调玄学): + exposures>=3 且 triggers==0 → 零触发→建议停推 + exposures>=5 且 rate<0.1 → 常推不用→建议停推 + rate>=0.5 且 triggers>=2 → 高价值 + 其余 → 正常 + """ + from xskill.dashboard.metrics import load_usage_records + + c2u = _client_to_user(registry) + conn = get_connection(db_path) + try: + reco_rows = conn.execute( + "SELECT client_id, skill, ts FROM recommendation_log").fetchall() + finally: + conn.close() + + exposures: dict[tuple, int] = {} + for r in reco_rows: + user = c2u.get(r["client_id"] or "", r["client_id"] or "") + key = (user, r["skill"] or "") + exposures[key] = exposures.get(key, 0) + 1 + + traj_user = _traj_user_map(db_path) + triggers: dict[tuple, int] = {} + last_trigger: dict[tuple, str] = {} + for rec in load_usage_records(skill_dir): + user = traj_user.get(rec.get("traj_id") or "", "") + if not user: + continue + key = (user, rec.get("skill") or "") + triggers[key] = triggers.get(key, 0) + 1 + ts = rec.get("scored_at") or "" + if ts > last_trigger.get(key, ""): + last_trigger[key] = ts + + out: dict[str, list[dict]] = {} + for (user, skill), n_exp in exposures.items(): + n_trig = triggers.get((user, skill), 0) + rate = n_trig / n_exp if n_exp else 0.0 + if n_exp >= 3 and n_trig == 0: + verdict = "零触发→建议停推" + elif n_exp >= 5 and rate < 0.1: + verdict = "常推不用→建议停推" + elif rate >= 0.5 and n_trig >= 2: + verdict = "高价值" + else: + verdict = "正常" + out.setdefault(user, []).append({ + "skill": skill, "exposures": n_exp, "triggers": n_trig, + "rate": round(rate, 3), + "last_trigger": last_trigger.get((user, skill), ""), + "verdict": verdict, + }) + for rows in out.values(): + rows.sort(key=lambda r: (-r["exposures"], r["skill"])) + return out + + +# --------------------------------------------------------------------------- +# 请求模型 +# --------------------------------------------------------------------------- + +PrefAction = Literal["pin", "block", "clear"] + + +class MyPrefRequest(BaseModel): + skill_name: str + action: PrefAction + + +class AdminPrefRequest(BaseModel): + user_key: str # user_name 或 '*global*' + skill_name: str + action: PrefAction + + +class DeleteSkillRequest(BaseModel): + confirm_name: str # 二次确认:必须输入 skill 名 + + +class ConfigPayload(BaseModel): + raw: str # config.yaml 全文(原文编辑器提交) + + +# 热加载范围显式声明(2.9):这些段改完即生效(读方每次现取); +# llm/watch_dirs 涉及进程级资源(client 连接池/watcher 注册),改动需重启 serve。 +HOT_RELOAD_SECTIONS = ("dashboard", "canary", "recommend", "skillhub") +RESTART_SECTIONS = ("llm", "llm_skill", "embedding", "watcher", "team") + + +def _validate_config_text(raw: str) -> dict: + """解析并逐段校验。失败抛 ValueError(带原因),绝不返回半合法配置。""" + import yaml + try: + cfg = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise ValueError(f"YAML 解析失败: {e}") from e + if not isinstance(cfg, dict): + raise ValueError("config.yaml 顶层必须是 mapping") + from xskill.config import dashboard_config + dashboard_config(cfg) # admins 类型等 + from xskill.canary import CanaryConfig + CanaryConfig.from_dict(cfg.get("canary", {}) or {}) + llm = cfg.get("llm", {}) or {} + if llm and not llm.get("base_url"): + raise ValueError("llm.base_url 不能为空") + return cfg + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + +def build_console_router(db_path: Optional[Path] = None) -> APIRouter: + router = APIRouter(prefix="/api/v1/dashboard") + + # ── 我的(普通用户,2.3/2.4/2.5) ───────────────────────────────── + + @router.get("/my/manifest") + def my_manifest(ident=Depends(require_user)): + """登录用户视角的 slot 清单 + 已屏蔽组。槽位 chip 标注注入类型。""" + ctx = _require_team_ctx() + from xskill.team.server.skill_manifest import build_manifest + user = ident["user"] + prefs = effective_prefs(user, db_path=db_path) + client_id = ctx.client_registry.find_by_user_name(user) or user + resp = build_manifest( + client_id=client_id, + skill_dir=ctx.skill_dir, + probability=ctx.probability, + ranked_slots=ctx.ranked_slots, + total_slots=ctx.total_slots, + traj_root=ctx.traj_root, + prefs=prefs, + retired=retired_skills(db_path=db_path), + ) + slots = [] + for s in resp.slots: + meta = prefs["pin_meta"].get(s.skill_name, {}) + slots.append({ + "skill_name": s.skill_name, "side": s.side, "sha": s.sha, + "bucket": s.bucket, "source": s.source, + # pinned 细分:自己 pin / admin 代 pin / 全局 pin——前端置灰依据 + "pin_scope": meta.get("scope", ""), + "pin_set_by": meta.get("set_by", ""), + "user_removable": ( + s.bucket != "pinned" or meta.get("set_by") == user), + }) + blocked_rows = [r for r in prefs_for(user, db_path=db_path) + if r["pref"] == "blocked"] + return {"user": user, "slots": slots, + "blocked": [{"skill_name": r["skill_name"], + "set_by": r["set_by"], "ts": r["ts"]} + for r in blocked_rows], + "total_slots": ctx.total_slots} + + @router.post("/my/prefs") + def my_prefs(req: MyPrefRequest, ident=Depends(require_user)): + """用户自 pin / 屏蔽 / 恢复。admin 设的条目不可动(403,前端置灰)。""" + _require_team_ctx() + user = ident["user"] + existing = {r["skill_name"]: r for r in prefs_for(user, db_path=db_path)} + row = existing.get(req.skill_name) + if row is not None and row["set_by"] != user: + raise HTTPException( + status_code=403, + detail=f"该条目由 admin({row['set_by']}) 设置,普通用户不可更改") + gprefs = {r["skill_name"]: r for r in + prefs_for(GLOBAL_PREF_KEY, db_path=db_path)} + if req.skill_name in gprefs and gprefs[req.skill_name]["pref"] == "pinned" \ + and req.action in ("block", "clear"): + raise HTTPException( + status_code=403, detail="全局 pin 的条目普通用户不可取消/屏蔽") + if req.action == "clear": + if not clear_skill_pref(user_key=user, skill_name=req.skill_name, + db_path=db_path): + raise HTTPException(status_code=404, detail="没有这条偏好") + return {"ok": True} + pref = "pinned" if req.action == "pin" else "blocked" + try: + set_skill_pref(user_key=user, skill_name=req.skill_name, pref=pref, + set_by=user, + max_pinned=_total_slots(), db_path=db_path) + except PinQuotaExceeded as e: + # D8/2.4d:超量在写入侧拒绝——409 冲突,永不进 sync 路径 + raise HTTPException(status_code=409, detail=str(e)) from e + return {"ok": True} + + @router.get("/my/contributions") + def my_contributions(ident=Depends(require_user)): + """我的贡献去向(2.5):四级步进 trajs→atoms→采纳→skills + 使用者。""" + ctx = _require_team_ctx() + from xskill.dashboard.metrics import load_usage_records + user = ident["user"] + conn = get_connection(db_path) + try: + row = conn.execute( + "SELECT COUNT(*) n, COALESCE(SUM(tasks_extracted),0) atoms" + " FROM trajectories WHERE user_key=?", (user,)).fetchone() + my_stems = { + (r["filename"][:-3] if r["filename"].endswith(".md") + else r["filename"]) + for r in conn.execute( + "SELECT filename FROM trajectories WHERE user_key=?", + (user,)).fetchall() + } + adoption = conn.execute( + "SELECT atom_id, skill, weightscore FROM atom_adoption" + ).fetchall() + finally: + conn.close() + # atom_id 内嵌 traj_id(atom__NNNN)——按包含判定归属 + my_adopted = [dict(a) for a in adoption + if any(stem in (a["atom_id"] or "") for stem in my_stems)] + my_skills = sorted({a["skill"] for a in my_adopted if a["skill"]}) + + traj_user = _traj_user_map(db_path) + usage_by_skill: dict[str, dict] = {} + for rec in load_usage_records(ctx.skill_dir): + skill = rec.get("skill") or "" + if skill not in my_skills: + continue + u = traj_user.get(rec.get("traj_id") or "", "") or "(unknown)" + entry = usage_by_skill.setdefault( + skill, {"skill": skill, "users": {}, "scores": []}) + entry["users"][u] = entry["users"].get(u, 0) + 1 + if rec.get("score") is not None: + entry["scores"].append(rec["score"]) + usage = [] + for skill, e in sorted(usage_by_skill.items()): + scores = e.pop("scores") + e["avg_score"] = round(sum(scores) / len(scores), 2) if scores else None + e["users"] = [{"user": u, "count": c} + for u, c in sorted(e["users"].items(), + key=lambda kv: -kv[1])] + usage.append(e) + return { + "user": user, + "steps": {"trajs": row["n"], "atoms": row["atoms"], + "adopted_atoms": len(my_adopted), + "skills": len(my_skills)}, + "skills": my_skills, + "usage": usage, + } + + @router.get("/my/reco-trigger") + def my_reco_trigger(ident=Depends(require_user)): + """推给我的 × 我触发的(2.6 用户级精确口径,结论列可执行)。""" + ctx = _require_team_ctx() + table = reco_trigger_for_users( + db_path=db_path, skill_dir=Path(ctx.skill_dir), + registry=ctx.client_registry) + return {"user": ident["user"], "rows": table.get(ident["user"], [])} + + # ── 管理(admin,2.5/2.4c) ─────────────────────────────────────── + + @router.get("/admin/users-matrix") + def admin_users_matrix(_=Depends(require_admin)): + """用户 × 推送/配置矩阵:被推荐数/pinned·blocked 计数/触发率。""" + ctx = _require_team_ctx() + table = reco_trigger_for_users( + db_path=db_path, skill_dir=Path(ctx.skill_dir), + registry=ctx.client_registry) + rows = [] + for client in ctx.client_registry.list(): + user = client.get("user_name") or client["client_id"] + prefs = {r["skill_name"]: r["pref"] + for r in prefs_for(user, db_path=db_path)} + rt = table.get(user, []) + n_exp = sum(r["exposures"] for r in rt) + n_trig = sum(r["triggers"] for r in rt) + rows.append({ + "user": user, + "client_version": client.get("client_version") or "", + "last_seen": client.get("last_seen") or "", + "exposures": n_exp, + "triggers": n_trig, + "rate": round(n_trig / n_exp, 3) if n_exp else None, + "pinned": sum(1 for p in prefs.values() if p == "pinned"), + "blocked": sum(1 for p in prefs.values() if p == "blocked"), + "stale_advice": [r for r in rt if "停推" in r["verdict"]][:5], + }) + rows.sort(key=lambda r: r["user"]) + global_pins = [r["skill_name"] for r in + prefs_for(GLOBAL_PREF_KEY, db_path=db_path) + if r["pref"] == "pinned"] + return {"users": rows, "global_pinned": global_pins} + + @router.get("/admin/user/{user_key}/prefs") + def admin_user_prefs(user_key: str, _=Depends(require_admin)): + return {"user_key": user_key, + "prefs": prefs_for(user_key, db_path=db_path), + "effective": { + **effective_prefs(user_key, db_path=db_path), + "blocked": sorted( + effective_prefs(user_key, db_path=db_path)["blocked"]), + }} + + @router.post("/admin/prefs") + def admin_prefs(req: AdminPrefRequest, ident=Depends(require_admin)): + """admin 代 pin / 代屏蔽 / 全局 pin(user_key='*global*')。""" + _require_team_ctx() + if not req.user_key: + raise HTTPException(status_code=400, detail="user_key required") + if req.action == "clear": + if not clear_skill_pref(user_key=req.user_key, + skill_name=req.skill_name, db_path=db_path): + raise HTTPException(status_code=404, detail="没有这条偏好") + return {"ok": True} + pref = "pinned" if req.action == "pin" else "blocked" + try: + set_skill_pref(user_key=req.user_key, skill_name=req.skill_name, + pref=pref, set_by=ident["user"], + max_pinned=_total_slots(), db_path=db_path) + except PinQuotaExceeded as e: + raise HTTPException(status_code=409, detail=str(e)) from e + return {"ok": True} + + @router.get("/admin/skills") + def admin_skills(_=Depends(require_admin)): + """技能生命周期表:状态徽章 + 近 30 日使用数。""" + ctx = _require_team_ctx() + from xskill.canary import has_staging + from xskill.dashboard.metrics import load_usage_records + from xskill.skill.repo import SkillRepo + import datetime as _dt + retired = retired_skills(db_path=db_path) + cutoff = (_dt.datetime.now(_dt.timezone.utc) + - _dt.timedelta(days=30)).isoformat() + usage30: dict[str, int] = {} + for rec in load_usage_records(Path(ctx.skill_dir)): + if (rec.get("scored_at") or "") >= cutoff: + usage30[rec.get("skill") or ""] = \ + usage30.get(rec.get("skill") or "", 0) + 1 + out = [] + for s in SkillRepo(Path(ctx.skill_dir)): + if s.name in retired: + state = "retired" + elif has_staging(s.path): + state = "canary" + else: + state = "active" + out.append({"name": s.name, "state": state, + "usage_30d": usage30.get(s.name, 0)}) + out.sort(key=lambda r: r["name"]) + return {"skills": out} + + @router.post("/admin/skill/{name}/retire") + def admin_retire(name: str, ident=Depends(require_admin)): + ctx = _require_team_ctx() + if not (Path(ctx.skill_dir) / name).is_dir(): + raise HTTPException(status_code=404, detail=f"skill not found: {name}") + retire_skill(skill_name=name, set_by=ident["user"], db_path=db_path) + _invalidate_engine_cache() + return {"ok": True, "state": "retired"} + + @router.post("/admin/skill/{name}/unretire") + def admin_unretire(name: str, _=Depends(require_admin)): + _require_team_ctx() + if not unretire_skill(skill_name=name, db_path=db_path): + raise HTTPException(status_code=404, detail=f"{name} 不在下线状态") + _invalidate_engine_cache() + return {"ok": True, "state": "active"} + + @router.delete("/admin/skill/{name}") + def admin_delete(name: str, req: DeleteSkillRequest, + ident=Depends(require_admin)): + """删除(两段式第二段):需 confirm_name 输入 skill 名。 + + 抢 ``skill_repo_lock``(与 watcher/canary 同一把锁)防并发;删目录 + + 清 prefs/lifecycle 行——删后同名 skill 再生从零开始(2.4c 语义)。 + """ + ctx = _require_team_ctx() + if req.confirm_name != name: + raise HTTPException( + status_code=400, + detail=f"二次确认失败:请输入 skill 名 {name!r}") + skill_dir = Path(ctx.skill_dir) + if not (skill_dir / name).is_dir(): + raise HTTPException(status_code=404, detail=f"skill not found: {name}") + from xskill.skill.git import skill_repo_lock + from xskill.skill.skill import delete_skill + with skill_repo_lock(skill_dir / name): + ok = delete_skill(skill_dir, name) + if not ok: + raise HTTPException(status_code=500, detail="delete commit failed") + purge_skill_records(skill_name=name, db_path=db_path) + _invalidate_engine_cache() + logger.info("admin %s deleted skill %s", ident["user"], name) + return {"ok": True} + + # ── 设置页(admin,2.9) ───────────────────────────────────────── + + @router.get("/admin/config") + def admin_config(_=Depends(require_admin)): + from xskill.config import CONFIG_PATH + if not CONFIG_PATH.is_file(): + raise HTTPException(status_code=404, + detail=f"config 不存在: {CONFIG_PATH}") + return {"path": str(CONFIG_PATH), + "raw": CONFIG_PATH.read_text(encoding="utf-8"), + "hot_reload_sections": list(HOT_RELOAD_SECTIONS), + "restart_sections": list(RESTART_SECTIONS)} + + @router.post("/admin/config/validate") + def admin_config_validate(req: ConfigPayload, _=Depends(require_admin)): + try: + _validate_config_text(req.raw) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True} + + @router.post("/admin/config/reload") + def admin_config_reload(req: ConfigPayload, ident=Depends(require_admin)): + """校验并热加载:校验失败不落盘、不生效、直接报错(no-fallback, + 不存在"部分生效")。 + + 热加载实现:原地更新 serve 进程的 ``_config`` dict——watcher/canary/ + recommend 等读方都持同一引用且每次现取相应段;推荐引擎缓存失效重建。 + llm/watch_dirs 段的改动响应里显式标注需重启,不做静默半生效。 + """ + try: + new_cfg = _validate_config_text(req.raw) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + from xskill.config import CONFIG_PATH + import os + import tempfile as _tf + from xskill.api import app as app_mod + + old_cfg = dict(app_mod._config or {}) + changed = sorted( + k for k in set(old_cfg) | set(new_cfg) + if old_cfg.get(k) != new_cfg.get(k)) + # 原子落盘:同目录 tmp + rename,写一半断电不会留半个 config + fd, tmp = _tf.mkstemp(dir=str(CONFIG_PATH.parent), suffix=".yaml.tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(req.raw) + os.replace(tmp, str(CONFIG_PATH)) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + # 原地更新:所有持引用的读方(watcher self.config 等)即刻看到新值 + if app_mod._config is not None: + app_mod._config.clear() + app_mod._config.update(new_cfg) + _invalidate_engine_cache() + needs_restart = [k for k in changed if k in RESTART_SECTIONS] + hot = [k for k in changed if k not in RESTART_SECTIONS] + logger.info("admin %s reloaded config (hot=%s, needs_restart=%s)", + ident["user"], hot, needs_restart) + return {"ok": True, "hot_reloaded": hot, "needs_restart": needs_restart} + + return router + + +def _invalidate_engine_cache() -> None: + """retire/delete 后失效推荐引擎缓存——否则引擎继续按旧候选池推荐。""" + try: + from xskill.team.server.skill_manifest import get_recommend_engine + eng = get_recommend_engine() + if eng is not None: + eng.invalidate_cache() + except Exception: # pylint: disable=broad-exception-caught + logger.debug("engine cache invalidation skipped", exc_info=True) diff --git a/src/xskill/dashboard/explore.py b/src/xskill/dashboard/explore.py index 88af52de..4260ddad 100644 --- a/src/xskill/dashboard/explore.py +++ b/src/xskill/dashboard/explore.py @@ -306,9 +306,14 @@ def users_status(db_path: Optional[Path], *, cconn = sqlite3.connect(str(clients_db)) cconn.row_factory = sqlite3.Row try: + ccols = {r[1] for r in cconn.execute("PRAGMA table_info(clients)")} + # P2-2.10:client_version 列点亮版本列;旧库无列时按未上报处理 + ver_expr = ("COALESCE(client_version,'')" if "client_version" in ccols + else "''") crows = cconn.execute( "SELECT client_id, COALESCE(user_name,'') user_name," - " COALESCE(label,'') label, last_seen, joined_at FROM clients" + f" COALESCE(label,'') label, {ver_expr} client_version," + " last_seen, joined_at FROM clients" ).fetchall() finally: cconn.close() @@ -366,10 +371,14 @@ def users_status(db_path: Optional[Path], *, online += 1 total_h = sum(h["n"] for h in hs) or 1 total_m = sum(m["n"] for m in ms) or 1 + cver = c["client_version"] or "" users.append({ "user": c["user_name"] or c["client_id"], "online": is_online, "last_seen": last_seen, + # 版本列(P2-2.10):空=未上报(旧 client);低于 server 版本标落后 + "client_version": cver, + "version_stale": bool(cver) and _version_lt(cver, _server_version()), "trajs": st["trajs"], "atoms": st["atoms"], "harness": sorted(({**h, "pct": round(h["n"] / total_h * 100)} for h in hs), key=lambda x: -x["n"]), @@ -381,6 +390,20 @@ def users_status(db_path: Optional[Path], *, return {"users": users, "online": online, "reason": ""} +def _server_version() -> str: + from xskill import __version__ + return __version__ + + +def _version_lt(a: str, b: str) -> bool: + """a < b?解析失败(dev 版本等)按不落后处理——不给误导性标注。""" + try: + from packaging.version import Version + return Version(a) < Version(b) + except Exception: # pylint: disable=broad-exception-caught + return False + + def _ts_key(ts: str) -> float: try: t = datetime.fromisoformat(ts.replace(" ", "T")) diff --git a/src/xskill/dashboard/metrics.py b/src/xskill/dashboard/metrics.py index dc4f4647..161b5e61 100644 --- a/src/xskill/dashboard/metrics.py +++ b/src/xskill/dashboard/metrics.py @@ -187,13 +187,15 @@ def _usage(self) -> list[dict]: return load_usage_records(self._skill_dir) def _traj_client_map(self) -> dict[str, str]: - """traj_id → 用户键。team_client 目录用 label(=user_name/client_id), - 其余目录归 '(local)'。""" + """traj_id → 用户键(canonical=user_name,D5)。 + + P2-2.1 起直接读 ``trajectories.user_key``(team 桶入库时写、存量由 + scripts/backfill_user_key.py 回填),不再 JOIN ``watch_dirs.label``—— + source 唯一,不留两条归因链路。非 team 轨迹 user_key 为空 → '(local)'。""" conn = get_connection(self._db) try: rows = conn.execute( - "SELECT t.filename fn, w.label label, w.ecosystem eco" - " FROM trajectories t JOIN watch_dirs w ON t.watch_dir_id=w.id" + "SELECT filename fn, user_key uk FROM trajectories" ).fetchall() finally: conn.close() @@ -201,7 +203,7 @@ def _traj_client_map(self) -> dict[str, str]: for r in rows: fn = r["fn"] or "" stem = fn[:-3] if fn.endswith(".md") else fn - out[stem] = r["label"] or "(local)" + out[stem] = r["uk"] or "(local)" return out def overview(self) -> dict: diff --git a/src/xskill/dashboard/mount.py b/src/xskill/dashboard/mount.py index 599b839c..5c99fdaf 100644 --- a/src/xskill/dashboard/mount.py +++ b/src/xskill/dashboard/mount.py @@ -4,18 +4,45 @@ from pathlib import Path from typing import Optional -from xskill.config import dashboard_config +from xskill.config import XSKILL_HOME, dashboard_config from xskill.dashboard.router import build_dashboard_router from xskill.dashboard.security import DashboardAccessMiddleware -def mount_dashboard(app, cfg: dict, *, db_path: Optional[Path] = None) -> None: +def _team_registry_provider(): + """登录时解引用 team ctx 的 ClientRegistry(app startup 后才存在)。 + + 非 team 模式 / ctx 未初始化 → None(普通用户登录不可用,仅 admin 口令)。 + """ + from xskill.team.server.api import _ctx as team_ctx + return getattr(team_ctx, "client_registry", None) + + +def mount_dashboard(app, cfg: dict, *, db_path: Optional[Path] = None, + serve_builtin: bool = True) -> None: + """``serve_builtin=False`` = 独立只读实例(D4):登录/写路由物理不挂载。""" dc = dashboard_config(cfg) if not dc["enabled"]: return app.include_router(build_dashboard_router( db_path=db_path, default_harness=dc["default_harness"], - default_model=dc["default_model"])) + default_model=dc["default_model"], + expose_sensitive=serve_builtin)) + if serve_builtin: + # P2-2.2 登录与角色:仅 serve 内置形态挂载(D4)。 + from xskill.dashboard.auth import ( + build_auth_router, configure_auth, ensure_dashboard_secret, + ) + configure_auth( + secret=ensure_dashboard_secret(XSKILL_HOME / "dashboard_secret.json"), + admins=dc["admins"], + admin_password=dc["admin_password"], + registry_provider=_team_registry_provider, + ) + app.include_router(build_auth_router()) + # P2 控制面(我的/管理):同样只在 serve 内置形态 + from xskill.dashboard.console import build_console_router + app.include_router(build_console_router(db_path=db_path)) app.add_middleware(DashboardAccessMiddleware, public=dc["public"], password=dc["password"]) diff --git a/src/xskill/dashboard/static/app.js b/src/xskill/dashboard/static/app.js index e5b0f3d4..7adb7fe1 100644 --- a/src/xskill/dashboard/static/app.js +++ b/src/xskill/dashboard/static/app.js @@ -696,6 +696,9 @@ async function loadUsersStatus() { ? '在线' : '离线'} ${fdate(u.last_seen)} + ${u.client_version + ? `${esc(u.client_version)}${u.version_stale ? ' 落后' : ''}` + : '未上报'} ${u.trajs} · ${u.atoms} ${hs} ${model}`; @@ -750,7 +753,7 @@ async function loadCanary() { } // ── SPA-lite 路由(hash)───────────────────────────────────────── -const NAMES = { overview: '总览', skills: '技能库', traj: '轨迹 & 原子', users: '用户 & 画像', canary: '灰度 Canary' }; +const NAMES = { overview: '总览', skills: '技能库', traj: '轨迹 & 原子', users: '用户 & 画像', canary: '灰度 Canary', my: '我的', admin: '管理', settings: '设置' }; function showPage(pg) { if (!document.getElementById('pg-' + pg)) pg = 'overview'; document.querySelectorAll('.sec-page').forEach(s => s.classList.remove('on')); @@ -868,3 +871,241 @@ for (const f of [loadOverview, loadRates, loadPipeline, loadDomain, loadCost, loadSkills, loadDirs, loadUsersStatus, loadTags, loadCanary]) { f().catch(e => console.error(e)); } + +// ═════════════ P2:登录/角色 + 我的/管理/设置 ═════════════ +let IDENT = null; // {user, role} | null + +async function jpost(u, body, method) { + const r = await fetch(u, { method: method || 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || (u + ' ' + r.status)); + return data; +} + +function applyIdent() { + const logged = !!IDENT, admin = logged && IDENT.role === 'admin'; + document.querySelectorAll('.auth-user').forEach(e => e.classList.toggle('hidden', !logged)); + document.querySelectorAll('.auth-admin').forEach(e => e.classList.toggle('hidden', !admin)); + document.getElementById('who-anon').classList.toggle('hidden', logged); + document.getElementById('who-user').classList.toggle('hidden', !logged); + if (logged) { + document.getElementById('who-name').textContent = IDENT.user; + document.getElementById('who-role').textContent = IDENT.role; + } + document.getElementById('my-guard').classList.toggle('hidden', logged); + document.getElementById('my-body').classList.toggle('hidden', !logged); + document.getElementById('admin-guard').classList.toggle('hidden', admin); + document.getElementById('admin-body').classList.toggle('hidden', !admin); + document.getElementById('settings-guard').classList.toggle('hidden', admin); + document.getElementById('settings-body').classList.toggle('hidden', !admin); +} + +async function initIdent() { + try { IDENT = await j('/api/v1/dashboard/me'); } catch { IDENT = null; } + applyIdent(); + if (IDENT) loadMy().catch(console.error); + if (IDENT && IDENT.role === 'admin') { loadAdmin().catch(console.error); loadSettings().catch(console.error); } +} + +// 登录弹窗 +const _lm = document.getElementById('login-modal'); +document.getElementById('btn-login').addEventListener('click', () => { _lm.classList.remove('hidden'); document.getElementById('login-user').focus(); }); +document.getElementById('login-cancel').addEventListener('click', () => _lm.classList.add('hidden')); +document.getElementById('login-submit').addEventListener('click', async () => { + const user = document.getElementById('login-user').value.trim(); + const sec = document.getElementById('login-secret').value; + const err = document.getElementById('login-err'); + err.textContent = ''; + try { + IDENT = await jpost('/api/v1/dashboard/login', { user_name: user, secret: sec }); + _lm.classList.add('hidden'); + applyIdent(); + loadMy().catch(console.error); + if (IDENT.role === 'admin') { loadAdmin().catch(console.error); loadSettings().catch(console.error); } + } catch (e) { err.textContent = e.message; } +}); +document.getElementById('login-secret').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-submit').click(); }); +document.getElementById('btn-logout').addEventListener('click', async () => { + await jpost('/api/v1/dashboard/logout').catch(() => {}); + IDENT = null; applyIdent(); location.hash = '#overview'; +}); + +// ── 我的 ──────────────────────────────────────────────────────── +const BUCKET_CHIP = { + pinned: 'bg-violet-100 text-violet-700', + ranked: 'bg-teal-50 text-teal-700 ring-1 ring-teal-100', + recommended: 'bg-sky-100 text-sky-700', +}; +function bucketLabel(s) { + if (s.bucket !== 'pinned') return s.bucket; + return s.pin_scope === 'global' ? 'pinned·全局' : (s.user_removable ? 'pinned·自己' : 'pinned·admin'); +} +async function loadMy() { + if (!IDENT) return; + const [m, ct, rt] = await Promise.all([ + j('/api/v1/dashboard/my/manifest'), + j('/api/v1/dashboard/my/contributions'), + j('/api/v1/dashboard/my/reco-trigger'), + ]); + document.getElementById('my-slot-sum').textContent = `${m.slots.length}/${m.total_slots} 槽位`; + document.getElementById('my-slots').innerHTML = m.slots.map(s => ` +
+ ${s.skill_name} + ${bucketLabel(s)} + ${s.side} + + ${s.bucket === 'pinned' + ? (s.user_removable ? `` + : `锁定`) + : ` + `} +
`).join('') || '暂无槽位'; + document.getElementById('my-blocked').innerHTML = m.blocked.map(b => ` + ${b.skill_name} + `).join('') + || ''; + const st = ct.steps; + document.getElementById('my-steps').innerHTML = + [['轨迹', st.trajs], ['原子', st.atoms], ['被采纳', st.adopted_atoms], ['进入 skill', st.skills]] + .map(([k, v], i) => `${i ? '' : ''} +
+
${v}
${k}
`).join(''); + document.getElementById('my-usage').innerHTML = ct.usage.map(u => ` +
${u.skill} + 均分 ${u.avg_score ?? '—'} + ${u.users.map(x => `${x.user}×${x.count}`).join('')}
`).join('') + || '还没有被他人使用的记录'; + const VC = { '高价值': 'bg-emerald-100 text-emerald-700', '正常': 'bg-slate-100 text-slate-600' }; + rows('my-rt-body', rt.rows.map(r => ` + ${r.skill} + ${r.exposures}${r.triggers} + ${pctf(r.rate)} + ${r.verdict}`).join(''), + '暂无推荐记录'); +} +document.addEventListener('click', async e => { + const b = e.target.closest('.my-pref'); + if (!b) return; + try { await jpost('/api/v1/dashboard/my/prefs', { skill_name: b.dataset.skill, action: b.dataset.act }); await loadMy(); } + catch (err) { alert(err.message); } +}); + +// ── 管理 ──────────────────────────────────────────────────────── +async function loadAdmin() { + if (!IDENT || IDENT.role !== 'admin') return; + const [um, sk] = await Promise.all([ + j('/api/v1/dashboard/admin/users-matrix'), + j('/api/v1/dashboard/admin/skills'), + ]); + document.getElementById('admin-gpins').innerHTML = um.global_pinned.map(g => ` + ${g} + `).join('') || ''; + rows('admin-users-body', um.users.map(u => ` + ${u.user} + ${u.client_version || '未上报'} + ${u.exposures} + ${u.rate === null ? '—' : pctf(u.rate)} + ${u.pinned} · ${u.blocked} + ${u.stale_advice.map(a => `${a.skill}`).join('') || ''} + `).join(''), + '暂无 client'); + const ST = { active: ['在役', 'bg-emerald-100 text-emerald-700'], canary: ['灰度中', 'bg-amber-100 text-amber-700'], retired: ['已下线', 'bg-rose-100 text-rose-700'] }; + rows('admin-skills-body', sk.skills.map(s => { + const [label, cls] = ST[s.state]; + return `${s.name} + ${label} + ${s.usage_30d} + + ${s.state === 'retired' + ? ` + ` + : ``} + `; + }).join(''), '暂无 skill'); +} +async function openAdminDrawer(user) { + const d = document.getElementById('admin-drawer'); + const p = await j('/api/v1/dashboard/admin/user/' + encodeURIComponent(user) + '/prefs'); + d.classList.remove('hidden'); + d.innerHTML = `
+

${user} 的偏好 pinned=${p.effective.pinned.length} blocked=${p.effective.blocked.length}

+
+
${p.prefs.map(r => ` + + ${r.skill_name} ${r.pref}·${r.set_by} + `).join('') || ''}
+
+ + + +
`; +} +document.addEventListener('click', async e => { + const cfg = e.target.closest('.adm-cfg'); + if (cfg) { openAdminDrawer(cfg.dataset.user).catch(err => alert(err.message)); return; } + if (e.target.id === 'adm-drawer-x') { document.getElementById('admin-drawer').classList.add('hidden'); return; } + const ap = e.target.closest('.adm-pref'); + if (ap) { + const skill = ap.dataset.skill || (document.getElementById('adm-skill-in') || {}).value; + if (!skill) return; + try { + await jpost('/api/v1/dashboard/admin/prefs', { user_key: ap.dataset.user, skill_name: skill.trim(), action: ap.dataset.act }); + await openAdminDrawer(ap.dataset.user); await loadAdmin(); + } catch (err) { alert(err.message); } + return; + } + const gd = e.target.closest('.gpin-del'); + if (gd) { + try { await jpost('/api/v1/dashboard/admin/prefs', { user_key: '*global*', skill_name: gd.dataset.skill, action: 'clear' }); await loadAdmin(); } + catch (err) { alert(err.message); } + return; + } + const lf = e.target.closest('.adm-life'); + if (lf) { + const name = lf.dataset.skill, act = lf.dataset.act; + try { + if (act === 'delete') { + const typed = prompt(`删除不可逆:skill 目录与 git 历史将被移除。\n请输入 skill 名确认: ${name}`); + if (typed === null) return; + await jpost('/api/v1/dashboard/admin/skill/' + encodeURIComponent(name), { confirm_name: typed }, 'DELETE'); + } else { + await jpost(`/api/v1/dashboard/admin/skill/${encodeURIComponent(name)}/${act}`); + } + await loadAdmin(); + } catch (err) { alert(err.message); } + } +}); +document.getElementById('gpin-add').addEventListener('click', async () => { + const v = document.getElementById('gpin-input').value.trim(); + if (!v) return; + try { await jpost('/api/v1/dashboard/admin/prefs', { user_key: '*global*', skill_name: v, action: 'pin' }); document.getElementById('gpin-input').value = ''; await loadAdmin(); } + catch (err) { alert(err.message); } +}); + +// ── 设置 ──────────────────────────────────────────────────────── +async function loadSettings() { + if (!IDENT || IDENT.role !== 'admin') return; + const c = await j('/api/v1/dashboard/admin/config'); + document.getElementById('cfg-path').textContent = c.path; + document.getElementById('cfg-editor').value = c.raw; +} +async function cfgAction(endpoint) { + const res = document.getElementById('cfg-result'); + res.className = 'text-[12.5px] text-slate-500'; res.textContent = '…'; + try { + const r = await jpost('/api/v1/dashboard/admin/config/' + endpoint, { raw: document.getElementById('cfg-editor').value }); + if (endpoint === 'validate') { res.className = 'text-[12.5px] text-emerald-700'; res.textContent = '✓ 校验通过'; } + else { + res.className = 'text-[12.5px] text-emerald-700'; + res.textContent = `✓ 已生效 ${r.hot_reloaded.join('/') || '无变更'}` + + (r.needs_restart.length ? `;⚠ ${r.needs_restart.join('/')} 段需重启 serve` : ''); + if (r.needs_restart.length) res.className = 'text-[12.5px] text-amber-700'; + } + } catch (err) { res.className = 'text-[12.5px] text-rose-600'; res.textContent = '✗ ' + err.message; } +} +document.getElementById('cfg-validate').addEventListener('click', () => cfgAction('validate')); +document.getElementById('cfg-reload').addEventListener('click', () => cfgAction('reload')); + +initIdent().catch(console.error); diff --git a/src/xskill/dashboard/static/index.html b/src/xskill/dashboard/static/index.html index f6e293e5..d0c6de5f 100644 --- a/src/xskill/dashboard/static/index.html +++ b/src/xskill/dashboard/static/index.html @@ -4,7 +4,7 @@ xskill 控制台 +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-6{left:1.5rem}.right-6{right:1.5rem}.top-0{top:0}.top-\[17px\]{top:17px}.z-10{z-index:10}.z-50{z-index:50}.col-span-12{grid-column:span 12/span 12}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0{height:0}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-36{max-height:9rem}.max-h-44{max-height:11rem}.max-h-72{max-height:18rem}.max-h-80{max-height:20rem}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[60px\]{min-width:60px}.min-w-\[92px\]{min-width:92px}.min-w-max{min-width:-moz-max-content;min-width:max-content}.max-w-\[200px\]{max-width:200px}.max-w-\[280px\]{max-width:280px}.max-w-\[360px\]{max-width:360px}.max-w-\[480px\]{max-width:480px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(241 245 249/var(--tw-divide-opacity,1))}.divide-slate-50>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(248 250 252/var(--tw-divide-opacity,1))}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/60{background-color:rgba(255,251,235,.6)}.bg-amber-50\/70{background-color:rgba(255,251,235,.7)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-rose-100{--tw-bg-opacity:1;background-color:rgb(255 228 230/var(--tw-bg-opacity,1))}.bg-rose-50{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.bg-rose-50\/50{background-color:rgba(255,241,242,.5)}.bg-rose-500{--tw-bg-opacity:1;background-color:rgb(244 63 94/var(--tw-bg-opacity,1))}.bg-sky-100{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity,1))}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/40{background-color:rgba(15,23,42,.4)}.bg-teal-50{--tw-bg-opacity:1;background-color:rgb(240 253 250/var(--tw-bg-opacity,1))}.bg-teal-50\/40{background-color:rgba(240,253,250,.4)}.bg-teal-50\/50{background-color:rgba(240,253,250,.5)}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-600{--tw-bg-opacity:1;background-color:rgb(13 148 136/var(--tw-bg-opacity,1))}.bg-violet-100{--tw-bg-opacity:1;background-color:rgb(237 233 254/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-teal-500{--tw-gradient-from:#14b8a6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(20,184,166,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-cyan-700{--tw-gradient-to:#0e7490 var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pt-3{padding-top:.75rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[26px\]{font-size:26px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-4{line-height:1rem}.leading-\[2\.4\]{line-height:2.4}.leading-normal{line-height:1.5}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-rose-600{--tw-text-opacity:1;color:rgb(225 29 72/var(--tw-text-opacity,1))}.text-rose-700{--tw-text-opacity:1;color:rgb(190 18 60/var(--tw-text-opacity,1))}.text-rose-800{--tw-text-opacity:1;color:rgb(159 18 57/var(--tw-text-opacity,1))}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-teal-600{--tw-text-opacity:1;color:rgb(13 148 136/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-teal-800{--tw-text-opacity:1;color:rgb(17 94 89/var(--tw-text-opacity,1))}.text-violet-600{--tw-text-opacity:1;color:rgb(124 58 237/var(--tw-text-opacity,1))}.text-violet-700{--tw-text-opacity:1;color:rgb(109 40 217/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-teal-200{text-decoration-color:#99f6e4}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-1,.ring-2{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-amber-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 243 199/var(--tw-ring-opacity,1))}.ring-amber-200{--tw-ring-opacity:1;--tw-ring-color:rgb(253 230 138/var(--tw-ring-opacity,1))}.ring-emerald-200{--tw-ring-opacity:1;--tw-ring-color:rgb(167 243 208/var(--tw-ring-opacity,1))}.ring-rose-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 205 211/var(--tw-ring-opacity,1))}.ring-sky-200{--tw-ring-opacity:1;--tw-ring-color:rgb(186 230 253/var(--tw-ring-opacity,1))}.ring-slate-100{--tw-ring-opacity:1;--tw-ring-color:rgb(241 245 249/var(--tw-ring-opacity,1))}.ring-slate-200{--tw-ring-opacity:1;--tw-ring-color:rgb(226 232 240/var(--tw-ring-opacity,1))}.ring-slate-300{--tw-ring-opacity:1;--tw-ring-color:rgb(203 213 225/var(--tw-ring-opacity,1))}.ring-teal-100{--tw-ring-opacity:1;--tw-ring-color:rgb(204 251 241/var(--tw-ring-opacity,1))}.ring-teal-200{--tw-ring-opacity:1;--tw-ring-color:rgb(153 246 228/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:bg-rose-50:hover{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-50:hover{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.hover\:bg-teal-700:hover{--tw-bg-opacity:1;background-color:rgb(15 118 110/var(--tw-bg-opacity,1))}.focus\:ring-teal-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(20 184 166/var(--tw-ring-opacity,1))}@media (min-width:768px){.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-7{grid-column:span 7/span 7}.md\:col-span-8{grid-column:span 8/span 8}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-7{grid-column:span 7/span 7}}@media (min-width:1280px){.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-7{grid-column:span 7/span 7}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}