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:
1 parent
97f019d1dd
commit
154c18e25d
3 files changed
+816
-17
No files matched your search
+300
-17
@@ -90,6 +90,22 @@
|
||||
<div><span id="quotaState" class="muted">未加载</span></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="adminCard">
|
||||
<h2>管理区 <span class="muted">(仅全局管理员)</span></h2>
|
||||
<div id="adminGate" class="muted">填 key 后判断权限…</div>
|
||||
<div id="adminBody" style="display:none">
|
||||
<div class="bar">
|
||||
<input id="adminUserId" type="text" placeholder="要查配额的用户 id">
|
||||
<button id="btnAdminQuota">查该用户配额</button>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button id="btnAdminSweep">手动触发 sweep(清理陈旧 running 任务)</button>
|
||||
<button id="btnAdminBackup">手动触发备份</button>
|
||||
</div>
|
||||
<div id="adminResult" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>健康状态</h2>
|
||||
<div class="bar">
|
||||
@@ -112,6 +128,20 @@
|
||||
<button id="createProject">新建项目</button>
|
||||
</div>
|
||||
<span id="projectMsg" class="muted"></span>
|
||||
<h3>项目成员</h3>
|
||||
<div class="bar">
|
||||
<input id="inviteUser" type="text" placeholder="成员 user_id 或用户名">
|
||||
<select id="inviteRole">
|
||||
<option value="viewer">viewer(只读)</option>
|
||||
<option value="contributor" selected>contributor(可建任务)</option>
|
||||
<option value="maintainer">maintainer(可批合并)</option>
|
||||
<option value="owner">owner(可管项目)</option>
|
||||
</select>
|
||||
<button id="inviteMember">邀请成员</button>
|
||||
<button id="refreshMembers">刷新成员</button>
|
||||
</div>
|
||||
<div id="memberList" class="muted">先选项目</div>
|
||||
<span id="memberMsg" class="muted"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Workers</h2>
|
||||
@@ -198,6 +228,23 @@
|
||||
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
|
||||
<div class="bar">
|
||||
<button id="refreshTasks">刷新</button>
|
||||
<select id="taskStatusFilter">
|
||||
<option value="">状态:全部</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="interrupted">interrupted</option>
|
||||
<option value="merged">merged</option>
|
||||
<option value="merge-failed">merge-failed</option>
|
||||
</select>
|
||||
<select id="taskExecutorFilter">
|
||||
<option value="">执行器:全部</option>
|
||||
<option value="local">local</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="cloud">cloud</option>
|
||||
</select>
|
||||
<input id="taskSearch" type="text" placeholder="搜索任务 id / prompt">
|
||||
<span id="taskCount" class="muted"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -273,6 +320,22 @@
|
||||
</div>
|
||||
<div id="reviewList" class="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
|
||||
<h3>实时链路</h3>
|
||||
<div class="bar">
|
||||
<select id="traceKindFilter">
|
||||
<option value="">链路类型:全部</option>
|
||||
<option value="exec">命令</option>
|
||||
<option value="file">文件</option>
|
||||
<option value="call">调用</option>
|
||||
<option value="tool">工具</option>
|
||||
<option value="message">消息</option>
|
||||
<option value="reasoning">思考</option>
|
||||
<option value="plan">计划</option>
|
||||
<option value="agent-start">子代理开始</option>
|
||||
<option value="agent-done">子代理结束</option>
|
||||
</select>
|
||||
<button id="btnDownloadTrace">下载完整链路</button>
|
||||
<span id="traceMsg" class="muted"></span>
|
||||
</div>
|
||||
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
|
||||
<div id="costBox"></div>
|
||||
<h3>后续消息</h3>
|
||||
@@ -484,6 +547,20 @@ async function decideApproval(approvalId, decision) {
|
||||
loadApprovals(currentTask.id);
|
||||
} catch (e) { $("#approvalMsg").innerHTML = "<span class='err'>决定失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
function workerAgeText(w) {
|
||||
// last_seen 是 epoch 秒;服务端也可能只给距今秒数(旧字段)。
|
||||
// 返回 {text, age}:age 为 null 时无法判断在线状态。
|
||||
var now = Date.now() / 1000;
|
||||
var age = null;
|
||||
if (typeof w.last_seen === "number") age = now - w.last_seen;
|
||||
else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago;
|
||||
if (age === null || age < 0) age = null;
|
||||
if (age === null) return { text: "未知", age: null };
|
||||
var s = Math.floor(age);
|
||||
if (s < 60) return { text: s + " 秒前", age: age };
|
||||
if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age: age };
|
||||
return { text: Math.floor(s / 3600) + " 小时前", age: age };
|
||||
}
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
var d = await api("/workers", "GET");
|
||||
@@ -499,10 +576,16 @@ async function loadWorkers() {
|
||||
$("#workers").innerHTML = list.map(function (w) {
|
||||
var run = (w.running === undefined || w.running === null) ? "?" : w.running;
|
||||
var cap = (w.capacity === undefined || w.capacity === null) ? "?" : w.capacity;
|
||||
var live = (w.alive === false) ? " / 离线(占用为上次上报值)" : "";
|
||||
// 在线判定:优先服务端 alive(阈值 120 秒,与服务端
|
||||
// alive_threshold_seconds 默认值一致),缺失时按 120 秒自算。
|
||||
var info = workerAgeText(w);
|
||||
var alive = (w.alive !== undefined && w.alive !== null)
|
||||
? !!w.alive
|
||||
: (info.age !== null && info.age <= 120);
|
||||
var live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
|
||||
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
|
||||
" / 占用 " + esc(run) + "/" + esc(cap) + live +
|
||||
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
|
||||
" / 最后心跳 " + esc(info.text);
|
||||
}).join("<br>");
|
||||
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
|
||||
}
|
||||
@@ -566,15 +649,33 @@ async function loadEnvironments() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>先选项目</td></tr>"; return; }
|
||||
try {
|
||||
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
|
||||
var tasks = d.tasks || [];
|
||||
$("#taskCount").textContent = tasks.length + " 个任务";
|
||||
if (!tasks.length) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>该项目暂无任务</td></tr>"; return; }
|
||||
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
|
||||
var tasksCache = [];
|
||||
function filteredTasks() {
|
||||
// 筛选与搜索是纯前端逻辑:10 秒刷新仍拉全量,筛选条件
|
||||
// 不随刷新丢失(不改服务端 /tasks 查询语义)。
|
||||
var st = $("#taskStatusFilter") ? $("#taskStatusFilter").value : "";
|
||||
var ex = $("#taskExecutorFilter") ? $("#taskExecutorFilter").value : "";
|
||||
var q = $("#taskSearch") ? $("#taskSearch").value.trim().toLowerCase() : "";
|
||||
return tasksCache.filter(function (t) {
|
||||
if (st && t.status !== st) return false;
|
||||
if (ex && (t.executor || "") !== ex) return false;
|
||||
if (q) {
|
||||
var hay = ((t.id || "") + " " + (t.prompt || "")).toLowerCase();
|
||||
if (hay.indexOf(q) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function renderTasks() {
|
||||
var tasks = filteredTasks();
|
||||
var total = tasksCache.length;
|
||||
$("#taskCount").textContent = tasks.length + " 个任务(共 " + total + " 个)";
|
||||
if (!tasks.length) {
|
||||
$("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>" +
|
||||
(total ? "没有符合筛选条件的任务" : "该项目暂无任务") + "</td></tr>";
|
||||
return;
|
||||
}
|
||||
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
|
||||
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
|
||||
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
|
||||
"<td>" + esc(shortId(t.id)) + "</td>" +
|
||||
@@ -590,6 +691,14 @@ async function loadTasks() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) {
|
||||
tr.onclick = function () { showDetail(tr.getAttribute("data-id")); };
|
||||
});
|
||||
}
|
||||
async function loadTasks() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>先选项目</td></tr>"; return; }
|
||||
try {
|
||||
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
|
||||
tasksCache = d.tasks || [];
|
||||
renderTasks();
|
||||
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
|
||||
}
|
||||
|
||||
@@ -612,19 +721,51 @@ function traceLine(e) {
|
||||
else body = esc(JSON.stringify(e).slice(0, 300));
|
||||
return "[" + k + "] " + body;
|
||||
}
|
||||
var lastTraceEntries = [];
|
||||
var lastTraceNote = "";
|
||||
var lastTraceDone = "";
|
||||
function renderTrace(entries, note, doneStatus) {
|
||||
// 全量条目先存下来供“下载完整链路”用;屏上显示仍截断
|
||||
// 最近 TRACE_LIMIT 条,再按类型过滤(过滤不影响下载内容)。
|
||||
lastTraceEntries = entries || [];
|
||||
lastTraceNote = note || "";
|
||||
lastTraceDone = doneStatus || "";
|
||||
var box = $("#traceBox");
|
||||
var html = "";
|
||||
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
|
||||
if (!entries.length) html += "(暂无链路条目)";
|
||||
var kind = $("#traceKindFilter") ? $("#traceKindFilter").value : "";
|
||||
var pool = kind ? lastTraceEntries.filter(function (e) { return e.kind === kind; }) : lastTraceEntries;
|
||||
if (!pool.length) html += kind ? "(该类型暂无链路条目)" : "(暂无链路条目)";
|
||||
else {
|
||||
var shown = entries.slice(-TRACE_LIMIT);
|
||||
if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)<br>";
|
||||
var shown = pool.slice(-TRACE_LIMIT);
|
||||
if (pool.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + pool.length + " 条)<br>";
|
||||
else if (kind) html += "(按类型 " + esc(kind) + " 过滤,共 " + pool.length + " 条)<br>";
|
||||
html += shown.map(traceLine).join("<br>");
|
||||
}
|
||||
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
|
||||
box.innerHTML = html;
|
||||
}
|
||||
async function downloadTrace() {
|
||||
// 下载走同一 /trace 端点全量拉取,不用屏上截断后的条目:
|
||||
// 屏上是最近 1000 条 + 类型过滤,下载必须是完整链路。
|
||||
if (!currentTask) { $("#traceMsg").textContent = "先选一个任务"; return; }
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/trace", "GET");
|
||||
var entries = d.entries || [];
|
||||
var blob = new Blob([JSON.stringify(
|
||||
{ task_id: currentTask.id, status: d.status, note: d.note || "",
|
||||
count: entries.length, entries: entries }, null, 2)],
|
||||
{ type: "application/json" });
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "trace-" + currentTask.id.slice(0, 8) + ".json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
$("#traceMsg").innerHTML = "<span class='ok'>已下载完整链路 " + entries.length + " 条</span>";
|
||||
} catch (e) { $("#traceMsg").innerHTML = "<span class='err'>下载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
|
||||
var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
|
||||
var AGENT_CALL_SHOWN = 50;
|
||||
@@ -985,6 +1126,136 @@ async function postFollowup(mode) {
|
||||
} catch (e) { $("#followupMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
function memberErr(e) {
|
||||
var m = e.message || "";
|
||||
if (m.indexOf("forbidden") >= 0) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
|
||||
if (m.indexOf("already_member") >= 0) return "该用户已是成员,改角色请用成员行的下拉框";
|
||||
if (m.indexOf("cannot demote the only owner") >= 0) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
|
||||
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对 user_id 或用户名是否正确";
|
||||
if (m.indexOf("member not found") >= 0) return "该用户不是本项目成员";
|
||||
if (m.indexOf("unknown role") >= 0) return "角色无效:只能是 viewer / contributor / maintainer / owner";
|
||||
if (m.indexOf("project not found") >= 0) return "项目不存在(可能已被删除)";
|
||||
return "成员操作失败:" + m;
|
||||
}
|
||||
async function loadMembers() {
|
||||
var pid = $("#projectSelect").value;
|
||||
var box = $("#memberList");
|
||||
if (!pid) { box.textContent = "先选项目"; return; }
|
||||
try {
|
||||
var d = await api("/projects/" + encodeURIComponent(pid) + "/members", "GET");
|
||||
var list = d.members || [];
|
||||
if (!list.length) { box.textContent = "该项目暂无成员"; return; }
|
||||
box.innerHTML = list.map(function (m) {
|
||||
var label = esc(m.username || "") + (m.username ? "(" + esc(String(m.user_id).slice(0, 8)) + ")" : esc(String(m.user_id).slice(0, 8)));
|
||||
var opts = ["viewer", "contributor", "maintainer", "owner"].map(function (r) {
|
||||
return "<option value='" + r + "'" + (r === m.role ? " selected" : "") + ">" + r + "</option>";
|
||||
}).join("");
|
||||
return "<div style='margin:4px 0'>" + label + " | <select data-member-role='" + esc(m.user_id) + "'>" + opts + "</select></div>";
|
||||
}).join("");
|
||||
Array.prototype.forEach.call(box.querySelectorAll("[data-member-role]"), function (sel) {
|
||||
var prev = sel.value;
|
||||
sel.onchange = async function () {
|
||||
var uid = sel.getAttribute("data-member-role");
|
||||
var role = sel.value;
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members/" + encodeURIComponent(uid), "PATCH", { role: role });
|
||||
prev = role;
|
||||
$("#memberMsg").innerHTML = "<span class='ok'>角色已改为 " + esc(role) + "</span>";
|
||||
} catch (e) {
|
||||
sel.value = prev;
|
||||
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (e) { box.innerHTML = "<span class='err'>成员加载失败:" + esc(memberErr(e)) + "</span>"; }
|
||||
}
|
||||
async function inviteMember() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#memberMsg").textContent = "先选项目"; return; }
|
||||
var raw = $("#inviteUser").value.trim();
|
||||
if (!raw) { $("#memberMsg").textContent = "请填成员 user_id 或用户名"; return; }
|
||||
// 输入可能是 user_id 也可能是用户名:服务端优先按 user_id
|
||||
// 查,查不到时前端再按 username 试一次(避免把用户名误当 id 报 404)。
|
||||
var body = { user_id: raw, role: $("#inviteRole").value };
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", body);
|
||||
} catch (e) {
|
||||
if (e.message.indexOf("user not found") >= 0) {
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", { username: raw, role: $("#inviteRole").value });
|
||||
} catch (e2) { $("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e2)) + "</span>"; return; }
|
||||
} else {
|
||||
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
|
||||
return;
|
||||
}
|
||||
}
|
||||
$("#memberMsg").innerHTML = "<span class='ok'>已邀请成员</span>";
|
||||
$("#inviteUser").value = "";
|
||||
loadMembers();
|
||||
}
|
||||
function adminErr(e) {
|
||||
var m = e.message || "";
|
||||
if (m.indexOf("forbidden") >= 0) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员";
|
||||
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对用户 id 是否正确";
|
||||
return "管理操作失败:" + m;
|
||||
}
|
||||
async function probeAdmin() {
|
||||
// 权限探针复用已有 /admin/quotas:服务端先验全局管理员
|
||||
// (非管理员 403),通过后才校验 user_id 参数(缺参数 400)。
|
||||
// 故 400 = 是管理员、403 = 不是管理员,不新增任何端点。
|
||||
var gate = $("#adminGate");
|
||||
var body = $("#adminBody");
|
||||
if (!getKey()) { gate.textContent = "未填 key,管理区不可用"; body.style.display = "none"; return; }
|
||||
try {
|
||||
var headers = { "X-API-Key": getKey() };
|
||||
var r = await fetch("/admin/quotas", { headers: headers });
|
||||
if (r.status === 400) {
|
||||
gate.innerHTML = "<span class='ok'>已确认为全局管理员,管理操作可用</span>";
|
||||
body.style.display = "block";
|
||||
} else if (r.status === 403) {
|
||||
gate.textContent = "管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)";
|
||||
body.style.display = "none";
|
||||
} else if (r.status === 401) {
|
||||
gate.textContent = "key 无效,管理区不可用(服务端返回 401)";
|
||||
body.style.display = "none";
|
||||
} else {
|
||||
gate.textContent = "管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用";
|
||||
body.style.display = "none";
|
||||
}
|
||||
} catch (e) { gate.textContent = "管理权限判断失败:" + e.message; body.style.display = "none"; }
|
||||
}
|
||||
function fmtUsd(micros) {
|
||||
if (micros === null || micros === undefined) return "不限额";
|
||||
return "$" + (micros / 1000000).toFixed(2);
|
||||
}
|
||||
async function adminQuota() {
|
||||
var uid = $("#adminUserId").value.trim();
|
||||
if (!uid) { $("#adminResult").textContent = "请填要查配额的用户 id"; return; }
|
||||
try {
|
||||
var d = await api("/admin/quotas?user_id=" + encodeURIComponent(uid), "GET");
|
||||
$("#adminResult").innerHTML = "用户 " + esc(uid.slice(0, 8)) + ":本月用量 " + esc(fmtUsd(d.used_usd_micros)) +
|
||||
" / 配额 " + esc(fmtUsd(d.quota_usd_micros)) +
|
||||
(d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + esc(fmtUsd(d.remaining_usd_micros)) + ")" : "") +
|
||||
" | 月份 " + esc(d.month || "");
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
async function adminSweep() {
|
||||
if (!confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
|
||||
try {
|
||||
var d = await api("/admin/sweep", "POST", {});
|
||||
var swept = d.swept || [];
|
||||
$("#adminResult").innerHTML = "<span class='ok'>sweep 完成,清理 " + swept.length + " 个任务" +
|
||||
(swept.length ? ":" + esc(swept.join("、")) : "") + "</span>";
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
async function adminBackup() {
|
||||
try {
|
||||
var d = await api("/admin/backup", "POST", {});
|
||||
$("#adminResult").innerHTML = "<span class='ok'>备份完成:</span>backend=" + esc(d.backend) +
|
||||
" | 路径 " + esc(d.path) + " | 大小 " + esc(d.size_bytes) + " 字节 | 任务 " + esc(d.task_count) + " 行";
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
|
||||
$("#saveKey").onclick = function () {
|
||||
var v = $("#key").value.trim();
|
||||
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
|
||||
@@ -996,18 +1267,29 @@ $("#saveKey").onclick = function () {
|
||||
loadHealth();
|
||||
loadProjects();
|
||||
loadWorkers();
|
||||
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
|
||||
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); loadMembers(); }
|
||||
if (getKey()) loadRelayModels();
|
||||
loadQuota();
|
||||
loadGithubRepos();
|
||||
probeAdmin();
|
||||
};
|
||||
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
|
||||
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); probeAdmin(); };
|
||||
$("#refreshHealth").onclick = loadHealth;
|
||||
$("#refreshProjects").onclick = loadProjects;
|
||||
$("#refreshWorkers").onclick = loadWorkers;
|
||||
$("#refreshTasks").onclick = loadTasks;
|
||||
$("#refreshEnvironments").onclick = loadEnvironments;
|
||||
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); };
|
||||
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); };
|
||||
$("#refreshMembers").onclick = loadMembers;
|
||||
$("#inviteMember").onclick = inviteMember;
|
||||
$("#taskStatusFilter").onchange = renderTasks;
|
||||
$("#taskExecutorFilter").onchange = renderTasks;
|
||||
$("#taskSearch").oninput = renderTasks;
|
||||
$("#traceKindFilter").onchange = function () { renderTrace(lastTraceEntries, lastTraceNote, lastTraceDone); };
|
||||
$("#btnDownloadTrace").onclick = downloadTrace;
|
||||
$("#btnAdminQuota").onclick = adminQuota;
|
||||
$("#btnAdminSweep").onclick = adminSweep;
|
||||
$("#btnAdminBackup").onclick = adminBackup;
|
||||
$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; };
|
||||
$("#createProject").onclick = async function () {
|
||||
var name = $("#newProjectName").value.trim();
|
||||
@@ -1240,6 +1522,7 @@ loadGithubRepos();
|
||||
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
|
||||
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
|
||||
if (getKey()) loadRelayModels();
|
||||
probeAdmin();
|
||||
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+181
@@ -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":
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""P2 切片 A(控制台管理面与筛选)单测。
|
||||
|
||||
只覆盖本切片新增或复用的服务端契约 + 控制台壳标记,不跑
|
||||
浏览器 JS(与 test_console.py 同款:壳只验标记与路由):
|
||||
- 成员端点:非 owner 邀请 403、owner 邀请成功(user_id 与
|
||||
username 两种入参)、重复邀请 409、非法角色 400、未知用户
|
||||
404、成员列表读权限、改角色权限与唯一 owner 防降级、
|
||||
非成员全局管理员可邀请/改角色。
|
||||
- 管理区复用端点:非管理员 sweep 403、管理员配额读成功、
|
||||
探针语义(非管理员 GET /admin/quotas 无参 403,管理员 400)。
|
||||
- 链路全量:写 1200 条链路文件,GET /trace 全量返回(控制台
|
||||
“下载完整链路”依赖的正是该端点,而非屏上截断的 1000 条)。
|
||||
- Workers:心跳后 /workers 返回 epoch 秒 last_seen 与 alive。
|
||||
- 控制台壳含本切片新增标记。
|
||||
|
||||
测试用的 key、用户 id 全是假值;不联网、不碰生产。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
def _req(port, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
conn.request(method, path,
|
||||
json.dumps(body) if body is not None else None, headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
data = None
|
||||
return resp.status, data
|
||||
|
||||
|
||||
class P2ConsoleTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.trace_dir = os.path.join(self.tmp.name, "host-traces")
|
||||
os.makedirs(self.trace_dir, exist_ok=True)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.maint_id, self.maint_key = self.auth.create_user("maint")
|
||||
self.member_id, self.member_key = self.auth.create_user("member")
|
||||
self.admin_id, self.admin_key = self.auth.create_user("admin")
|
||||
self.outsider_id, self.out_key = self.auth.create_user("outsider")
|
||||
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
|
||||
# 全局管理员:非项目成员的 admin,验“owner 或全局管理员”双通道。
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.trace_dir = self.trace_dir
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, proj = _req(self.port, "POST", "/projects",
|
||||
{"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, proj)
|
||||
self.project_id = proj["id"]
|
||||
self.auth.set_member(self.project_id, self.owner_id,
|
||||
self.maint_id, "maintainer")
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
if self._old_admins is None:
|
||||
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
|
||||
else:
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
|
||||
|
||||
def _members(self, key=None):
|
||||
return _req(self.port, "GET",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
key=key)
|
||||
|
||||
# -- 成员邀请 / 列表 -------------------------------------------
|
||||
def test_non_owner_invite_forbidden(self):
|
||||
# maintainer 不是 owner、不是全局管理员:邀请必须 403,
|
||||
# 且安全门不能因“项目成员”身份放行。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_outsider_invite_forbidden(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.out_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_owner_invite_by_user_id_and_username(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["role"], "contributor")
|
||||
# 第二个用户走 username 入参(控制台输入框两种都接受)。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"username": "outsider", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["user_id"], self.outsider_id)
|
||||
|
||||
def test_invite_validation(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "superuser"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": "no-such-user", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_invite_duplicate_conflict(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
# 已是成员:改角色走 PATCH,重复邀请 409,不静默覆盖。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "maintainer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
|
||||
def test_member_list_read_and_outsider_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = self._members(self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
roles = {m["user_id"]: m["role"] for m in data["members"]}
|
||||
self.assertEqual(roles[self.owner_id], "owner")
|
||||
self.assertEqual(roles[self.maint_id], "maintainer")
|
||||
self.assertEqual(roles[self.member_id], "viewer")
|
||||
# 列表不泄露认证字段。
|
||||
for m in data["members"]:
|
||||
self.assertNotIn("api_key_hash", m)
|
||||
self.assertNotIn("key_hash", m)
|
||||
status, _ = self._members(self.out_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_global_admin_can_invite_without_membership(self):
|
||||
# admin 不是项目成员,但凭 CODEX_GLOBAL_ADMINS 可邀请。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.admin_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
|
||||
# -- 改角色 / 唯一 owner 防降级 ---------------------------------
|
||||
def test_patch_role_by_owner_and_non_owner_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["role"], "maintainer")
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, "ghost"),
|
||||
{"role": "viewer"}, self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_cannot_demote_only_owner(self):
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
# 先把另一位成员升为 owner,之后降原 owner 才放行。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.maint_id),
|
||||
{"role": "owner"}, self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
|
||||
def test_global_admin_patch_blocked_by_sole_owner_guard(self):
|
||||
# 全局管理员也不能把唯一 owner 降掉(防项目无 owner 可管)。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "viewer"}, self.admin_key)
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
# -- 管理区复用端点 ---------------------------------------------
|
||||
def test_admin_probe_and_quota_reuse(self):
|
||||
# 探针语义:控制台凭此区分管理员与否,不新增端点。
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, data = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertIn("used_usd_micros", data)
|
||||
status, _ = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_non_admin_sweep_forbidden(self):
|
||||
status, _ = _req(self.port, "POST", "/admin/sweep", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "POST", "/admin/backup", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
# -- 链路全量 / Workers ------------------------------------------
|
||||
def test_trace_endpoint_returns_full_entries(self):
|
||||
status, task = _req(
|
||||
self.port, "POST", "/tasks",
|
||||
{"project_id": self.project_id, "prompt": "trace probe"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
task_id = task["id"]
|
||||
path = os.path.join(self.trace_dir, task_id + ".jsonl")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for i in range(1200):
|
||||
f.write(json.dumps(
|
||||
{"id": "e%d" % i, "kind": "message",
|
||||
"text": "line %d" % i}) + "\n")
|
||||
status, data = _req(
|
||||
self.port, "GET", "/tasks/%s/trace" % task_id,
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
# 服务端不截断:控制台下载按钮拿到的就是这 1200 条全量,
|
||||
# 屏上 1000 条截断只是渲染层行为。
|
||||
self.assertEqual(len(data["entries"]), 1200)
|
||||
|
||||
def test_workers_last_seen_is_epoch_and_alive(self):
|
||||
status, data = _req(
|
||||
self.port, "POST", "/workers/heartbeat",
|
||||
{"worker_id": "w-p2", "executor": "docker",
|
||||
"capacity": 2, "running": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
status, data = _req(self.port, "GET", "/workers",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
workers = data["workers"]
|
||||
self.assertEqual(len(workers), 1)
|
||||
# 控制台“x 秒/分钟前 + 120 秒在线阈值”的输入:epoch 秒。
|
||||
self.assertIsInstance(workers[0]["last_seen"], (int, float))
|
||||
self.assertLess(abs(workers[0]["last_seen"] - time.time()), 60)
|
||||
self.assertTrue(workers[0]["alive"])
|
||||
|
||||
# -- 控制台壳标记 -------------------------------------------------
|
||||
def test_console_contains_p2_markers(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
conn.request("GET", "/console")
|
||||
resp = conn.getresponse()
|
||||
text = resp.read().decode("utf-8")
|
||||
conn.close()
|
||||
self.assertEqual(resp.status, 200)
|
||||
for marker in ("/members", "inviteMember", "memberList",
|
||||
"adminBody", "/admin/sweep", "/admin/backup",
|
||||
"/admin/quotas", "taskStatusFilter",
|
||||
"taskExecutorFilter", "taskSearch",
|
||||
"btnDownloadTrace", "traceKindFilter",
|
||||
"workerAgeText", "last_seen"):
|
||||
self.assertIn(marker, text, marker)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user