P2 console slice A: project members, admin panel, task filters, trace download, worker liveness

- Members: GET/POST /projects/{id}/members, PATCH /projects/{id}/members/{user_id};
  owner or CODEX_GLOBAL_ADMINS only, audit via set_member; invite by user_id
  or username, duplicate 409, sole-owner demotion 409.
- Console admin panel reuses /admin/quotas, /admin/sweep, /admin/backup;
  admin probe reuses /admin/quotas 403-vs-400 ordering, no new endpoint.
- Task list: frontend status/executor filters + id/prompt search over the
  full fetch; 10s refresh unchanged.
- Trace: kind filter on render, download button fetches full /trace
  (not the 1000-row render truncation); workers show relative last_seen
  with the 120s alive threshold.
- Tests: cloud/tests/test_p2_console.py, 15 tests.
This commit is contained in:
host committed 2026-10-27 16:37:12 +00:00
1 parent 97f019d1dd
commit 154c18e25d
3 files changed
+816 -17

No files matched your search

+181
View File
@@ -55,6 +55,7 @@ from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse
from cloud.auth import ROLES as _MEMBER_ROLES
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
from cloud.coordinator import validate_parts as _coord_validate_parts
@@ -2077,6 +2078,170 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
projects.append(item)
self._send_json(200, {"projects": projects})
# -- 项目成员:邀请 / 列表 / 改角色 -------------------------------
def _can_manage_members(self, user_id, project_id):
"""成员管理权:项目 owner 或全局管理员。
全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员,
故不能只用项目内 manage_project 判定;项目 owner 判定
复用 check_permission 的 manage_project(仅 owner)。
"""
if self._is_global_admin(user_id):
return True
with _auth_store(self.server.db_path) as store:
return store.check_permission(
user_id, project_id, "manage_project")
def _set_member_row(self, store, project_id, actor_id, target_id,
role):
"""写成员行并记审计:owner 走 AuthStore.set_member(内含
owner 校验与审计);非成员的全局管理员走直接写 + 同格式
审计(set_member 的 owner 校验会挡住全局管理员,故分流)。
"""
if store.check_permission(actor_id, project_id,
"manage_project"):
store.set_member(project_id, actor_id, target_id, role)
return
store.conn.execute(
"INSERT OR REPLACE INTO project_members"
"(project_id, user_id, role) VALUES(?, ?, ?)",
(project_id, target_id, role))
store.conn.commit()
store.audit(actor_id, project_id, "set_member",
"%s:%s" % (target_id, role))
def _project_members_list(self, user_id, project_id):
"""GET /projects/{id}/members:项目成员(read)或全局
管理员可读。只返回 user_id / username / role,绝不返回
key 哈希等认证字段。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._is_global_admin(user_id):
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
rows = conn.execute(
"SELECT m.user_id, m.role, u.username FROM"
" project_members m LEFT JOIN users u"
" ON u.id = m.user_id WHERE m.project_id = ?"
" ORDER BY u.username, m.user_id",
(project_id,)).fetchall()
finally:
conn.close()
self._send_json(200, {"project_id": project_id, "members": [
{"user_id": r["user_id"], "username": r["username"],
"role": r["role"]} for r in rows]})
def _resolve_invite_target(self, body):
"""邀请目标解析:优先 user_id,其次 username 查 id;
返回 (target_id, error_status, error)。"""
target = body.get("user_id")
username = body.get("username")
if target is not None and not isinstance(target, str):
return None, 400, "user_id must be a string"
if username is not None and not isinstance(username, str):
return None, 400, "username must be a string"
if not target and not username:
return None, 400, "user_id or username required"
conn = self._db()
try:
if target:
row = conn.execute(
"SELECT id FROM users WHERE id = ?",
(target,)).fetchone()
if row is None:
return None, 404, "user not found"
return row["id"], None, None
row = conn.execute(
"SELECT id FROM users WHERE username = ?",
(username,)).fetchone()
if row is None:
return None, 404, "user not found"
return row["id"], None, None
finally:
conn.close()
def _project_member_invite(self, user_id, project_id):
"""POST /projects/{id}/members:限项目 owner 或全局管理
员。已是成员时 409(改角色走 PATCH,不用邀请覆盖);
成功记 set_member 审计。role 缺省 contributor。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._can_manage_members(user_id, project_id):
self._send_json(403, {"error": "forbidden"})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
role = body.get("role") or "contributor"
if role not in _MEMBER_ROLES:
self._send_json(400, {"error": "unknown role"})
return
target, estatus, eerr = self._resolve_invite_target(body)
if eerr:
self._send_json(estatus, {"error": eerr})
return
with _auth_store(self.server.db_path) as store:
existing = store.conn.execute(
"SELECT role FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target)).fetchone()
if existing is not None:
self._send_json(409, {"error": "already_member"})
return
self._set_member_row(store, project_id, user_id,
target, role)
self._send_json(201, {"project_id": project_id,
"user_id": target, "role": role})
def _project_member_patch(self, user_id, project_id, target):
"""PATCH /projects/{id}/members/{user_id}:限项目 owner
或全局管理员。唯一 owner 降为非 owner 时 409(无论降
自己还是降他人——否则项目将无 owner 可管);目标非成员
时 404。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._can_manage_members(user_id, project_id):
self._send_json(403, {"error": "forbidden"})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
role = body.get("role")
if role not in _MEMBER_ROLES:
self._send_json(400, {"error": "unknown role"})
return
with _auth_store(self.server.db_path) as store:
cur = store.conn.execute(
"SELECT role FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target)).fetchone()
if cur is None:
self._send_json(404, {"error": "member not found"})
return
if cur[0] == "owner" and role != "owner":
owners = store.conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE project_id = ? AND role = 'owner'",
(project_id,)).fetchone()[0]
if owners <= 1:
self._send_json(
409,
{"error": "cannot demote the only owner"})
return
self._set_member_row(store, project_id, user_id,
target, role)
self._send_json(200, {"project_id": project_id,
"user_id": target, "role": role})
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
def _project_exists(self, project_id):
conn = self._db()
@@ -2107,6 +2272,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if len(parts) == 1 and method == "PATCH":
self._update_project(user_id, project_id)
return
# /projects/{id}/members:GET 列表 / POST 邀请
if len(parts) == 2 and parts[1] == "members":
if method == "GET":
self._project_members_list(user_id, project_id)
elif method == "POST":
self._project_member_invite(user_id, project_id)
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/members/{user_id}:PATCH 改角色
if len(parts) == 3 and parts[1] == "members":
if method == "PATCH":
self._project_member_patch(user_id, project_id, parts[2])
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/memory
if len(parts) == 2 and parts[1] == "memory":
if method == "POST":