Files
codex-app-server/cloud/console.html
T
host dbcc8e641a feat: 任务列表显示 turn_id 与 root_turn_id
host.py dispatch 打印补 turn_id/root_turn_id;执行器解析(local/Docker)透传;worker 写回 tasks 新列(新值非空才覆盖);server 迁移与接口字段;控制台任务列表新增 turn/root turn 两列(短显+悬停全文);新单测 9 个。
2026-10-27 14:52:00 +08:00

969 lines
47 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>
:root {
--bg: #f4f5f7; --card: #ffffff; --text: #1d2129; --muted: #667085;
--border: #e4e7ec; --accent: #2563eb; --accent-soft: #eff4ff;
--accent-dark: #1d4ed8; --success: #067647; --danger: #b42318;
--danger-soft: #fef3f2; --warning: #b54708; --purple: #6941c6;
--radius: 12px; --shadow: 0 1px 2px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.06);
}
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; margin: 0 auto; max-width: 1160px; padding: 24px 20px 48px; color: var(--text); background: var(--bg); line-height: 1.5; }
.hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 20px; }
.hero h1 { font-size: 26px; margin: 0 0 4px; letter-spacing: .2px; }
.hero p { margin: 0; }
.version { flex: none; font-size: 12px; color: var(--muted); border: 1px solid var(--border); background: var(--card); border-radius: 999px; padding: 4px 10px; }
h2 { font-size: 15px; margin: 0 0 12px; }
h3 { font-size: 14px; margin: 20px 0 8px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; margin-bottom: 16px; }
.muted { color: var(--muted); font-size: 13px; }
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 10px 0; }
.card > .bar:first-of-type { margin-top: 0; }
.card > .bar:last-of-type { margin-bottom: 0; }
input, select, textarea, button { font-size: 14px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff; color: var(--text); }
input[type="text"], input[type="password"] { min-width: 260px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea { width: 100%; min-height: 84px; resize: vertical; }
button { cursor: pointer; font-weight: 500; }
button:hover { border-color: #cbd5e1; background: #f8fafc; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
button.danger { color: var(--danger); border-color: #fecdca; background: var(--danger-soft); }
button.danger:hover { background: #fee4e2; }
.table-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: auto; background: var(--card); box-shadow: var(--shadow); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { padding: 9px 12px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
th { background: #f9fafb; color: var(--muted); font-size: 12px; font-weight: 600; white-space: nowrap; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: #f9fafb; }
tr.selected { background: var(--accent-soft); }
tbody tr:last-child td { border-bottom: none; }
.st-queued { color: var(--muted); } .st-running { color: var(--accent); font-weight: 600; }
.st-completed { color: var(--success); } .st-failed, .st-merge-failed { color: var(--danger); }
.st-merged { color: var(--purple); } .st-interrupted { color: var(--warning); }
td.st-queued::before, td.st-running::before, td.st-completed::before,
td.st-failed::before, td.st-merge-failed::before, td.st-merged::before,
td.st-interrupted::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: 1px; }
pre { background: #f8f9fb; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; overflow: auto; max-height: 400px; white-space: pre-wrap; word-break: break-word; font-size: 13px; }
#traceBox { background: #f8f9fb; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; max-height: 420px; overflow: auto; font-size: 13px; line-height: 1.75; color: var(--text); }
.err { color: var(--danger); }
.ok { color: var(--success); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.grid > div { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; }
@media (max-width: 760px) {
.grid { grid-template-columns: 1fr; }
body { padding: 16px 12px 32px; }
input[type="text"], input[type="password"] { min-width: 0; flex: 1; }
.hero { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<header class="hero">
<div>
<h1>Codex 命令台</h1>
<p class="muted">控制面操作台 v1:任务是核心操作对象;成本、workers、健康状态附带查看。公网入口未开时经 SSH 隧道访问本机 8765/8766 端口。</p>
</div>
<span class="version">key 只存本浏览器</span>
</header>
<section class="card">
<h2>API Key</h2>
<div class="bar">
<input id="key" type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autocomplete="off">
<button id="saveKey" class="primary">保存到本浏览器</button>
<button id="clearKey">清除</button>
<span id="keyState" class="muted"></span>
</div>
<div><span id="quotaState" class="muted">未加载</span></div>
</section>
<section class="card">
<h2>健康状态</h2>
<div class="bar">
<span id="health" class="muted">未加载</span>
<button id="refreshHealth">刷新</button>
</div>
</section>
<div class="grid">
<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="新项目名">
<select id="newProjectRepoSelect"><option value="">(从 GitHub App 安装仓库里选,或手动输入)</option></select>
<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>
<section class="card">
<h2>环境 <span class="muted">(选、建、管:当前项目的基准检出)</span></h2>
<div class="bar">
<button id="refreshEnvironments">刷新</button>
<span id="envCount" class="muted"></span>
</div>
<div id="envList" class="muted">先选项目</div>
<div class="bar">
<input id="newEnvName" type="text" placeholder="新环境名">
<input id="newEnvBranch" type="text" placeholder="分支(可选,空=默认分支)">
<select id="newEnvExecutor">
<option value="">执行器:不限</option>
<option value="local">local</option>
<option value="docker">docker</option>
</select>
<button id="createEnvironment">新建环境</button>
</div>
<div class="bar"><span id="envRepoLine" class="muted"></span></div>
<div class="bar" id="envRepoBar" style="display:none">
<select id="envRepoSelect"><option value="">(从 GitHub App 安装仓库里选)</option></select>
<button id="saveEnvRepo">保存仓库</button>
</div>
<span id="envRepoMsg" class="muted"></span>
<span id="envMsg" class="muted"></span>
</section>
<section class="card">
<h2>新建任务</h2>
<div class="bar">
<select id="envSelect"><option value="">环境:不指定(走项目仓库)</option></select>
<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>
<label><input id="planMode" type="checkbox"> 计划模式(只读)</label>
</div>
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
<div class="bar">
<select id="modelSelect">
<option value="">模型:默认(跟随配置)</option>
</select>
<input id="modelCustom" type="text" placeholder="手动输入模型(中转列表取不到时用)" style="display:none">
<select id="effortSelect">
<option value="">推理强度:默认(跟随配置)</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<select id="approvalSelect">
<option value="">权限:默认(跟随配置)</option>
<option value="ask">请求批准</option>
<option value="auto">帮我批准</option>
<option value="full">完全访问</option>
</select>
</div>
<div id="goalHint" style="display:none" class="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div>
<div class="bar">
<span class="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input type="file" id="attFiles" multiple title="文件">
<input type="file" id="attDir" webkitdirectory title="文件夹">
<input type="file" id="attImg" accept="image/*" multiple title="图片">
</div>
<div class="bar">
<button id="createTask" class="primary">提交任务</button>
<span id="taskMsg" class="muted"></span>
</div>
</section>
<section class="card">
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
<div class="bar">
<button id="refreshTasks">刷新</button>
<span id="taskCount" class="muted"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>任务</th><th>turn</th><th>root turn</th><th>prompt</th><th>状态</th><th>executor</th><th>主机</th><th>PR</th><th>更新</th></tr></thead>
<tbody id="taskRows"><tr><td colspan="9" class="muted">先选项目</td></tr></tbody>
</table>
</div>
</section>
<section class="card">
<h2>任务详情</h2>
<div id="detail" class="muted">点任务行查看详情</div>
<div class="bar" id="detailActions" style="display:none">
<button id="btnRetry">重试</button>
<button id="btnCancel" class="danger">取消</button>
<button id="btnMerge">合并(completed → merged)</button>
<button id="btnPr">提 PR(须 merged)</button>
<button id="btnFork">分支</button>
<button id="btnCost">查成本</button>
<span id="actionMsg" class="muted"></span>
</div>
<div id="forkForm" style="display:none">
<input id="forkMsg" type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<div class="bar"><button id="btnForkConfirm" class="primary">创建分支任务</button><span id="forkMsgSpan" class="muted"></span></div>
</div>
<div id="continueForm" style="display:none">
<h3>继续会话(旧任务接回来,同一会话再跑一轮;不传的选项沿用任务原值)</h3>
<textarea id="continueText" placeholder="本轮要说的话(必填)"></textarea>
<div class="bar">
<select id="continueModel"><option value="">模型:沿用任务原值</option></select>
<select id="continueEffort">
<option value="">强度:沿用任务原值</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<select id="continueApproval">
<option value="">权限:沿用任务原值</option>
<option value="ask">请求批准</option>
<option value="auto">帮我批准</option>
<option value="full">完全访问</option>
</select>
<label><input type="checkbox" id="continuePlan"> 计划模式(只读)</label>
<label><input type="checkbox" id="continueGoal"> goal 模式(不限轮次,模型宣告完成)</label>
</div>
<div class="bar"><button id="btnContinueConfirm" class="primary">继续会话</button><span id="continueMsgSpan" class="muted"></span></div>
</div>
<pre id="detailBody" style="display:none"></pre>
<h3>子代理链路</h3>
<div id="agentBox" class="muted">点任务行查看详情后,这里显示子代理链路</div>
<h3>实时链路</h3>
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
<div id="costBox"></div>
<h3>后续消息</h3>
<div id="followupForm">
<textarea id="followupText" placeholder="后续消息(任务运行中/排队中可用;排队不打断当前轮,直接发送跑完当前轮后优先执行)"></textarea>
<div class="bar">
<button id="btnQueueFollowup">排队</button>
<button id="btnDirectFollowup" class="primary">直接发送</button>
<span id="followupMsg" class="muted"></span>
</div>
</div>
<div id="followupList" class="muted"></div>
<h3>给其他任务发消息</h3>
<div id="peerMessageForm">
<input id="peerTarget" placeholder="目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)" style="width:100%">
<textarea id="peerBody" placeholder="消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)"></textarea>
<div class="bar">
<button id="btnQueuePeerMessage">排队发给对方</button>
<button id="btnDirectPeerMessage" class="primary">直接发送</button>
<span id="peerMsg" class="muted"></span>
</div>
</div>
<div id="peerMessageList" class="muted"></div>
</section>
<script>
"use strict";
var $ = function (s) { return document.querySelector(s); };
var KEY_STORE = "codex-console-key";
var currentTask = null;
var traceAbort = null;
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
var TRACE_LIMIT = 1000;
var TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
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 += "(触发限流,稍后再试)";
if (r.status === 402) msg += "(本月配额已用完,联系管理员调整)";
throw new Error(msg);
}
return data;
}
async function loadRelayModels() {
// 中转模型下拉:服务端现取现回,只含模型 id。取不到时不挡
// 提交,留手动输入框给用户填。
var sel = $("#modelSelect");
try {
var d = await api("/relay/models", "GET");
var models = d.models || [];
var opts = models.map(function (m) {
return "<option value='" + esc(m) + "'>" + esc(m) + "</option>";
}).join("");
sel.innerHTML = "<option value=''>模型:默认(跟随配置)</option>" + opts;
var csel = $("#continueModel");
if (csel) csel.innerHTML = "<option value=''>模型:沿用任务原值</option>" + opts;
$("#modelCustom").style.display = "none";
} catch (e) {
$("#modelCustom").style.display = "inline";
}
}
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 loadQuota() {
// 本月配额与用量:只读展示,管理员改配额走 API(/admin/quotas)。
var el = $("#quotaState");
if (!getKey()) { el.textContent = "未填 key,配额不显示"; return; }
try {
var d = await api("/users/me/quota", "GET");
var used = (d.used_usd_micros / 1000000).toFixed(2);
if (d.quota_usd_micros === null || d.quota_usd_micros === undefined) {
el.textContent = "本月用量 $" + used + "(不限额)";
} else {
var q = (d.quota_usd_micros / 1000000).toFixed(2);
var rem = (d.remaining_usd_micros / 1000000).toFixed(2);
el.textContent = "本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
}
} catch (e) { el.textContent = "配额加载失败:" + e.message; }
}
var projectsCache = [];
var githubReposCache = [];
async function loadGithubRepos() {
try {
var d = await api("/github/repos", "GET");
githubReposCache = d.repos || [];
var opts = "<option value=''>(从 GitHub App 安装仓库里选,或手动输入)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
var s1 = $("#newProjectRepoSelect"); if (s1) s1.innerHTML = opts;
var s2 = $("#envRepoSelect");
if (s2) s2.innerHTML = "<option value=''>(从 GitHub App 安装仓库里选)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
} catch (e) { /* 未登录或无安装记录时只留手动输入,不报错 */ }
}
async function loadProjects() {
try {
var d = await api("/projects", "GET");
projectsCache = d.projects || [];
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 loadEnvironments() {
var pid = $("#projectSelect").value;
var sel = $("#envSelect");
var cur = sel.value;
if (!pid) {
$("#envList").textContent = "先选项目";
$("#envCount").textContent = "";
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
return;
}
try {
var d = await api("/environments?project_id=" + encodeURIComponent(pid), "GET");
var envs = d.environments || [];
$("#envCount").textContent = envs.length + " 个环境";
if (!envs.length) { $("#envList").textContent = "该项目暂无环境(先建一个,或不指定环境直接建任务)"; }
else {
$("#envList").innerHTML = envs.map(function (e) {
var btn = e.status === "archived" ? "" : " <button data-env-archive='" + esc(e.id) + "'>归档</button>";
var err = e.status === "failed" && e.error_text ? " / 失败原因:" + esc(e.error_text) : "";
return esc(e.name) + "(" + esc(shortId(e.id)) + ") / 分支 " + esc(e.branch || "默认分支") +
" / 执行器 " + esc(e.executor || "不限") + " / 状态 " + esc(e.status) + err + btn + "<br>";
}).join("");
Array.prototype.forEach.call(document.querySelectorAll("[data-env-archive]"), function (b) {
b.onclick = async function () {
if (!confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
try { await api("/environments/" + encodeURIComponent(b.getAttribute("data-env-archive")) + "/archive", "POST", {}); loadEnvironments(); }
catch (e) { $("#envMsg").innerHTML = "<span class='err'>归档失败:" + esc(e.message) + "</span>"; }
};
});
}
// 任务创建的环境下拉:只列未归档环境,未 ready 的置灰(creating
// 时可看到它在准备,ready 后才能选);刷新时尽量保留已选值。
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
envs.forEach(function (e) {
var o = document.createElement("option");
o.value = e.id; o.textContent = e.name + "(" + e.status + ")";
if (e.status !== "ready") o.disabled = true;
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur && !o.disabled; })) sel.value = cur;
// 项目仓库行:有仓库就显示;没仓库就亮出补仓库的下拉(截图里
// 那个 400 的死结就卡在这里——不先补仓库,环境永远建不出来)。
var proj = null;
for (var i = 0; i < projectsCache.length; i++) { if (projectsCache[i].id === pid) { proj = projectsCache[i]; break; } }
if (proj && proj.repo_url) {
$("#envRepoLine").textContent = "仓库:" + proj.repo_url;
$("#envRepoBar").style.display = "none";
} else if (proj) {
$("#envRepoLine").textContent = "该项目未绑定仓库,环境无法创建——先选一个仓库保存";
$("#envRepoBar").style.display = "";
} else {
$("#envRepoLine").textContent = "";
$("#envRepoBar").style.display = "none";
}
} catch (e) {
$("#envList").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>";
}
}
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 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 title='" + esc(t.turn_id || "") + "'>" + esc(shortId(t.turn_id)) + "</td>" +
"<td title='" + esc(t.root_turn_id || "") + "'>" + esc(shortId(t.root_turn_id)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + (t.environment_id ? "[环境 " + esc(shortId(t.environment_id)) + "] " : "") + ((t.attachments && t.attachments.length) ? "[附件×" + t.attachments.length + "] " : "") + 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='9' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
}
function stopTrace() {
if (traceAbort) { try { traceAbort.abort(); } catch (e) {} traceAbort = null; }
if (agentTimer) { clearTimeout(agentTimer); agentTimer = null; }
}
function traceLine(e) {
var k = TRACE_KIND[e.kind] || e.kind || "?";
var body = "";
if (e.kind === "exec") body = esc(e.command || "") + "(exit=" + esc(e.exitCode) + "," + esc(e.durationMs) + "ms,cwd=" + esc(e.cwd || "") + ")";
else if (e.kind === "file") body = esc(e.path || "") + " " + esc(e.change || "") + " " + esc(e.status || "");
else if (e.kind === "message") body = (e.phase ? "[" + esc(e.phase) + "] " : "") + esc(e.text || "");
else if (e.kind === "reasoning") body = esc(e.summary || "");
else if (e.kind === "tool") body = esc(e.server || "") + "/" + esc(e.tool || "") + " " + esc(e.status || "");
else if (e.kind === "call") body = esc(e.name || "");
else if (e.kind === "plan") body = esc(e.text || "");
else if (e.kind === "agent-start") body = "开始:" + esc(e.name || "") + (e.prompt ? "(" + esc(e.prompt) + ")" : "");
else if (e.kind === "agent-done") body = "结束:" + esc(e.name || "") + " " + esc(e.status || "");
else body = esc(JSON.stringify(e).slice(0, 300));
return "[" + k + "] " + body;
}
function renderTrace(entries, note, doneStatus) {
var box = $("#traceBox");
var html = "";
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
if (!entries.length) html += "(暂无链路条目)";
else {
var shown = entries.slice(-TRACE_LIMIT);
if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
}
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
box.innerHTML = html;
}
var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
var AGENT_CALL_SHOWN = 50;
var agentTimer = null;
function renderAgents(data) {
var box = $("#agentBox");
var html = "";
if (!data) { box.innerHTML = "(暂无子代理链路)"; return; }
if (data.note) html += "<span class='err'>" + esc(data.note) + "</span><br>";
var agents = data.agents || [];
if (!agents.length) html += "(暂无子代理)";
agents.forEach(function (a) {
var st = AGENT_STATUS[a.status] || a.status || "?";
html += "<div style='margin:6px 0;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:#fff'>";
html += "<b>" + esc(AGENT_KIND[a.kind] || a.kind || "") + ":</b>" + esc(a.name || "") +
" <span class='st-" + esc(a.status) + "'>" + esc(st) + "</span>" +
" | 过程调用 " + (a.calls ? a.calls.length : 0) + " 条" +
(a.updated_ts ? " | 最近活动 " + fmt(a.updated_ts / 1000) : "") + "<br>";
if (a.prompt) html += "<span class='muted'>任务:" + esc(a.prompt) + "</span><br>";
if (a.calls && a.calls.length) {
var shown = a.calls.slice(-AGENT_CALL_SHOWN);
if (a.calls.length > AGENT_CALL_SHOWN) html += "(仅显示最近 " + AGENT_CALL_SHOWN + " 条,共 " + a.calls.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
} else html += "<span class='muted'>(暂无过程调用)</span>";
html += "</div>";
});
box.innerHTML = html;
}
async function loadAgents(taskId) {
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/agents", "GET");
renderAgents(d);
} catch (e) { $("#agentBox").innerHTML = "<span class='err'>子代理链路加载失败:" + esc(e.message) + "</span>"; }
}
function scheduleAgents(taskId) {
if (agentTimer) clearTimeout(agentTimer);
agentTimer = setTimeout(function () { loadAgents(taskId); }, 1000);
}
async function startTrace(taskId, status) {
stopTrace();
var box = $("#traceBox");
if (TRACE_TERMINAL.indexOf(status) >= 0) {
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/trace", "GET");
renderTrace(d.entries || [], d.note || "", d.status);
} catch (e) { box.innerHTML = "<span class='err'>链路加载失败:" + esc(e.message) + "</span>"; }
return;
}
// 运行中:fetch 流式读 SSE(EventSource 不能带 X-API-Key 头,故不用它)。
var entries = [];
var index = {};
var note = "";
var done = false;
var doneStatus = "";
box.innerHTML = "链路连接中…";
var ctrl = new AbortController();
traceAbort = ctrl;
try {
var headers = {};
var key = getKey();
if (key) headers["X-API-Key"] = key;
var r = await fetch("/tasks/" + encodeURIComponent(taskId) + "/trace/stream", { headers: headers, signal: ctrl.signal });
if (!r.ok) {
var msg = "HTTP " + r.status;
try { var dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch (e) {}
if (r.status === 401) msg += "(key 未填或无效)";
throw new Error(msg);
}
var reader = r.body.getReader();
var decoder = new TextDecoder();
var buf = "";
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split("\n\n");
buf = parts.pop();
parts.forEach(function (part) {
var line = part.split("\n").filter(function (l) { return l.indexOf("data: ") === 0; }).map(function (l) { return l.slice(6); }).join("\n");
if (!line) return;
try {
var m = JSON.parse(line);
if (m.type === "snapshot") {
entries = m.entries || []; index = {};
entries.forEach(function (e, i) { index[e.id] = i; });
note = m.note || "";
renderTrace(entries, note, "");
scheduleAgents(taskId);
} else if (m.type === "entry") {
var e = m.entry;
if (e.id in index) entries[index[e.id]] = e; else { index[e.id] = entries.length; entries.push(e); }
renderTrace(entries, note, "");
scheduleAgents(taskId);
} else if (m.type === "done") {
done = true; doneStatus = m.status;
renderTrace(entries, note, doneStatus);
}
} catch (e) {}
});
if (done) break;
}
} catch (e) {
if (e && e.name === "AbortError") return;
box.innerHTML = "<span class='err'>链路流中断:" + esc(e.message) + "</span>";
return;
}
if (done) { loadTasks(); showDetail(taskId); }
}
async function showDetail(taskId) {
stopTrace();
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 || "") +
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
" | 权限 " + esc(t.approval || "默认") +
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
(t.goal_mode ? " | <b>goal 模式(不限轮次,模型宣告完成)</b>" : (t.verify_command ? " | goal 模式(旧语义,验收命令)" : "")) +
((t.attachments && t.attachments.length) ? " | 附件:" + t.attachments.map(esc).join("、") : "") +
(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";
$("#btnFork").style.display = TRACE_TERMINAL.indexOf(t.status) >= 0 ? "" : "none";
$("#actionMsg").textContent = "";
$("#costBox").innerHTML = "";
$("#forkForm").style.display = "none";
$("#forkMsgSpan").textContent = "";
$("#followupMsg").textContent = "";
$("#followupForm").style.display = ["queued", "running"].indexOf(t.status) >= 0 ? "block" : "none";
$("#continueMsgSpan").textContent = "";
$("#continueForm").style.display = (TRACE_TERMINAL.indexOf(t.status) >= 0 && t.status !== "merged") ? "block" : "none";
startTrace(taskId, t.status);
$("#agentBox").innerHTML = "子代理链路加载中…";
loadAgents(taskId);
loadFollowups(taskId);
loadPeerMessages(taskId);
$("#peerMsg").textContent = "";
} 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>"; }
}
async function loadFollowups(taskId) {
var box = $("#followupList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/followups", "GET");
var list = d.followups || [];
if (!list.length) { box.innerHTML = "(暂无后续消息)"; return; }
box.innerHTML = list.map(function (f) {
var from = f.sender_task_id ? "来自任务 " + esc(String(f.sender_task_id).slice(0, 8)) + "|" : "";
return "[" + esc(f.mode) + "|" + esc(f.status) + "] " + from + esc(String(f.body || "").slice(0, 120));
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>后续消息加载失败:" + esc(e.message) + "</span>"; }
}
async function postFollowup(mode) {
if (!currentTask) return;
var text = $("#followupText").value.trim();
if (!text) { $("#followupMsg").textContent = "后续消息不能为空"; return; }
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/followups", "POST", { body: text, mode: mode });
$("#followupMsg").innerHTML = "<span class='ok'>已" + (mode === "direct" ? "直接发送" : "排队") + "</span>";
$("#followupText").value = "";
loadFollowups(currentTask.id);
} catch (e) { $("#followupMsg").innerHTML = "<span class='err'>发送失败:" + 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();
// 保存后立即重载数据:此前保存完页面还停留在旧的报错上,
// 用户会误以为 key 无效(2026-10-27 实测)。
loadHealth();
loadProjects();
loadWorkers();
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
if (getKey()) loadRelayModels();
loadQuota();
loadGithubRepos();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#refreshEnvironments").onclick = loadEnvironments;
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); };
$("#goalMode").onchange = function () { $("#goalHint").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>"; }
};
$("#createEnvironment").onclick = async function () {
var pid = $("#projectSelect").value;
var name = $("#newEnvName").value.trim();
if (!pid) { $("#envMsg").textContent = "先选项目"; return; }
if (!name) { $("#envMsg").textContent = "环境名必填"; return; }
var body = { project_id: pid, name: name };
var br = $("#newEnvBranch").value.trim();
if (br) body.branch = br;
var ex = $("#newEnvExecutor").value;
if (ex) body.executor = ex;
try {
await api("/environments", "POST", body);
$("#envMsg").innerHTML = "<span class='ok'>环境已建,worker 准备中(creating → ready,失败会在列表里写原因)</span>";
$("#newEnvName").value = ""; $("#newEnvBranch").value = "";
loadEnvironments();
} catch (e) { $("#envMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#newProjectRepoSelect").onchange = function () {
var v = this.value;
if (v) $("#newProjectRepo").value = v;
};
$("#saveEnvRepo").onclick = async function () {
var pid = $("#projectSelect").value;
var repo = $("#envRepoSelect").value;
if (!pid) { $("#envRepoMsg").textContent = "先选项目"; return; }
if (!repo) { $("#envRepoMsg").textContent = "先选一个仓库"; return; }
try {
await api("/projects/" + encodeURIComponent(pid), "PATCH", { repo_url: repo });
$("#envRepoMsg").innerHTML = "<span class='ok'>仓库已保存,现在可以建环境了</span>";
loadProjects(); loadEnvironments();
} catch (e) { $("#envRepoMsg").innerHTML = "<span class='err'>保存失败:" + esc(e.message) + "</span>"; }
};
function gatherAttachments() {
// 三路输入合并成 [{path, file}]:文件路用文件名,文件夹路用
// webkitRelativePath(含子目录),图片路用文件名;同路径只留
// 第一个(跨路重复以先选为准,服务端还会再校验一次)。
var out = [], seen = {};
function add(list, pathOf) {
for (var i = 0; i < list.length; i++) {
var p = pathOf(list[i]);
if (!p || seen[p]) continue;
seen[p] = true;
out.push({ path: p, file: list[i] });
}
}
add($("#attFiles").files, function (f) { return f.name; });
add($("#attDir").files, function (f) { return f.webkitRelativePath || f.name; });
add($("#attImg").files, function (f) { return f.name; });
return out;
}
async function uploadAttachments(items) {
// 先 POST /uploads 拿 upload_id,建任务时绑定;上传失败直接
// 抛错,任务不建(避免无附件的任务先跑起来)。注意不能复用
// api()(它会固定写 Content-Type: application/json,multipart
// 的 boundary 必须由浏览器自己生成)。
var fd = new FormData();
fd.append("paths", JSON.stringify(items.map(function (x) { return x.path; })));
items.forEach(function (x, i) { fd.append("file" + i, x.file); });
var r = await fetch("/uploads", {
method: "POST",
headers: { "X-API-Key": getKey() },
body: fd
});
var d = await r.json().catch(function () { return {}; });
if (!r.ok) throw new Error((d && d.error) || ("HTTP " + r.status));
return d.upload_id;
}
$("#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 envId = $("#envSelect").value;
if (envId) body.environment_id = envId;
var ex = $("#executor").value;
if (ex) body.executor = ex;
var model = $("#modelCustom").value.trim() || $("#modelSelect").value;
if (model) body.model = model;
var eff = $("#effortSelect").value;
if (eff) body.effort = eff;
var ap = $("#approvalSelect").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
body.approval = ap;
}
if ($("#planMode").checked) {
if (ap === "full") { $("#taskMsg").textContent = "计划模式不能与完全访问同用"; return; }
if ($("#goalMode").checked) { $("#taskMsg").textContent = "计划模式不能与 goal 模式同用"; return; }
body.plan = 1;
}
if ($("#goalMode").checked) {
// 新 goal 语义:prompt 本身就是目标;不发专用 goal 文本、
// 验收命令与轮次(服务端 goal_mode=1 时一律不限轮次)。
body.goal_mode = 1;
}
var atts = gatherAttachments();
if (atts.length) {
try {
$("#taskMsg").textContent = "上传附件中…";
body.upload_id = await uploadAttachments(atts);
} catch (e) {
$("#taskMsg").innerHTML = "<span class='err'>附件上传失败:" + esc(e.message) + "</span>";
return;
}
}
try {
var t = await api("/tasks", "POST", body);
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
$("#prompt").value = "";
$("#attFiles").value = ""; $("#attDir").value = ""; $("#attImg").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"); };
$("#btnFork").onclick = function () {
if (!currentTask) return;
if (TRACE_TERMINAL.indexOf(currentTask.status) < 0) { $("#forkMsgSpan").textContent = "仅终态任务可分支"; return; }
$("#forkForm").style.display = "block";
};
$("#btnForkConfirm").onclick = async function () {
if (!currentTask) return;
var msg = $("#forkMsg").value.trim();
if (!msg) { $("#forkMsgSpan").textContent = "分支消息必填"; return; }
try {
var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/fork", "POST", { message: msg });
$("#forkMsgSpan").innerHTML = "<span class='ok'>分支任务已建:" + esc(shortId(t.id)) + "</span>";
$("#forkMsg").value = "";
loadTasks();
} catch (e) { $("#forkMsgSpan").innerHTML = "<span class='err'>分支失败:" + esc(e.message) + "</span>"; }
};
$("#btnContinueConfirm").onclick = async function () {
if (!currentTask) return;
var msg = $("#continueText").value.trim();
if (!msg) { $("#continueMsgSpan").textContent = "本轮消息必填"; return; }
var body = { message: msg };
var model = $("#continueModel").value;
if (model) body.model = model;
var eff = $("#continueEffort").value;
if (eff) body.effort = eff;
var ap = $("#continueApproval").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认继续吗?")) return;
body.approval = ap;
}
if ($("#continuePlan").checked) body.plan = 1;
if ($("#continueGoal").checked) body.goal_mode = 1;
try {
var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/continue", "POST", body);
$("#continueMsgSpan").innerHTML = "<span class='ok'>已重新排队,同一会话继续</span>";
$("#continueText").value = "";
showDetail(currentTask.id);
loadTasks();
} catch (e) { $("#continueMsgSpan").innerHTML = "<span class='err'>继续失败:" + esc(e.message) + "</span>"; }
};
async function loadPeerMessages(taskId) {
var box = $("#peerMessageList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/messages", "GET");
var list = d.messages || [];
if (!list.length) { box.innerHTML = "(本任务尚未发出任务间消息)"; return; }
box.innerHTML = list.map(function (f) {
return "[发给 " + esc(String(f.task_id).slice(0, 8)) + "|" + esc(f.mode) + "|" + esc(f.status) + "] " + esc(String(f.body || "").slice(0, 120));
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>发出消息加载失败:" + esc(e.message) + "</span>"; }
}
async function postPeerMessage(mode) {
if (!currentTask) return;
var target = $("#peerTarget").value.trim();
var text = $("#peerBody").value.trim();
if (!target) { $("#peerMsg").textContent = "目标任务 id 不能为空"; return; }
if (!text) { $("#peerMsg").textContent = "消息正文不能为空"; return; }
try {
var d = await api("/tasks/" + encodeURIComponent(target) + "/messages", "POST", { from_task_id: currentTask.id, body: text, mode: mode });
$("#peerMsg").innerHTML = "<span class='ok'>已发送(" + esc(d.delivery) + ")</span>";
$("#peerBody").value = "";
loadPeerMessages(currentTask.id);
} catch (e) { $("#peerMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
}
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
$("#btnQueuePeerMessage").onclick = function () { postPeerMessage("queued"); };
$("#btnDirectPeerMessage").onclick = function () { postPeerMessage("direct"); };
$("#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();
loadQuota();
loadGithubRepos();
// 回访用户 key 已在 localStorage 里:初始化也要加载模型列表,
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
if (getKey()) loadRelayModels();
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);
</script>
</body>
</html>