orchestrator/dashboard.html
OpSpawn Agent 3301dfa081 KB viewer/editor: full CRUD UI, DELETE API, 41 tests
Add knowledge base management to dashboard: clickable topic tags,
inline content viewer, edit/create modal, delete with confirmation.
New DELETE /api/knowledge/:topic endpoint and deleteKnowledge() function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 09:24:19 +00:00

530 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpSpawn Orchestrator</title>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #7d8590; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
.header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.header h1 { font-size: 20px; font-weight: 600; }
.header h1 span { color: var(--accent); }
.meta { color: var(--muted); font-size: 13px; }
.meta .live { color: var(--green); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 24px; max-width: 1400px; margin: 0 auto; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.card-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.card-body { padding: 16px; }
.card.full { grid-column: 1 / -1; }
.ws { margin-bottom: 16px; }
.ws:last-child { margin-bottom: 0; }
.ws-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; cursor: pointer; }
.ws-name { font-weight: 600; font-size: 15px; }
.ws-priority { background: var(--accent); color: #000; font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 10px; }
.ws-stats { color: var(--muted); font-size: 13px; margin-left: auto; }
.progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 8px; }
.progress-fill { height: 100%; background: var(--green); transition: width 0.3s; }
.task-list { list-style: none; }
.task { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
.task:last-child { border-bottom: none; }
.task-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.task-status.pending { background: var(--muted); }
.task-status.in_progress { background: var(--yellow); }
.task-status.done { background: var(--green); }
.task-title { flex: 1; }
.task-title.done { color: var(--muted); text-decoration: line-through; }
.task-agent { color: var(--purple); font-size: 12px; }
.task-id { color: var(--muted); font-size: 11px; font-family: monospace; }
.agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.agent-row:last-child { border-bottom: none; }
.agent-dot { width: 8px; height: 8px; border-radius: 50%; }
.agent-dot.active { background: var(--green); }
.agent-dot.stale { background: var(--red); }
.agent-name { font-weight: 500; font-size: 14px; }
.agent-type { color: var(--muted); font-size: 12px; }
.agent-seen { color: var(--muted); font-size: 12px; margin-left: auto; }
.event-row { display: flex; gap: 8px; padding: 4px 0; font-size: 13px; font-family: monospace; border-bottom: 1px solid var(--border); }
.event-row:last-child { border-bottom: none; }
.event-time { color: var(--muted); white-space: nowrap; }
.event-agent { color: var(--purple); min-width: 60px; }
.event-action { color: var(--accent); }
.kb-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.kb-tag { background: var(--border); color: var(--text); padding: 4px 12px; border-radius: 16px; font-size: 13px; cursor: pointer; border: 1px solid transparent; transition: border-color 0.2s; }
.kb-tag:hover { border-color: var(--accent); }
.kb-tag.active { border-color: var(--accent); background: rgba(88,166,255,0.15); }
.kb-viewer { margin-top: 8px; }
.kb-content { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 16px; font-size: 13px; line-height: 1.7; white-space: pre-wrap; font-family: monospace; max-height: 400px; overflow-y: auto; }
.kb-content h1, .kb-content h2, .kb-content h3 { font-family: inherit; margin: 8px 0 4px; color: var(--accent); }
.kb-toolbar { display: flex; gap: 6px; margin-top: 8px; }
.kb-editor { width: 100%; min-height: 250px; background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 12px; border-radius: 6px; font-size: 13px; font-family: monospace; line-height: 1.6; resize: vertical; }
.kb-editor:focus { border-color: var(--accent); outline: none; }
.lock-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
.lock-row:last-child { border-bottom: none; }
.lock-icon { color: var(--yellow); }
.empty { color: var(--muted); font-size: 13px; font-style: italic; }
/* Action buttons */
.btn { background: var(--border); color: var(--text); border: none; padding: 3px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit; }
.btn:hover { background: var(--muted); }
.btn-accent { background: var(--accent); color: #000; }
.btn-accent:hover { opacity: 0.85; }
.btn-green { background: var(--green); color: #000; }
.btn-green:hover { opacity: 0.85; }
.btn-sm { padding: 2px 6px; font-size: 11px; }
.task-actions { display: flex; gap: 4px; flex-shrink: 0; }
/* Inline forms */
.inline-form { display: flex; gap: 6px; padding: 8px 0; align-items: center; }
.inline-form input, .inline-form select { background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 4px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; }
.inline-form input:focus, .inline-form select:focus { border-color: var(--accent); outline: none; }
.inline-form input[type="text"] { flex: 1; min-width: 120px; }
.add-task-btn { margin-top: 6px; }
/* Modal overlay */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.show { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; width: 400px; max-width: 90vw; }
.modal h3 { margin-bottom: 12px; font-size: 16px; }
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; margin-top: 10px; }
.modal input, .modal select { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 6px 10px; border-radius: 4px; font-size: 13px; font-family: inherit; }
.modal input:focus, .modal select:focus { border-color: var(--accent); outline: none; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.summary-row { display: flex; gap: 24px; padding: 16px 24px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.summary-stat { text-align: center; }
.summary-stat .num { font-size: 28px; font-weight: 700; }
.summary-stat .label { font-size: 12px; color: var(--muted); text-transform: uppercase; }
.summary-stat .num.green { color: var(--green); }
.summary-stat .num.yellow { color: var(--yellow); }
.summary-stat .num.muted { color: var(--muted); }
.summary-stat .num.accent { color: var(--accent); }
</style>
</head>
<body>
<div class="header">
<h1><span>&#9670;</span> OpSpawn Orchestrator</h1>
<div class="meta">
<span class="live">&#9679;</span> Auto-refresh 5s &middot;
v<span id="version">-</span> &middot;
Updated <span id="updated">-</span>
</div>
</div>
<div class="summary-row">
<div class="summary-stat"><div class="num accent" id="s-workstreams">-</div><div class="label">Workstreams</div></div>
<div class="summary-stat"><div class="num muted" id="s-pending">-</div><div class="label">Pending</div></div>
<div class="summary-stat"><div class="num yellow" id="s-active">-</div><div class="label">In Progress</div></div>
<div class="summary-stat"><div class="num green" id="s-done">-</div><div class="label">Completed</div></div>
<div class="summary-stat"><div class="num accent" id="s-agents">-</div><div class="label">Agents</div></div>
<div class="summary-stat"><div class="num" id="s-events">-</div><div class="label">Events</div></div>
</div>
<div class="grid">
<div class="card full">
<div class="card-header">Workstreams &amp; Tasks <button class="btn btn-accent btn-sm" style="margin-left:auto" onclick="showAddWorkstream()">+ Workstream</button></div>
<div class="card-body" id="workstreams"><div class="empty">Loading...</div></div>
</div>
<!-- Add Workstream Modal -->
<div class="modal-overlay" id="ws-modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal">
<h3>Add Workstream</h3>
<label>Name</label>
<input type="text" id="ws-name" placeholder="e.g. revenue">
<label>Description</label>
<input type="text" id="ws-desc" placeholder="Optional description">
<label>Priority</label>
<select id="ws-priority"><option value="1">P1 - Critical</option><option value="2" selected>P2 - High</option><option value="3">P3 - Medium</option><option value="4">P4 - Low</option></select>
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('ws-modal').classList.remove('show')">Cancel</button>
<button class="btn btn-accent" onclick="createWorkstream()">Create</button>
</div>
</div>
</div>
<!-- Claim Task Modal -->
<div class="modal-overlay" id="claim-modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal">
<h3>Claim Task</h3>
<p id="claim-task-title" style="font-size:13px;color:var(--muted);margin-bottom:8px"></p>
<label>Agent Name</label>
<input type="text" id="claim-agent" placeholder="e.g. opspawn-main" value="opspawn">
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('claim-modal').classList.remove('show')">Cancel</button>
<button class="btn btn-accent" onclick="claimTask()">Claim</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Agents</div>
<div class="card-body" id="agents"><div class="empty">Loading...</div></div>
</div>
<div class="card">
<div class="card-header">Active Locks</div>
<div class="card-body" id="locks"><div class="empty">Loading...</div></div>
</div>
<div class="card full">
<div class="card-header">Recent Events</div>
<div class="card-body" id="events"><div class="empty">Loading...</div></div>
</div>
<div class="card full">
<div class="card-header">Knowledge Base <button class="btn btn-accent btn-sm" style="margin-left:auto" onclick="showKBEditor()">+ New Topic</button></div>
<div class="card-body" id="knowledge"><div class="empty">Loading...</div></div>
</div>
<!-- KB Editor Modal -->
<div class="modal-overlay" id="kb-modal" onclick="if(event.target===this)closeKBModal()">
<div class="modal" style="width:600px">
<h3 id="kb-modal-title">New Knowledge Entry</h3>
<label>Topic (filename without .md)</label>
<input type="text" id="kb-topic" placeholder="e.g. architecture-decisions">
<label>Content (Markdown)</label>
<textarea class="kb-editor" id="kb-editor-content" placeholder="# Topic Title&#10;&#10;Write your knowledge entry here..."></textarea>
<div class="modal-actions">
<button class="btn" onclick="closeKBModal()">Cancel</button>
<button class="btn btn-accent" onclick="saveKBEntry()">Save</button>
</div>
</div>
</div>
</div>
<script>
async function refresh() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('fetch failed');
const d = await res.json();
document.getElementById('version').textContent = d.version;
document.getElementById('updated').textContent = timeAgo(d.updated_at);
// Summary
let totalPending = 0, totalActive = 0, totalDone = 0;
d.workstreams.forEach(ws => { totalPending += ws.pending; totalActive += ws.in_progress; totalDone += ws.done; });
document.getElementById('s-workstreams').textContent = d.workstreams.length;
document.getElementById('s-pending').textContent = totalPending;
document.getElementById('s-active').textContent = totalActive;
document.getElementById('s-done').textContent = totalDone;
document.getElementById('s-agents').textContent = d.agents.length;
document.getElementById('s-events').textContent = d.recent_events.length;
// Workstreams
const wsEl = document.getElementById('workstreams');
if (d.workstreams.length === 0) {
wsEl.innerHTML = '<div class="empty">No workstreams configured</div>';
} else {
wsEl.innerHTML = d.workstreams.map(ws => {
const total = ws.task_count || 1;
const pct = Math.round((ws.done / total) * 100);
const tasks = (ws.tasks || []).sort((a, b) => {
const order = { in_progress: 0, pending: 1, done: 2 };
return (order[a.status] ?? 1) - (order[b.status] ?? 1) || a.priority - b.priority;
});
return `<div class="ws">
<div class="ws-header">
<span class="ws-priority">P${ws.priority}</span>
<span class="ws-name">${esc(ws.name)}</span>
${ws.description ? `<span style="color:var(--muted);font-size:13px">${esc(ws.description)}</span>` : ''}
<span class="ws-stats">${ws.pending}p / ${ws.in_progress}a / ${ws.done}d</span>
</div>
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
<ul class="task-list">${tasks.map(t => `<li class="task">
<span class="task-status ${t.status}"></span>
<span class="task-title ${t.status === 'done' ? 'done' : ''}">${esc(t.title)}</span>
${t.assigned_to ? `<span class="task-agent">${esc(t.assigned_to)}</span>` : ''}
<span class="task-id">${t.id}</span>
<span class="task-actions">
${t.status === 'pending' ? `<button class="btn btn-accent btn-sm" onclick="showClaim('${esc(ws.name)}','${t.id}','${esc(t.title)}')">Claim</button>` : ''}
${t.status === 'in_progress' ? `<button class="btn btn-green btn-sm" onclick="completeTask('${esc(ws.name)}','${t.id}')">Done</button>` : ''}
</span>
</li>`).join('')}</ul>
<div class="add-task-btn">
<div id="add-form-${esc(ws.name)}" style="display:none" class="inline-form">
<input type="text" id="add-title-${esc(ws.name)}" placeholder="Task title" onkeydown="if(event.key==='Enter')addTask('${esc(ws.name)}')">
<button class="btn btn-accent btn-sm" onclick="addTask('${esc(ws.name)}')">Add</button>
<button class="btn btn-sm" onclick="hideAddTask('${esc(ws.name)}')">X</button>
</div>
<button class="btn btn-sm" onclick="showAddTask('${esc(ws.name)}')" id="add-btn-${esc(ws.name)}">+ Add Task</button>
</div>
</div>`;
}).join('');
}
// Agents
const agEl = document.getElementById('agents');
if (d.agents.length === 0) {
agEl.innerHTML = '<div class="empty">No agents registered</div>';
} else {
agEl.innerHTML = d.agents.map(a => `<div class="agent-row">
<span class="agent-dot ${a.stale ? 'stale' : 'active'}"></span>
<span class="agent-name">${esc(a.id)}</span>
<span class="agent-type">${esc(a.type)}</span>
<span class="agent-seen">${timeAgo(a.last_seen)}</span>
</div>`).join('');
}
// Locks
const lockEl = document.getElementById('locks');
if (d.active_locks.length === 0) {
lockEl.innerHTML = '<div class="empty">No active locks</div>';
} else {
lockEl.innerHTML = d.active_locks.map(l => `<div class="lock-row">
<span class="lock-icon">&#128274;</span>
<strong>${esc(l.resource)}</strong>
<span style="color:var(--muted)">held by</span>
<span style="color:var(--purple)">${esc(l.agent)}</span>
<span style="color:var(--muted);margin-left:auto">${timeAgo(l.acquired_at)}</span>
</div>`).join('');
}
// Events
const evEl = document.getElementById('events');
if (d.recent_events.length === 0) {
evEl.innerHTML = '<div class="empty">No events recorded</div>';
} else {
evEl.innerHTML = [...d.recent_events].reverse().map(e => {
const time = e.ts.split('T')[1].split('.')[0];
return `<div class="event-row">
<span class="event-time">${time}</span>
<span class="event-agent">${esc(e.agent)}</span>
<span class="event-action">${esc(e.action)}</span>
</div>`;
}).join('');
}
// Restore open add-task forms after refresh
openAddForms.forEach(wsName => {
const form = document.getElementById('add-form-' + wsName);
if (form) form.style.display = 'flex';
});
// Knowledge
const kbEl = document.getElementById('knowledge');
if (d.knowledge_topics.length === 0) {
kbEl.innerHTML = '<div class="empty">No knowledge entries</div>';
} else {
const tagHtml = d.knowledge_topics.map(t =>
`<span class="kb-tag${activeKBTopic === t ? ' active' : ''}" onclick="viewKBTopic('${esc(t)}')">${esc(t)}</span>`
).join('');
const viewerHtml = activeKBTopic && activeKBContent !== null
? `<div class="kb-viewer">
<div class="kb-content">${esc(activeKBContent)}</div>
<div class="kb-toolbar">
<button class="btn btn-accent btn-sm" onclick="editKBTopic('${esc(activeKBTopic)}')">Edit</button>
<button class="btn btn-sm" style="color:var(--red)" onclick="deleteKBTopic('${esc(activeKBTopic)}')">Delete</button>
<button class="btn btn-sm" onclick="closeKBViewer()">Close</button>
</div>
</div>`
: '';
kbEl.innerHTML = '<div class="kb-list">' + tagHtml + '</div>' + viewerHtml;
}
} catch (e) {
console.error('Refresh failed:', e);
}
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function timeAgo(iso) {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
// State for modals and open forms
let claimWs = '', claimId = '';
let openAddForms = new Set(); // track which workstream add-task forms are open
let activeKBTopic = null, activeKBContent = null;
let kbEditMode = false; // true = editing existing, false = creating new
function showAddWorkstream() {
document.getElementById('ws-name').value = '';
document.getElementById('ws-desc').value = '';
document.getElementById('ws-priority').value = '2';
document.getElementById('ws-modal').classList.add('show');
document.getElementById('ws-name').focus();
}
async function createWorkstream() {
const name = document.getElementById('ws-name').value.trim();
if (!name) return;
await fetch('/api/workstreams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: document.getElementById('ws-desc').value.trim(),
priority: parseInt(document.getElementById('ws-priority').value)
})
});
document.getElementById('ws-modal').classList.remove('show');
refresh();
}
function showAddTask(wsName) {
openAddForms.add(wsName);
const form = document.getElementById('add-form-' + wsName);
if (form) { form.style.display = 'flex'; document.getElementById('add-title-' + wsName).focus(); }
}
async function addTask(wsName) {
const input = document.getElementById('add-title-' + wsName);
const title = input.value.trim();
if (!title) return;
await fetch('/api/workstreams/' + encodeURIComponent(wsName) + '/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
input.value = '';
openAddForms.delete(wsName);
refresh();
}
function hideAddTask(wsName) {
openAddForms.delete(wsName);
const form = document.getElementById('add-form-' + wsName);
if (form) form.style.display = 'none';
}
function showClaim(wsName, taskId, title) {
claimWs = wsName; claimId = taskId;
document.getElementById('claim-task-title').textContent = title;
document.getElementById('claim-modal').classList.add('show');
document.getElementById('claim-agent').focus();
}
async function claimTask() {
const agent = document.getElementById('claim-agent').value.trim();
if (!agent) return;
await fetch('/api/workstreams/' + encodeURIComponent(claimWs) + '/tasks/' + claimId + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent })
});
document.getElementById('claim-modal').classList.remove('show');
refresh();
}
async function completeTask(wsName, taskId) {
await fetch('/api/workstreams/' + encodeURIComponent(wsName) + '/tasks/' + taskId + '/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
refresh();
}
// --- Knowledge Base functions ---
async function viewKBTopic(topic) {
if (activeKBTopic === topic) { closeKBViewer(); return; }
try {
const res = await fetch('/api/knowledge/' + encodeURIComponent(topic));
if (!res.ok) throw new Error('Not found');
const data = await res.json();
activeKBTopic = topic;
activeKBContent = data.content;
refresh();
} catch (e) {
console.error('Failed to load topic:', e);
}
}
function closeKBViewer() {
activeKBTopic = null;
activeKBContent = null;
refresh();
}
function showKBEditor(topic, content) {
const titleEl = document.getElementById('kb-modal-title');
const topicEl = document.getElementById('kb-topic');
const editorEl = document.getElementById('kb-editor-content');
if (topic) {
titleEl.textContent = 'Edit: ' + topic;
topicEl.value = topic;
topicEl.readOnly = true;
editorEl.value = content || '';
kbEditMode = true;
} else {
titleEl.textContent = 'New Knowledge Entry';
topicEl.value = '';
topicEl.readOnly = false;
editorEl.value = '';
kbEditMode = false;
}
document.getElementById('kb-modal').classList.add('show');
(topic ? editorEl : topicEl).focus();
}
function closeKBModal() {
document.getElementById('kb-modal').classList.remove('show');
}
function editKBTopic(topic) {
showKBEditor(topic, activeKBContent);
}
async function saveKBEntry() {
const topic = document.getElementById('kb-topic').value.trim();
const content = document.getElementById('kb-editor-content').value;
if (!topic || !content) return;
try {
await fetch('/api/knowledge/' + encodeURIComponent(topic), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, agent: 'dashboard' })
});
closeKBModal();
activeKBTopic = topic;
activeKBContent = content;
refresh();
} catch (e) {
console.error('Failed to save:', e);
}
}
async function deleteKBTopic(topic) {
if (!confirm('Delete knowledge entry "' + topic + '"?')) return;
try {
await fetch('/api/knowledge/' + encodeURIComponent(topic), { method: 'DELETE' });
activeKBTopic = null;
activeKBContent = null;
refresh();
} catch (e) {
console.error('Failed to delete:', e);
}
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>