177 lines
5.3 KiB
JavaScript
177 lines
5.3 KiB
JavaScript
/**
|
|
* Hermes Dashboard - minimal ES module for auto-refresh.
|
|
*
|
|
* Updates elapsed-time spans every 10 seconds and refreshes
|
|
* the worker status and tasks sections on the dashboard.
|
|
*/
|
|
|
|
function updateElapsedTimes() {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
document.querySelectorAll('.elapsed-time[data-started]').forEach(el => {
|
|
const started = parseInt(el.dataset.started, 10);
|
|
if (!started) return;
|
|
const diff = now - started;
|
|
if (diff < 0) {
|
|
el.textContent = 'in the future';
|
|
} else if (diff < 60) {
|
|
el.textContent = `${diff}s`;
|
|
} else if (diff < 3600) {
|
|
el.textContent = `${Math.floor(diff / 60)}m ${diff % 60}s`;
|
|
} else {
|
|
const h = Math.floor(diff / 3600);
|
|
const m = Math.floor((diff % 3600) / 60);
|
|
el.textContent = `${h}h ${m}m`;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshDashboard() {
|
|
try {
|
|
const resp = await fetch('/api/workers');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
const container = document.getElementById('worker-status');
|
|
if (!container) return;
|
|
|
|
if (data.current_task) {
|
|
const t = data.current_task;
|
|
let html = `<p><strong>Current Task:</strong> #${t.id} - ${(t.title || '').substring(0, 60)}</p>`;
|
|
html += `<p><strong>Worker PID:</strong> ${t.worker_pid || 'N/A'}</p>`;
|
|
if (t.started_at) {
|
|
html += `<p><strong>Elapsed:</strong> <span class="elapsed-time" data-started="${t.started_at}"></span></p>`;
|
|
}
|
|
container.innerHTML = html;
|
|
} else {
|
|
container.innerHTML = '<p class="text-muted">No task currently running</p>';
|
|
}
|
|
|
|
// Refresh process list
|
|
const procContainer = document.getElementById('process-list');
|
|
if (procContainer && data.processes) {
|
|
if (data.processes.length > 0) {
|
|
let html = '<table><thead><tr><th>PID</th><th>Elapsed</th><th>Command</th></tr></thead><tbody>';
|
|
for (const proc of data.processes) {
|
|
html += `<tr><td>${proc.pid}</td>`;
|
|
html += `<td><span class="elapsed-time" data-started="${proc.create_time || 0}"></span></td>`;
|
|
html += `<td class="text-small">${escapeHtml(proc.cmdline || '')}</td></tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
procContainer.innerHTML = html;
|
|
} else {
|
|
procContainer.innerHTML = '<p class="text-muted">No opencode workers running</p>';
|
|
}
|
|
}
|
|
|
|
updateElapsedTimes();
|
|
} catch (_err) {
|
|
// Silently ignore refresh errors
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function getCsrfToken() {
|
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
return meta ? meta.content : '';
|
|
}
|
|
|
|
async function postJson(url, payload) {
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
return { ok: resp.ok, status: resp.status, data };
|
|
}
|
|
|
|
async function submitDispatch(ev) {
|
|
ev.preventDefault();
|
|
const form = ev.target;
|
|
const out = document.getElementById('dispatch-result');
|
|
const title = form.title.value.trim();
|
|
const body = form.body.value.trim();
|
|
const maxRuntime = parseInt(form.max_runtime.value, 10) || 1800;
|
|
const assignee = (form.assignee.value || 'omo-pm').trim();
|
|
|
|
if (!title) {
|
|
out.textContent = 'Error: title is required';
|
|
out.style.color = '#f88';
|
|
return false;
|
|
}
|
|
|
|
out.textContent = 'Dispatching...';
|
|
out.style.color = '#888';
|
|
|
|
const { ok, status, data } = await postJson('/api/dispatch', {
|
|
title,
|
|
body,
|
|
max_runtime: maxRuntime,
|
|
assignee,
|
|
});
|
|
|
|
if (ok) {
|
|
out.textContent = `Dispatched: ${data.task_id} (status=${data.status})`;
|
|
out.style.color = '#8f8';
|
|
form.reset();
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else {
|
|
out.textContent = `Error ${status}: ${data.error || 'unknown'}`;
|
|
out.style.color = '#f88';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function initDispatchForm() {
|
|
const form = document.getElementById('dispatch-form');
|
|
if (!form) return;
|
|
form.addEventListener('submit', submitDispatch);
|
|
|
|
const tplSelect = document.getElementById('dispatch-template');
|
|
if (!tplSelect) return;
|
|
fetch('/api/dispatch/templates')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const templates = data.templates || [];
|
|
for (const t of templates) {
|
|
const opt = document.createElement('option');
|
|
opt.value = t.name;
|
|
opt.textContent = t.name;
|
|
opt.dataset.body = t.default_body || '';
|
|
opt.dataset.description = t.description || '';
|
|
tplSelect.appendChild(opt);
|
|
}
|
|
tplSelect.addEventListener('change', () => {
|
|
const sel = tplSelect.selectedOptions[0];
|
|
if (sel && sel.dataset.body) {
|
|
form.body.value = sel.dataset.body;
|
|
if (!form.title.value) form.title.value = sel.value;
|
|
}
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// Initial update + 10s interval
|
|
updateElapsedTimes();
|
|
setInterval(updateElapsedTimes, 10000);
|
|
|
|
// Refresh dashboard data every 10s if on the dashboard page
|
|
if (document.getElementById('worker-status')) {
|
|
setInterval(refreshDashboard, 10000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initDispatchForm);
|
|
} else {
|
|
initDispatchForm();
|
|
}
|