mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 13:05:18 +02:00
render() ran after load()'s ordered failure path and after shownLoad had moved on. A snapshot the board could not show (a session whose detectedHarnesses is stored as an object) threw past the ordering, and an older valid snapshot could no longer take the board. render() now runs inside that path, and shownLoad moves only once it returns. Run posted state.query, the query typed last. When an older load succeeds after a newer one failed, the board shows the older query's results and recall command while Run acted on the newer query. Each load now keeps its query, render() records it for the board, and Run posts that. The test harness assigns new arrays instead of pushing onto page.pending, and records requests the same way.
750 lines
24 KiB
JavaScript
750 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
function renderControlPaneHtml() {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ECC Control Pane</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #101312;
|
|
--panel: #181d1b;
|
|
--panel-2: #202622;
|
|
--ink: #f4f0e8;
|
|
--muted: #aab3aa;
|
|
--line: #344038;
|
|
--accent: #6fd8b5;
|
|
--accent-2: #e6c35c;
|
|
--danger: #ff7a72;
|
|
--blue: #82aaff;
|
|
--shadow: rgba(0, 0, 0, 0.28);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
min-width: 320px;
|
|
}
|
|
|
|
button, input {
|
|
font: inherit;
|
|
}
|
|
|
|
.shell {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 18px;
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #121715;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 4;
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 180px;
|
|
}
|
|
|
|
.brand img {
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 18px;
|
|
line-height: 1.1;
|
|
margin: 0;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.query {
|
|
flex: 1;
|
|
display: flex;
|
|
gap: 8px;
|
|
min-width: 220px;
|
|
max-width: 780px;
|
|
}
|
|
|
|
input[type="search"] {
|
|
width: 100%;
|
|
color: var(--ink);
|
|
background: #0c0f0e;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 9px 10px;
|
|
outline: none;
|
|
}
|
|
|
|
input[type="search"]:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
button {
|
|
color: var(--ink);
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 9px 12px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
button:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
button.primary {
|
|
color: #06100c;
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
font-weight: 700;
|
|
}
|
|
|
|
main {
|
|
padding: 18px;
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
|
align-items: start;
|
|
}
|
|
|
|
.metrics {
|
|
display: grid;
|
|
gap: 10px;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric,
|
|
section {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
box-shadow: 0 12px 30px var(--shadow);
|
|
}
|
|
|
|
.metric {
|
|
padding: 12px;
|
|
min-height: 84px;
|
|
}
|
|
|
|
.metric span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.metric strong {
|
|
display: block;
|
|
margin-top: 8px;
|
|
font-size: 26px;
|
|
line-height: 1;
|
|
}
|
|
|
|
section {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: #151a18;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.subtle {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
th, td {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
font-size: 13px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
th {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0;
|
|
background: #131816;
|
|
}
|
|
|
|
.stack {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.result,
|
|
.connector,
|
|
.work-item,
|
|
.action {
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.result:last-child,
|
|
.connector:last-child,
|
|
.work-item:last-child,
|
|
.action:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.kanban {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 8px;
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
|
}
|
|
|
|
.kanban-lane {
|
|
min-width: 0;
|
|
padding: 9px;
|
|
border: 1px solid rgba(52, 64, 56, 0.8);
|
|
border-radius: 6px;
|
|
background: #141917;
|
|
}
|
|
|
|
.kanban-lane span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.kanban-lane strong {
|
|
display: block;
|
|
margin-top: 6px;
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: start;
|
|
min-width: 0;
|
|
}
|
|
|
|
.row > * {
|
|
min-width: 0;
|
|
}
|
|
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 22px;
|
|
padding: 2px 7px;
|
|
border-radius: 999px;
|
|
background: #222a26;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pill.good { color: #07110d; background: var(--accent); }
|
|
.pill.warn { color: #171000; background: var(--accent-2); }
|
|
.pill.bad { color: #190706; background: var(--danger); }
|
|
.pill.blue { color: #071020; background: var(--blue); }
|
|
|
|
code {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 9px;
|
|
background: #0c0f0e;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
color: #d6e7dc;
|
|
overflow-x: auto;
|
|
white-space: pre;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#run-output {
|
|
min-height: 44px;
|
|
max-height: 260px;
|
|
}
|
|
|
|
#app {
|
|
position: fixed;
|
|
right: 16px;
|
|
bottom: 16px;
|
|
max-width: min(640px, calc(100vw - 32px));
|
|
max-height: 45vh;
|
|
overflow: auto;
|
|
padding: 12px 14px;
|
|
background: #190706;
|
|
border: 1px solid var(--danger);
|
|
border-radius: 8px;
|
|
color: #ffe5e2;
|
|
box-shadow: 0 12px 30px var(--shadow);
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
z-index: 10;
|
|
}
|
|
|
|
.empty {
|
|
padding: 18px 14px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
@media (max-width: 1040px) {
|
|
main { grid-template-columns: 1fr; }
|
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
header { align-items: stretch; flex-direction: column; }
|
|
.query { max-width: none; width: 100%; }
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
main { padding: 12px; }
|
|
.metrics { grid-template-columns: 1fr; }
|
|
.kanban { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.query { flex-direction: column; }
|
|
th:nth-child(4), td:nth-child(4) { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header>
|
|
<div class="brand">
|
|
<img src="/assets/ecc-icon.svg" alt="">
|
|
<h1>ECC Control Pane</h1>
|
|
</div>
|
|
<form class="query" id="query-form">
|
|
<input id="query" type="search" placeholder="Recall operator memory, session context, runbooks">
|
|
<button class="primary" type="submit">Recall</button>
|
|
<button type="button" id="refresh">Refresh</button>
|
|
</form>
|
|
</header>
|
|
<main>
|
|
<div class="stack">
|
|
<div class="metrics" id="metrics"></div>
|
|
<section>
|
|
<div class="section-head">
|
|
<h2>Sessions</h2>
|
|
<span class="subtle" id="db-path"></span>
|
|
</div>
|
|
<div id="sessions"></div>
|
|
</section>
|
|
<section>
|
|
<div class="section-head">
|
|
<h2>Work Items</h2>
|
|
<span class="subtle" id="work-item-count"></span>
|
|
</div>
|
|
<div id="work-items"></div>
|
|
</section>
|
|
</div>
|
|
<div class="stack">
|
|
<section>
|
|
<div class="section-head">
|
|
<h2>Knowledge</h2>
|
|
<span class="subtle" id="knowledge-count"></span>
|
|
</div>
|
|
<div id="knowledge"></div>
|
|
</section>
|
|
<section>
|
|
<div class="section-head">
|
|
<h2>Connectors</h2>
|
|
<span class="subtle" id="connector-count"></span>
|
|
</div>
|
|
<div id="connectors"></div>
|
|
</section>
|
|
<section>
|
|
<div class="section-head">
|
|
<h2>Actions</h2>
|
|
<span class="subtle" id="action-status">local allowlist</span>
|
|
</div>
|
|
<div id="actions"></div>
|
|
<div class="action">
|
|
<code id="run-output">No action output yet.</code>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<div id="app" hidden></div>
|
|
<script>
|
|
const state = { query: '', shownQuery: '' };
|
|
const $ = selector => document.querySelector(selector);
|
|
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
})[char]);
|
|
const fmt = new Intl.NumberFormat('en-US');
|
|
|
|
function formatError(error) {
|
|
if (!error) return 'Unknown error';
|
|
return error.stack || error.message || String(error);
|
|
}
|
|
|
|
function showError(targetSelector, error) {
|
|
const target = $(targetSelector);
|
|
if (!target) return;
|
|
target.hidden = false;
|
|
target.textContent = formatError(error);
|
|
}
|
|
|
|
function clearError(targetSelector) {
|
|
const target = $(targetSelector);
|
|
if (!target) return;
|
|
target.hidden = true;
|
|
target.textContent = '';
|
|
}
|
|
|
|
// The board keeps the last snapshot on screen, so a failed refresh has to
|
|
// say that the data is no longer live, and since when.
|
|
let loadedAt = null;
|
|
// Loads are numbered as they start and finish in any order. The board shows
|
|
// the newest data any load brought, and the error box the outcome of the
|
|
// newest load that has finished, so a load that finishes late can neither
|
|
// replace newer data nor overrule a newer outcome.
|
|
let loadsStarted = 0;
|
|
let newestFinished = 0;
|
|
let shownLoad = 0;
|
|
let failure = null;
|
|
function showRefreshFailure(error) {
|
|
const since = loadedAt
|
|
? ' The data below is from ' + loadedAt.toLocaleString() + '.'
|
|
: '';
|
|
showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error));
|
|
}
|
|
function showFailure() {
|
|
if (failure.live) showRefreshFailure(failure.error);
|
|
else showError('#app', failure.error);
|
|
}
|
|
|
|
async function readJsonResponse(response) {
|
|
let payload;
|
|
try {
|
|
payload = await response.json();
|
|
} catch (error) {
|
|
throw new Error('Expected JSON response from control pane: ' + error.message);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const detail = payload && payload.error ? payload.error : response.status + ' ' + response.statusText;
|
|
throw new Error(detail);
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function statePill(stateName) {
|
|
const state = String(stateName || 'unknown');
|
|
const klass = ['running', 'done'].includes(state)
|
|
? 'good'
|
|
: ['failed', 'blocked'].includes(state)
|
|
? 'bad'
|
|
: ['pending', 'ready'].includes(state)
|
|
? 'warn'
|
|
: 'blue';
|
|
return '<span class="pill ' + klass + '">' + escapeHtml(state) + '</span>';
|
|
}
|
|
|
|
function renderMetrics(summary) {
|
|
const items = [
|
|
['Sessions', summary.totalSessions],
|
|
['Running', summary.runningSessions],
|
|
['Unread', summary.unreadMessages],
|
|
['Tokens', fmt.format(summary.totalTokens || 0)],
|
|
];
|
|
$('#metrics').innerHTML = items.map(([label, value]) =>
|
|
'<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
|
|
).join('');
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
if (!sessions.length) {
|
|
$('#sessions').innerHTML = '<div class="empty">No ECC2 sessions found.</div>';
|
|
return;
|
|
}
|
|
|
|
$('#sessions').innerHTML = '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
|
sessions.map(session => '<tr>' +
|
|
'<td>' + statePill(session.state) + '</td>' +
|
|
'<td><strong>' + escapeHtml(session.id) + '</strong><br><span class="subtle">' + escapeHtml(session.task) + '</span></td>' +
|
|
'<td>' + escapeHtml(session.agentType || session.harness) + '<br><span class="subtle">' + escapeHtml((session.detectedHarnesses || []).join(', ')) + '</span></td>' +
|
|
'<td>' + escapeHtml(session.worktree ? session.worktree.branch || session.worktree.path : '-') + '</td>' +
|
|
'<td>' + escapeHtml(session.updatedAt || '-') + '</td>' +
|
|
'</tr>').join('') +
|
|
'</tbody></table>';
|
|
}
|
|
|
|
function renderWorkItems(workItems) {
|
|
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
|
const items = Array.isArray(summary.items) ? summary.items : [];
|
|
const kanban = summary.kanban || {};
|
|
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
|
|
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
|
|
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
|
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
|
|
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
|
|
|
|
const lanes = ['ready', 'running', 'blocked', 'done'];
|
|
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
|
|
'<div class="kanban-lane"><span>' + escapeHtml(lane) + '</span><strong>' + escapeHtml(kanban[lane] || 0) + '</strong></div>'
|
|
).join('') + '</div>';
|
|
|
|
if (!items.length) {
|
|
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
|
|
return;
|
|
}
|
|
|
|
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
|
|
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
|
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
|
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
|
const assigneeKind = item.assigneeKind || 'unassigned';
|
|
const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned';
|
|
// Ids/lanes go into HTML-escaped data-* attributes and are read back via
|
|
// dataset in delegated listeners below — never concatenated into inline
|
|
// JS handlers — so a crafted work-item id cannot inject script (XSS).
|
|
const idAttr = escapeHtml(item.id);
|
|
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane =>
|
|
'<button type="button" data-wi-action="move" data-wi-id="' + idAttr + '" data-wi-lane="' + lane + '">' + escapeHtml(lane) + '</button>'
|
|
).join('');
|
|
const controls = state.allowActions
|
|
? '<div class="row">'
|
|
+ (assigneeKind === 'unassigned' ? '<button type="button" data-wi-action="claim" data-wi-id="' + idAttr + '">Claim</button>' : '')
|
|
+ moveButtons
|
|
+ '</div>'
|
|
: '';
|
|
return '<div class="work-item">' +
|
|
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
|
|
'<div class="subtle">[' + escapeHtml(assigneeKind) + '] ' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
|
|
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
|
|
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
|
|
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
|
|
controls +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
document.querySelectorAll('#work-items [data-wi-action]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const id = button.getAttribute('data-wi-id');
|
|
if (button.getAttribute('data-wi-action') === 'claim') {
|
|
eccClaimItem(id);
|
|
} else {
|
|
eccMoveItem(id, button.getAttribute('data-wi-lane'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderKnowledge(knowledge) {
|
|
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
|
|
if (!knowledge.results.length) {
|
|
$('#knowledge').innerHTML = '<div class="empty">No recall results for this query.</div>';
|
|
return;
|
|
}
|
|
|
|
$('#knowledge').innerHTML = knowledge.results.map(result => {
|
|
const entity = result.entity;
|
|
const obs = result.latestObservation;
|
|
return '<div class="result">' +
|
|
'<div class="row"><strong>' + escapeHtml(entity.name) + '</strong><span class="pill good">score ' + escapeHtml(result.score) + '</span></div>' +
|
|
'<div class="subtle">' + escapeHtml(entity.entityType) + (entity.path ? ' - ' + escapeHtml(entity.path) : '') + '</div>' +
|
|
'<div>' + escapeHtml(entity.summary || '') + '</div>' +
|
|
(obs ? '<div class="subtle">' + (result.hasPinnedObservation ? 'Pinned - ' : '') + escapeHtml(obs.summary) + '</div>' : '') +
|
|
'<div class="subtle">terms: ' + escapeHtml((result.matchedTerms || []).join(', ') || '-') + '</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderConnectors(connectors) {
|
|
$('#connector-count').textContent = connectors.length + ' configured';
|
|
if (!connectors.length) {
|
|
$('#connectors').innerHTML = '<div class="empty">No memory connectors configured.</div>';
|
|
return;
|
|
}
|
|
|
|
$('#connectors').innerHTML = connectors.map(connector => {
|
|
const status = connector.syncedSources > 0 ? '<span class="pill good">synced</span>' : '<span class="pill warn">not synced</span>';
|
|
return '<div class="connector">' +
|
|
'<div class="row"><strong>' + escapeHtml(connector.name) + '</strong>' + status + '</div>' +
|
|
'<div class="subtle">' + escapeHtml(connector.kind) + ' - ' + escapeHtml(connector.path || '-') + '</div>' +
|
|
'<div class="subtle">sources ' + escapeHtml(connector.syncedSources) + ' - last ' + escapeHtml(connector.lastSyncedAt || '-') + '</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderActions(actions) {
|
|
$('#actions').innerHTML = actions.map(action => '<div class="action">' +
|
|
'<div class="row"><strong>' + escapeHtml(action.label) + '</strong>' +
|
|
(action.executable ? '<button data-action="' + escapeHtml(action.id) + '">Run</button>' : '<span class="pill">copy</span>') + '</div>' +
|
|
'<div class="subtle">' + escapeHtml(action.description) + '</div>' +
|
|
'<code>' + escapeHtml(action.commandLine) + '</code>' +
|
|
'</div>').join('');
|
|
|
|
document.querySelectorAll('[data-action]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
runAction(button.dataset.action);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runAction(actionId) {
|
|
const output = $('#run-output');
|
|
output.textContent = 'Running ' + actionId + '...';
|
|
|
|
try {
|
|
// An action runs for the query its card on the board was built for.
|
|
// After a failed load that is not always the query typed last.
|
|
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ query: state.shownQuery })
|
|
});
|
|
const payload = await readJsonResponse(response);
|
|
output.textContent = JSON.stringify(payload, null, 2);
|
|
await load();
|
|
} catch (error) {
|
|
output.textContent = formatError(error);
|
|
}
|
|
}
|
|
|
|
async function load(live = false) {
|
|
const id = ++loadsStarted;
|
|
const query = state.query;
|
|
try {
|
|
const url = new URL('/api/snapshot', window.location.href);
|
|
if (query) url.searchParams.set('query', query);
|
|
const response = await fetch(url);
|
|
const snapshot = await readJsonResponse(response);
|
|
// A snapshot that cannot be shown fails its load like one that could
|
|
// not be fetched, and older data may still take the board.
|
|
if (id > shownLoad) {
|
|
render(snapshot, query);
|
|
shownLoad = id;
|
|
}
|
|
} catch (error) {
|
|
if (id < newestFinished) return;
|
|
newestFinished = id;
|
|
failure = { error, live };
|
|
showFailure();
|
|
return;
|
|
}
|
|
if (id < newestFinished) {
|
|
// A newer load failed first. This data is still the newest on the
|
|
// board, so the failure stays, dated by it.
|
|
if (failure && id === shownLoad) showFailure();
|
|
return;
|
|
}
|
|
newestFinished = id;
|
|
failure = null;
|
|
clearError('#app');
|
|
}
|
|
|
|
function render(snapshot, query) {
|
|
$('#query').value = snapshot.knowledge.query || state.query;
|
|
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
|
|
state.allowActions = Boolean(snapshot.execution.allowActions);
|
|
$('#action-status').textContent = state.allowActions ? 'local allowlist' : 'read-only';
|
|
renderMetrics(snapshot.summary);
|
|
renderSessions(snapshot.sessions);
|
|
renderWorkItems(snapshot.workItems);
|
|
renderKnowledge(snapshot.knowledge);
|
|
renderConnectors(snapshot.connectors);
|
|
renderActions(snapshot.actions.map(action => ({
|
|
...action,
|
|
executable: snapshot.execution.allowActions && action.executable
|
|
})));
|
|
state.shownQuery = query;
|
|
loadedAt = new Date();
|
|
}
|
|
|
|
$('#query-form').addEventListener('submit', event => {
|
|
event.preventDefault();
|
|
state.query = $('#query').value.trim();
|
|
load().catch(error => showError('#app', error));
|
|
});
|
|
$('#refresh').addEventListener('click', () => {
|
|
load().catch(error => showError('#app', error));
|
|
});
|
|
|
|
async function postWorkItem(pathSuffix, payload) {
|
|
const response = await fetch('/api/work-items/' + pathSuffix, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload || {})
|
|
});
|
|
const result = await readJsonResponse(response);
|
|
if (!result.ok) throw new Error(result.error || 'request failed');
|
|
await load();
|
|
}
|
|
|
|
window.eccClaimItem = function (id) {
|
|
if (!state.allowActions) return;
|
|
const owner = window.prompt('Claim "' + id + '" as (owner name):');
|
|
if (!owner) return;
|
|
const as = (window.prompt("Owner kind: 'agent' or 'human'", 'human') || '').trim().toLowerCase();
|
|
postWorkItem(encodeURIComponent(id) + '/claim', { owner: owner.trim(), as: as === 'agent' ? 'agent' : 'human' })
|
|
.catch(error => showError('#app', error));
|
|
};
|
|
window.eccMoveItem = function (id, lane) {
|
|
if (!state.allowActions) return;
|
|
postWorkItem(encodeURIComponent(id) + '/move', { lane })
|
|
.catch(error => showError('#app', error));
|
|
};
|
|
|
|
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
|
|
setInterval(() => {
|
|
if (document.hidden) return;
|
|
load(true).catch(error => showError('#app', error));
|
|
}, 15000);
|
|
|
|
load().catch(error => showError('#app', error));
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
module.exports = {
|
|
renderControlPaneHtml
|
|
};
|