Implement loopback HTTP console with claim new, integrate prepare, and pi packet sharing the same helpers as CLI; ship SPA and MCP brief/claim_archive wrappers.
200 lines
9.0 KiB
HTML
200 lines
9.0 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>agentctl console</title>
|
||
<style>
|
||
:root { --bg:#0f1419; --panel:#1a2332; --text:#e7ecf3; --muted:#8b9bb4; --accent:#3d8bfd; --danger:#e35d6a; --ok:#3dd68c; --border:#2a3548; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; font:14px/1.45 ui-sans-serif, system-ui, sans-serif; background:var(--bg); color:var(--text); }
|
||
header { display:flex; gap:1rem; align-items:center; padding:.75rem 1rem; border-bottom:1px solid var(--border); background:var(--panel); position:sticky; top:0; z-index:2; }
|
||
header h1 { font-size:1rem; margin:0; font-weight:600; letter-spacing:.02em; }
|
||
header .meta { color:var(--muted); flex:1; }
|
||
button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:.35rem .7rem; cursor:pointer; }
|
||
button.ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||
button.danger { background:var(--danger); }
|
||
main { display:grid; grid-template-columns:1fr 1fr; gap:1rem; padding:1rem; }
|
||
section { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem 1rem; min-height:220px; }
|
||
section h2 { margin:0 0 .5rem; font-size:.95rem; }
|
||
table { width:100%; border-collapse:collapse; font-size:12px; }
|
||
th, td { text-align:left; padding:.3rem .25rem; border-bottom:1px solid var(--border); vertical-align:top; }
|
||
th { color:var(--muted); font-weight:500; }
|
||
.empty { color:var(--muted); font-style:italic; }
|
||
.toast { position:fixed; right:1rem; bottom:1rem; max-width:28rem; padding:.75rem 1rem; border-radius:8px; background:#222; border:1px solid var(--border); display:none; white-space:pre-wrap; }
|
||
.toast.err { border-color:var(--danger); color:#ffd0d4; }
|
||
.toast.ok { border-color:var(--ok); }
|
||
dialog { border:1px solid var(--border); border-radius:10px; background:var(--panel); color:var(--text); padding:1rem; max-width:36rem; width:90%; }
|
||
dialog textarea, dialog input, dialog select { width:100%; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.4rem; margin:.25rem 0 .6rem; font:inherit; }
|
||
#brief { grid-column:1 / -1; }
|
||
pre.brief { margin:0; white-space:pre-wrap; font-size:12px; color:var(--muted); max-height:12rem; overflow:auto; }
|
||
@media (max-width:900px) { main { grid-template-columns:1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>agentctl console</h1>
|
||
<div class="meta" id="meta">loading…</div>
|
||
<label><input type="checkbox" id="auto" checked/> 10s</label>
|
||
<button class="ghost" id="refresh">刷新</button>
|
||
<button id="btnClaim">认领…</button>
|
||
<button id="btnPrepare">Prepare…</button>
|
||
<button class="ghost" id="btnArchive" title="dry-run by default in CLI; SPA 仅提示">归档指引</button>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<h2>1. 任务 / 租约</h2>
|
||
<div id="tasks"></div>
|
||
</section>
|
||
<section>
|
||
<h2>2. 队列与集成</h2>
|
||
<div id="queue"></div>
|
||
<p class="empty">Enqueue 请用 CLI:<code>agentctl queue enqueue --task … --expected-dev … --head …</code></p>
|
||
</section>
|
||
<section>
|
||
<h2>3. 文档漂移</h2>
|
||
<div id="drift"></div>
|
||
</section>
|
||
<section>
|
||
<h2>4. 清理积压</h2>
|
||
<div id="cleanup"></div>
|
||
</section>
|
||
<section id="brief">
|
||
<h2>brief</h2>
|
||
<pre class="brief" id="briefBody"></pre>
|
||
</section>
|
||
</main>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<dialog id="dlgClaim">
|
||
<form method="dialog" id="formClaim">
|
||
<h3>claim new</h3>
|
||
<label>task <input name="task" required/></label>
|
||
<label>owner <input name="owner" value="Cursor" required/></label>
|
||
<label>mutex <input name="mutex" value="-"/></label>
|
||
<label><input type="checkbox" name="replace"/> replace</label>
|
||
<p class="empty">须在已注册任务 worktree 上下文由 serve 代其 Repo 执行。</p>
|
||
<menu>
|
||
<button value="cancel" class="ghost">取消</button>
|
||
<button id="confirmClaim" value="default">确认</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="dlgPrepare">
|
||
<form method="dialog" id="formPrepare">
|
||
<h3>integrate prepare</h3>
|
||
<label>task <input name="task" required/></label>
|
||
<label>expected_dev <input name="expected_dev" required placeholder="full sha"/></label>
|
||
<label>integrate_id <input name="integrate_id" placeholder="optional"/></label>
|
||
<menu>
|
||
<button value="cancel" class="ghost">取消</button>
|
||
<button id="confirmPrepare" value="default">确认</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<script>
|
||
const $ = (s) => document.querySelector(s);
|
||
const toast = (msg, ok) => {
|
||
const el = $('#toast');
|
||
el.textContent = msg;
|
||
el.className = 'toast ' + (ok ? 'ok' : 'err');
|
||
el.style.display = 'block';
|
||
setTimeout(() => el.style.display = 'none', 6000);
|
||
};
|
||
async function api(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
const ct = r.headers.get('content-type') || '';
|
||
const body = ct.includes('json') ? await r.json() : await r.text();
|
||
if (!r.ok) {
|
||
const code = body && body.code ? body.code : r.status;
|
||
const msg = body && body.message ? body.message : String(body);
|
||
throw new Error(code + ': ' + msg);
|
||
}
|
||
return body;
|
||
}
|
||
function table(headers, rows) {
|
||
if (!rows.length) return '<p class="empty">(empty)</p>';
|
||
let h = '<table><thead><tr>' + headers.map(x => '<th>'+x+'</th>').join('') + '</tr></thead><tbody>';
|
||
for (const row of rows) h += '<tr>' + row.map(c => '<td>'+esc(c)+'</td>').join('') + '</tr>';
|
||
return h + '</tbody></table>';
|
||
}
|
||
function esc(s) {
|
||
return String(s ?? '-').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
async function refresh() {
|
||
try {
|
||
const [status, queue, claims, cleanup, docs, cat, daemon, brief] = await Promise.all([
|
||
api('/api/status'), api('/api/queue'), api('/api/claims'), api('/api/cleanup'),
|
||
api('/api/docs/drift'), api('/api/catalog/drift'), api('/api/daemon'),
|
||
api('/api/brief?format=json'),
|
||
]);
|
||
const active = (status.tasks||[]).filter(t => t.state !== 'done' && t.state !== 'cancelled');
|
||
$('#meta').textContent = `daemon:${daemon.status||'?'} lock:${daemon.manager_lock&&daemon.manager_lock.held} queue:${(queue.items||[]).length} active:${active.length}`;
|
||
$('#tasks').innerHTML = table(['task','owner','state','worktree'], active.map(t => [t.id, t.owner, t.state, (t.worktree||'').split('/.worktrees/').pop()||t.worktree]))
|
||
+ '<h3 style="font-size:12px;color:var(--muted)">runs</h3>'
|
||
+ table(['run','task','state'], (status.runs||[]).map(r => [r.id, r.task, r.state]))
|
||
+ '<h3 style="font-size:12px;color:var(--muted)">claims</h3>'
|
||
+ table(['task','resource','state'], (claims.claims||[]).map(c => [c.task, c.resource, c.state]));
|
||
$('#queue').innerHTML = table(['task','state','pri','head'], (queue.items||[]).map(i => [i.task, i.state, i.priority, (i.head_sha||'').slice(0,12)]));
|
||
const regs = (docs.regressions||[]).concat(cat.regressions||[]);
|
||
$('#drift').innerHTML = table(['record','prev→cur','msg'], regs.map(r => [r.record_id, (r.previous||r.catalog||'')+'→'+(r.current||r.matrix||''), r.message||'']));
|
||
const trees = cleanup.worktrees||[];
|
||
$('#cleanup').innerHTML = table(['path','allowed','owner'], trees.map(t => {
|
||
const allowed = t.cleanup_allowed || t.status === 'allowed';
|
||
return [(t.path||'').split('/.worktrees/').pop()||t.path, allowed, t.cleanup_owner||'-'];
|
||
}));
|
||
$('#briefBody').textContent = brief.brief || '';
|
||
} catch (e) {
|
||
toast(String(e), false);
|
||
}
|
||
}
|
||
$('#refresh').onclick = refresh;
|
||
let timer = setInterval(refresh, 10000);
|
||
$('#auto').onchange = (e) => {
|
||
clearInterval(timer);
|
||
if (e.target.checked) timer = setInterval(refresh, 10000);
|
||
};
|
||
$('#btnClaim').onclick = () => $('#dlgClaim').showModal();
|
||
$('#btnPrepare').onclick = () => $('#dlgPrepare').showModal();
|
||
$('#btnArchive').onclick = () => toast(
|
||
'归档请用 CLI(需 docs:admin):\nagentctl claim archive --task <id> [--apply]\n或 MCP claim_archive',
|
||
true,
|
||
);
|
||
$('#confirmClaim').onclick = async (ev) => {
|
||
ev.preventDefault();
|
||
const f = $('#formClaim');
|
||
const body = {
|
||
task: f.task.value.trim(),
|
||
owner: f.owner.value.trim(),
|
||
mutex: f.mutex.value.trim() || '-',
|
||
replace: f.replace.checked,
|
||
};
|
||
try {
|
||
const r = await api('/api/claim/new', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||
toast(JSON.stringify(r, null, 2), true);
|
||
$('#dlgClaim').close();
|
||
refresh();
|
||
} catch (e) { toast(String(e), false); }
|
||
};
|
||
$('#confirmPrepare').onclick = async (ev) => {
|
||
ev.preventDefault();
|
||
const f = $('#formPrepare');
|
||
const body = {
|
||
task: f.task.value.trim(),
|
||
expected_dev: f.expected_dev.value.trim(),
|
||
integrate_id: f.integrate_id.value.trim() || undefined,
|
||
};
|
||
try {
|
||
const r = await api('/api/integrate/prepare', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||
toast(JSON.stringify(r, null, 2), true);
|
||
$('#dlgPrepare').close();
|
||
refresh();
|
||
} catch (e) { toast(String(e), false); }
|
||
};
|
||
refresh();
|
||
</script>
|
||
</body>
|
||
</html>
|