Files
codex-app-server/cloud/console.html
T
2026-10-27 01:02:24 +08:00

326 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<!--
命令台 v1:纯静态单页,API 用绝对路径,假定与控制面同源同根挂载
(即本页由 server.py 的 /console 直接 serve)。数据一律凭用户在
本页输入的 API key 调接口;key 只存在本浏览器 localStorage,
不经过任何第三方。页面本身不走鉴权(只是个壳,没有 key 时
调不到任何数据);日后若经 nginx 子路径挂载,需改 API 前缀。
-->
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0 auto; max-width: 1100px; padding: 16px; color: #222; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 24px 0 8px; }
.muted { color: #666; font-size: 13px; }
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
input, select, textarea, button { font-size: 14px; padding: 6px 8px; }
input[type="text"], input[type="password"] { min-width: 260px; }
textarea { width: 100%; min-height: 70px; box-sizing: border-box; }
button { cursor: pointer; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
tr { cursor: pointer; }
tr.selected { background: #eef6ff; }
.st-queued { color: #666; } .st-running { color: #1565c0; font-weight: bold; }
.st-completed { color: #2e7d32; } .st-failed, .st-merge-failed { color: #c62828; }
.st-merged { color: #6a1b9a; } .st-interrupted { color: #e65100; }
pre { background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px; white-space: pre-wrap; word-break: break-word; }
.err { color: #c62828; }
.ok { color: #2e7d32; }
.row { display: flex; gap: 16px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 300px; }
</style>
</head>
<body>
<h1>Codex 命令台</h1>
<p class="muted">控制面操作台 v1:任务是核心操作对象;成本、workers、健康状态附带查看。公网入口未开时经 SSH 隧道访问本机 8765/8766 端口。</p>
<h2>API Key</h2>
<div class="bar">
<input id="key" type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autocomplete="off">
<button id="saveKey">保存到本浏览器</button>
<button id="clearKey">清除</button>
<span id="keyState" class="muted"></span>
</div>
<h2>健康状态</h2>
<div class="bar">
<span id="health" class="muted">未加载</span>
<button id="refreshHealth">刷新</button>
</div>
<div class="row">
<div>
<h2>项目</h2>
<div class="bar">
<select id="projectSelect"><option value="">(先加载项目)</option></select>
<button id="refreshProjects">刷新</button>
</div>
<div class="bar">
<input id="newProjectName" type="text" placeholder="新项目名">
<input id="newProjectRepo" type="text" placeholder="repo_url(可选)">
<button id="createProject">新建项目</button>
</div>
<span id="projectMsg" class="muted"></span>
</div>
<div>
<h2>Workers</h2>
<div class="bar"><button id="refreshWorkers">刷新</button></div>
<div id="workers" class="muted">未加载</div>
</div>
</div>
<h2>新建任务</h2>
<div class="bar">
<select id="executor">
<option value="">executor:任意 worker</option>
<option value="local">local</option>
<option value="docker">docker</option>
<option value="cloud">cloud</option>
</select>
<label><input id="goalMode" type="checkbox"> goal 模式</label>
</div>
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
<div id="goalFields" style="display:none">
<input id="goal" type="text" placeholder="goal(缺省用 prompt)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<input id="verifyCommand" type="text" placeholder="verify_command 验收命令(goal 模式必填项按服务端规则)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<label>轮次 <input id="goalRounds" type="number" value="3" min="1" style="width:60px;"></label>
</div>
<div class="bar">
<button id="createTask">提交任务</button>
<span id="taskMsg" class="muted"></span>
</div>
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
<div class="bar">
<button id="refreshTasks">刷新</button>
<span id="taskCount" class="muted"></span>
</div>
<table>
<thead><tr><th>任务</th><th>prompt</th><th>状态</th><th>executor</th><th>主机</th><th>PR</th><th>更新</th></tr></thead>
<tbody id="taskRows"><tr><td colspan="7" class="muted">先选项目</td></tr></tbody>
</table>
<h2>任务详情</h2>
<div id="detail" class="muted">点任务行查看详情</div>
<div class="bar" id="detailActions" style="display:none">
<button id="btnRetry">重试</button>
<button id="btnCancel">取消</button>
<button id="btnMerge">合并(completed → merged)</button>
<button id="btnPr">提 PR(须 merged)</button>
<button id="btnCost">查成本</button>
<span id="actionMsg" class="muted"></span>
</div>
<pre id="detailBody" style="display:none"></pre>
<div id="costBox"></div>
<script>
"use strict";
var $ = function (s) { return document.querySelector(s); };
var KEY_STORE = "codex-console-key";
var currentTask = null;
function esc(s) {
return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmt(ts) {
if (!ts) return "";
return new Date(ts * 1000).toLocaleString();
}
function shortId(id) { return id ? id.slice(0, 8) : ""; }
function getKey() { return localStorage.getItem(KEY_STORE) || ""; }
function setKeyState() {
$("#keyState").textContent = getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401";
}
async function api(path, method, body) {
var headers = { "Content-Type": "application/json" };
var key = getKey();
if (key) headers["X-API-Key"] = key;
var opt = { method: method || "GET", headers: headers };
if (body !== undefined) opt.body = JSON.stringify(body);
var r = await fetch(path, opt);
var data = null;
try { data = await r.json(); } catch (e) { data = null; }
if (!r.ok) {
var msg = (data && data.error) ? data.error : ("HTTP " + r.status);
if (r.status === 401) msg += "(key 未填或无效)";
if (r.status === 429) msg += "(触发限流,稍后再试)";
throw new Error(msg);
}
return data;
}
async function loadHealth() {
try {
var h = await api("/health", "GET");
$("#health").textContent = "backend=" + h.backend + " db_ok=" + h.db_ok +
" worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7);
} catch (e) { $("#health").textContent = "加载失败:" + e.message; }
}
async function loadProjects() {
try {
var d = await api("/projects", "GET");
var sel = $("#projectSelect");
var cur = sel.value;
sel.innerHTML = "";
(d.projects || []).forEach(function (p) {
var o = document.createElement("option");
o.value = p.id; o.textContent = p.name + " (" + shortId(p.id) + ")";
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur; })) sel.value = cur;
$("#projectMsg").textContent = (d.projects || []).length + " 个项目";
} catch (e) { $("#projectMsg").textContent = "加载失败:" + e.message; }
}
async function loadWorkers() {
try {
var d = await api("/workers", "GET");
var list = d.workers || d || [];
if (!list.length) { $("#workers").textContent = "无 worker 注册"; return; }
$("#workers").innerHTML = list.map(function (w) {
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
" / capacity " + esc(w.capacity || "") +
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
}).join("<br>");
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
}
async function loadTasks() {
var pid = $("#projectSelect").value;
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='7' 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='7' class='muted'>该项目暂无任务</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>" +
"<td>" + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
"<td>" + esc(t.executor || "") + "</td>" +
"<td>" + esc(t.worktree_host || "") + "</td>" +
"<td>" + pr + "</td>" +
"<td>" + esc(fmt(t.updated_at)) + "</td></tr>";
}).join("");
Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) {
tr.onclick = function () { showDetail(tr.getAttribute("data-id")); };
});
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
}
async function showDetail(taskId) {
try {
var t = await api("/tasks/" + encodeURIComponent(taskId), "GET");
currentTask = t;
document.querySelectorAll("#taskRows tr").forEach(function (tr) {
tr.classList.toggle("selected", tr.getAttribute("data-id") === taskId);
});
$("#detail").innerHTML =
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b> | " +
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
"<br>prompt:<br>" + esc(t.prompt || "");
var body = "";
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
if (!body) body = "(暂无 result/error 输出)";
var pre = $("#detailBody");
pre.textContent = body;
pre.style.display = "block";
$("#detailActions").style.display = "flex";
$("#actionMsg").textContent = "";
$("#costBox").innerHTML = "";
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
}
async function doAction(kind) {
if (!currentTask) return;
var confirmNeeded = { cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)" };
if (confirmNeeded[kind] && !confirm(confirmNeeded[kind])) return;
try {
var r = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/" + kind, "POST", {});
$("#actionMsg").innerHTML = "<span class='ok'>" + kind + " 已提交:" + esc(JSON.stringify(r).slice(0, 300)) + "</span>";
loadTasks();
showDetail(currentTask.id);
} catch (e) { $("#actionMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
}
$("#saveKey").onclick = function () {
var v = $("#key").value.trim();
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
localStorage.setItem(KEY_STORE, v);
$("#key").value = "";
setKeyState();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#projectSelect").onchange = loadTasks;
$("#goalMode").onchange = function () { $("#goalFields").style.display = this.checked ? "block" : "none"; };
$("#createProject").onclick = async function () {
var name = $("#newProjectName").value.trim();
if (!name) { $("#projectMsg").textContent = "项目名必填"; return; }
var body = { name: name };
var repo = $("#newProjectRepo").value.trim();
if (repo) body.repo_url = repo;
try {
await api("/projects", "POST", body);
$("#projectMsg").innerHTML = "<span class='ok'>项目已建</span>";
$("#newProjectName").value = ""; $("#newProjectRepo").value = "";
loadProjects();
} catch (e) { $("#projectMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#createTask").onclick = async function () {
var pid = $("#projectSelect").value;
var prompt = $("#prompt").value.trim();
if (!pid) { $("#taskMsg").textContent = "先选项目"; return; }
if (!prompt) { $("#taskMsg").textContent = "prompt 必填"; return; }
var body = { project_id: pid, prompt: prompt };
var ex = $("#executor").value;
if (ex) body.executor = ex;
if ($("#goalMode").checked) {
body.goal = $("#goal").value.trim() || prompt;
body.verify_command = $("#verifyCommand").value.trim();
body.goal_max_rounds = parseInt($("#goalRounds").value, 10) || 3;
}
try {
var t = await api("/tasks", "POST", body);
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
$("#prompt").value = "";
loadTasks();
} catch (e) { $("#taskMsg").innerHTML = "<span class='err'>提交失败:" + esc(e.message) + "</span>"; }
};
$("#btnRetry").onclick = function () { doAction("retry"); };
$("#btnCancel").onclick = function () { doAction("cancel"); };
$("#btnMerge").onclick = function () { doAction("merge"); };
$("#btnPr").onclick = function () { doAction("pr"); };
$("#btnCost").onclick = async function () {
if (!currentTask) return;
try {
var c = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/cost", "GET");
$("#costBox").innerHTML = "<pre>" + esc(JSON.stringify(c, null, 2).slice(0, 4000)) + "</pre>";
} catch (e) { $("#costBox").innerHTML = "<span class='err'>查成本失败:" + esc(e.message) + "</span>"; }
};
setKeyState();
loadHealth();
loadProjects();
loadWorkers();
setInterval(function () { if ($("#projectSelect").value) loadTasks(); loadHealth(); }, 10000);
</script>
</body>
</html>