Workers
@@ -198,6 +228,23 @@
任务列表 (每 10 秒自动刷新当前项目)
+
+
+
@@ -273,6 +320,22 @@
点任务行查看详情后,点“刷新队列”查看评审记录
实时链路
+
+
+
+
+
点任务行查看详情后,这里显示过程链路
后续消息
@@ -484,6 +547,20 @@ async function decideApproval(approvalId, decision) {
loadApprovals(currentTask.id);
} catch (e) { $("#approvalMsg").innerHTML = "
决定失败:" + esc(e.message) + ""; }
}
+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("
");
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
}
@@ -566,15 +649,33 @@ async function loadEnvironments() {
}
}
-async function loadTasks() {
- var pid = $("#projectSelect").value;
- if (!pid) { $("#taskRows").innerHTML = "
| 先选项目 |
"; 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 = "
| 该项目暂无任务 |
"; 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 = "
| " +
+ (total ? "没有符合筛选条件的任务" : "该项目暂无任务") + " |
";
+ return;
+ }
+ $("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
var pr = t.pr_url ? "
#" + esc(t.pr_number) + "" : "";
return "
" +
"| " + esc(shortId(t.id)) + " | " +
@@ -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 = "
| 先选项目 |
"; return; }
+ try {
+ var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
+ tasksCache = d.tasks || [];
+ renderTasks();
} catch (e) { $("#taskRows").innerHTML = "
| 加载失败:" + esc(e.message) + " |
"; }
}
@@ -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 += "
" + esc(note) + "";
- 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 + " 条)
";
+ var shown = pool.slice(-TRACE_LIMIT);
+ if (pool.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + pool.length + " 条)
";
+ else if (kind) html += "(按类型 " + esc(kind) + " 过滤,共 " + pool.length + " 条)
";
html += shown.map(traceLine).join("
");
}
if (doneStatus) html += "
链路已结束(" + esc(doneStatus) + ")";
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 = "
已下载完整链路 " + entries.length + " 条";
+ } catch (e) { $("#traceMsg").innerHTML = "
下载失败:" + esc(e.message) + ""; }
+}
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 = "
发送失败:" + esc(e.message) + ""; }
}
+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 "
";
+ }).join("");
+ return "
" + label + " |
";
+ }).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 = "
角色已改为 " + esc(role) + "";
+ } catch (e) {
+ sel.value = prev;
+ $("#memberMsg").innerHTML = "
" + esc(memberErr(e)) + "";
+ }
+ };
+ });
+ } catch (e) { box.innerHTML = "
成员加载失败:" + esc(memberErr(e)) + ""; }
+}
+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 = "
" + esc(memberErr(e2)) + ""; return; }
+ } else {
+ $("#memberMsg").innerHTML = "
" + esc(memberErr(e)) + "";
+ return;
+ }
+ }
+ $("#memberMsg").innerHTML = "
已邀请成员";
+ $("#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 = "
已确认为全局管理员,管理操作可用";
+ 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 = "
" + esc(adminErr(e)) + ""; }
+}
+async function adminSweep() {
+ if (!confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
+ try {
+ var d = await api("/admin/sweep", "POST", {});
+ var swept = d.swept || [];
+ $("#adminResult").innerHTML = "
sweep 完成,清理 " + swept.length + " 个任务" +
+ (swept.length ? ":" + esc(swept.join("、")) : "") + "";
+ } catch (e) { $("#adminResult").innerHTML = "
" + esc(adminErr(e)) + ""; }
+}
+async function adminBackup() {
+ try {
+ var d = await api("/admin/backup", "POST", {});
+ $("#adminResult").innerHTML = "
备份完成:backend=" + esc(d.backend) +
+ " | 路径 " + esc(d.path) + " | 大小 " + esc(d.size_bytes) + " 字节 | 任务 " + esc(d.task_count) + " 行";
+ } catch (e) { $("#adminResult").innerHTML = "
" + esc(adminErr(e)) + ""; }
+}
+
$("#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);