agentic-os/dashboard/pages/command-center.js

466 lines
18 KiB
JavaScript

/* Command Center — a unified dispatcher + live console for Agentic OS.
*
* This page combines two things:
* 1. The real Agentic-OS surfaces (agent registry, scheduler jobs, audit
* trail, agent-time, server uptime) — all pulled live from the API.
* 2. The "command console" UX from the standalone Desktop Command Center
* (sidebar command-target selector, a small command language, Wake/Reset,
* and the compact color-coded log + event streams).
*
* It is a browser frontend to the running OS server. Nothing here is mocked.
*/
// Module state for this page.
let _cc = {
agents: [],
selected: null,
running: false,
simTimer: null,
tickTimer: null,
hwTimer: 0,
startTime: null,
jobs: [],
hw: null,
};
// ── small command language used in the console ──────────────────────
// /help list available commands
// /status refresh + print agent states
// /run <task> dispatch a task to the selected agent via /api/chat
// /wake re-run agent health checks
// /reset clear the console + event streams
// /clear clear just the log stream
// anything else sent to the selected agent as a free-text message
async function ccRunCommand() {
const input = document.getElementById('ccCmd');
if (!input) return;
const raw = input.value.trim();
if (!raw) { ccLog('no command entered', 'warn'); return; }
ccLog('> ' + raw, 'sys');
const [cmd, ...rest] = raw.split(/\s+/);
const arg = rest.join(' ').trim();
switch (cmd.toLowerCase()) {
case '/help':
ccLog('commands: /help /status /run <task> /wake /reset /clear', 'info');
ccLog('anything else is sent to the selected agent', 'sys');
break;
case '/status':
await ccRefreshAgents(true);
break;
case '/wake':
await ccRefreshAgents(true);
ccEvent('wake signal broadcast to all agents', 'ok');
break;
case '/reset':
ccReset();
break;
case '/clear':
document.getElementById('ccLogStream').innerHTML = '';
break;
case '/run':
if (!arg) { ccLog('usage: /run <task description>', 'warn'); break; }
await ccDispatch(arg);
break;
default:
await ccDispatch(raw);
}
}
async function ccDispatch(message) {
const agent = _cc.selected;
if (!agent) { ccLog('select a target agent first', 'warn'); return; }
if (_cc.running) { ccLog('a command is already running — wait or reset', 'warn'); return; }
_cc.running = true;
const runBtn = document.getElementById('ccRun');
const stopBtn = document.getElementById('ccStop');
if (runBtn) runBtn.disabled = true;
if (stopBtn) stopBtn.disabled = false;
const jobId = 'J' + String(Date.now()).slice(-5);
ccJobAdd(jobId, agent, message, 'running');
ccEvent(`dispatching to ${agent} ...`, 'info');
ccLog(`${jobId}${agent}: ${message}`, 'info');
const controller = new AbortController();
_cc._controller = controller;
try {
const res = await api.chat(agent, message, controller);
const out = (res && (res.response || res.reply || res.output || JSON.stringify(res))) || '(no output)';
ccLog(`${jobId} · ${String(out).slice(0, 400)}`, 'ok');
ccEvent(`${jobId} done`, 'ok');
ccJobUpdate(jobId, 'done');
} catch (err) {
ccLog(`${jobId} · error: ${err.message}`, 'err');
ccEvent(`${jobId} failed`, 'err');
ccJobUpdate(jobId, 'failed');
} finally {
_cc.running = false;
_cc._controller = null;
if (runBtn) runBtn.disabled = false;
if (stopBtn) stopBtn.disabled = true;
await ccRefreshAgents(false);
}
}
function ccStop() {
if (_cc._controller) _cc._controller.abort();
_cc.running = false;
ccLog('stop requested — aborted', 'warn');
ccEvent('run interrupted by operator', 'warn');
const runBtn = document.getElementById('ccRun');
const stopBtn = document.getElementById('ccStop');
if (runBtn) runBtn.disabled = false;
if (stopBtn) stopBtn.disabled = true;
}
// ── rendering ─────────────────────────────────────────────────────
async function renderCommandCenter() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Command Center</h1>
<p class="page-subtitle">Dispatch tasks to agents and watch live execution</p>
</div>
<div class="btn-group">
<button class="btn btn-ghost" onclick="ccRefreshAll()">↻ Refresh</button>
<button class="btn btn-primary" onclick="document.getElementById('ccCmd').focus()">⌨ Focus Console</button>
</div>
</div>
<div class="grid grid-2" style="align-items:start">
<!-- Command Console -->
<section class="card span-2" id="ccPanelControl">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<span class="card-title">Command Console</span>
<div style="display:flex;gap:8px">
<button id="ccRun" class="btn btn-primary" onclick="ccRunCommand()">Run Command</button>
<button id="ccStop" class="btn btn-ghost" onclick="ccStop()" disabled>■ Stop</button>
</div>
</div>
<div style="margin:12px 0">
<textarea id="ccCmd" class="form-textarea" rows="2" spellcheck="false"
placeholder="e.g. /run summarize the brain folder · /status · /help"></textarea>
</div>
<div style="display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;margin-bottom:12px">
<div style="flex:1;min-width:220px">
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Command Target</div>
<select id="ccAgentSelect" class="form-select" onchange="ccSelectAgent(this.value)"></select>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost btn-sm" onclick="ccRefreshAgents(true)">↻ Wake Agents</button>
<button class="btn btn-ghost btn-sm" onclick="ccReset()">⟲ Reset Console</button>
</div>
</div>
<div class="cc-tip-row">
<span class="cc-tip"><b>/help</b> commands</span>
<span class="cc-tip"><b>/status</b> agent states</span>
<span class="cc-tip"><b>/run</b> dispatch task</span>
<span class="cc-tip" style="margin-left:auto;color:var(--text-muted)">Selected: <span id="ccSelected" class="mono">--</span></span>
</div>
<div class="cc-log-head" style="display:flex;justify-content:space-between;margin:14px 0 8px">
<span class="card-title" style="font-size:14px">Execution Log</span>
<span id="ccJobLast" style="font-size:12px;color:var(--text-muted)">Last job: --</span>
</div>
<div id="ccLogStream" class="cc-log" role="log" aria-live="polite"></div>
</section>
<!-- Agent Status -->
<section class="card" id="ccPanelAgents">
<div class="card-header"><span class="card-title">Agent Status</span></div>
<div id="ccAgentRows" class="cc-agent-rows"></div>
</section>
<!-- System Metrics (real data) -->
<section class="card" id="ccPanelMetrics">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<span class="card-title">System</span>
<span id="ccUptime" class="nav-badge" style="background:var(--bg-card)">Uptime: --</span>
</div>
<div class="cc-metrics" id="ccMetrics"></div>
<div style="margin-top:16px">
<span class="card-title" style="font-size:14px">Hardware Monitors</span>
<span style="font-size:11px;color:var(--text-muted);margin-left:8px">live · real readings</span>
<div class="cc-metrics" id="ccHw" style="margin-top:10px"></div>
</div>
<div style="margin-top:14px">
<span class="card-title" style="font-size:14px">Recent Jobs</span>
<div id="ccJobs" class="cc-jobs"></div>
</div>
</section>
<!-- Event Stream -->
<section class="card span-2" id="ccPanelLogs">
<div class="card-header"><span class="card-title">Event Stream</span></div>
<div id="ccEventStream" class="cc-log" role="log" aria-live="polite" style="height:200px"></div>
</section>
</div>
`;
// Ctrl/Cmd+Enter to run from the textarea.
const ta = document.getElementById('ccCmd');
if (ta) ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); ccRunCommand(); }
});
_cc.startTime = Date.now();
await ccRefreshAll();
// Live tick: uptime + light metrics refresh. Hardware polls every 3s
// (cheap, but no need to hammer /api/system every second).
if (_cc.tickTimer) clearInterval(_cc.tickTimer);
_cc.tickTimer = setInterval(() => {
ccTickUptime();
ccRenderMetrics();
if ((_cc.hwTimer = (_cc.hwTimer + 1) % 3) === 0) ccRefreshSystem();
}, 1000);
}
async function ccRefreshAll() {
await Promise.all([ccRefreshAgents(false), ccRefreshJobs(), ccRefreshMetrics(), ccRefreshSystem()]);
ccEvent('command center synced', 'info');
}
async function ccRefreshAgents(announce) {
try {
const { agents } = await api.getAgents();
_cc.agents = agents || [];
if (!_cc.selected && _cc.agents.length) _cc.selected = _cc.agents[0].name;
ccRenderAgents();
ccRenderAgentSelect();
if (announce) _cc.agents.forEach(a =>
ccLog(`${a.display_name || a.name}${a.status}`, 'info'));
} catch (err) {
ccLog('agent refresh failed: ' + err.message, 'err');
}
}
function ccRenderAgents() {
const rows = document.getElementById('ccAgentRows');
if (!rows) return;
rows.innerHTML = _cc.agents.map(a => {
const sc = statusColor(a.status);
const dotCls = a.status === 'online' ? 'online' : a.status === 'warning' ? 'warning' : 'offline';
return `
<div class="cc-agent-row" onclick="ccSelectAgent('${a.name}')" style="cursor:pointer">
<div class="agent-dot ${dotCls}" style="width:10px;height:10px"></div>
<div style="flex:1">
<div style="font-weight:600;font-size:13px">${escapeHtml(a.display_name || a.name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${escapeHtml(a.type || 'cli')}${a.builtin ? ' · built-in' : ''}</div>
</div>
<span style="font-size:11px;color:${sc.text};text-transform:uppercase;font-weight:600">${a.status}</span>
</div>`;
}).join('') || '<div class="empty-state"><div class="empty-state-desc">No agents registered</div></div>';
}
function ccRenderAgentSelect() {
const sel = document.getElementById('ccAgentSelect');
if (!sel) return;
sel.innerHTML = _cc.agents.map(a =>
`<option value="${a.name}">${escapeHtml(a.display_name || a.name)}</option>`).join('');
sel.value = _cc.selected || '';
ccSelectAgent(_cc.selected);
}
function ccSelectAgent(name) {
_cc.selected = name;
const label = document.getElementById('ccSelected');
if (label) {
const a = _cc.agents.find(x => x.name === name);
label.textContent = a ? (a.display_name || a.name) : (name || '--');
}
const sel = document.getElementById('ccAgentSelect');
if (sel && sel.value !== name) sel.value = name;
}
async function ccRefreshJobs() {
try {
const jobs = await api.getJobs();
_cc.jobs = (jobs || []).slice(-8).reverse();
ccRenderJobs();
} catch { /* scheduler may be empty */ }
}
function ccRenderJobs() {
const el = document.getElementById('ccJobs');
if (!el) return;
el.innerHTML = _cc.jobs.map(j => {
const st = (j.enabled === false) ? 'off' : (j.next_run ? 'queued' : 'queued');
return `<div class="cc-job">
<span><b>${escapeHtml(j.name || j.id || 'job')}</b> <span style="color:var(--text-muted);font-size:11px">${escapeHtml(j.schedule || '')}</span></span>
<span class="nav-badge" style="background:var(--bg-card)">${escapeHtml(st)}</span>
</div>`;
}).join('') || '<div style="font-size:12px;color:var(--text-muted)">No scheduled jobs</div>';
}
function ccJobAdd(id, agent, cmd, status) {
_cc.jobs.unshift({ id, name: id, agent, cmd, status, schedule: agent });
if (_cc.jobs.length > 8) _cc.jobs.pop();
ccRenderJobs();
const last = document.getElementById('ccJobLast');
if (last) last.textContent = 'Last job: ' + id;
}
function ccJobUpdate(id, status) {
const j = _cc.jobs.find(x => x.id === id);
if (j) { j.status = status; ccRenderJobs(); }
}
// Metrics: real OS data only — no fabricated CPU/mem.
async function ccRefreshMetrics() {
try {
const status = await api.getStatus();
_cc.systemStatus = status;
} catch { /* offline */ }
ccRenderMetrics();
ccTickUptime();
}
function ccRenderMetrics() {
const el = document.getElementById('ccMetrics');
if (!el) return;
const st = _cc.systemStatus || {};
const agents = _cc.agents || [];
const online = agents.filter(a => a.status === 'online').length;
const cards = [
{ label: 'Agents Online', value: `${online}/${agents.length}`, pct: agents.length ? (online / agents.length) * 100 : 0, color: 'var(--green)' },
{ label: 'Skills', value: String(st.skills_count ?? '--'), pct: 100, color: 'var(--accent)' },
{ label: 'Scheduled Jobs', value: String(_cc.jobs.length), pct: Math.min(100, _cc.jobs.length * 20), color: 'var(--yellow)' },
{ label: 'System', value: (st.status || 'unknown').toUpperCase(), pct: st.status === 'healthy' ? 100 : 30, color: st.status === 'healthy' ? 'var(--green)' : 'var(--red)' },
];
el.innerHTML = cards.map(c => `
<div class="cc-metric">
<div class="cc-metric-label">${c.label}</div>
<div class="cc-metric-value">${c.value}</div>
<div class="cc-bar"><div class="cc-fill" style="width:${Math.max(0, Math.min(100, c.pct))}%;background:${c.color}"></div></div>
</div>`).join('');
}
// Hardware monitors: real CPU / GPU / RAM / Disk from /api/system.
async function ccRefreshSystem() {
try {
const hw = await api.request('/api/system');
_cc.hw = hw;
} catch { /* metrics endpoint optional */ }
ccRenderHw();
}
function ccLoadColor(pct) {
if (pct == null) return 'var(--text-muted)';
if (pct >= 85) return 'var(--red)';
if (pct >= 60) return 'var(--yellow)';
return 'var(--green)';
}
function ccRenderHw() {
const el = document.getElementById('ccHw');
if (!el) return;
const hw = _cc.hw || {};
const cards = [];
// CPU
const cpu = hw.cpu || {};
cards.push({
label: 'CPU',
value: cpu.percent == null ? 'N/A' : cpu.percent + '%',
pct: cpu.percent || 0,
color: ccLoadColor(cpu.percent),
});
// GPU (may be null when no nvidia-smi / no GPU)
const gpu = hw.gpu;
if (gpu && !gpu.error) {
const gv = gpu.util_percent == null ? 'N/A' : gpu.util_percent + '%';
const gsub = gpu.name ? gpu.name : '';
cards.push({
label: 'GPU',
value: gv,
sub: gsub,
pct: gpu.util_percent || 0,
color: ccLoadColor(gpu.util_percent),
});
} else {
cards.push({ label: 'GPU', value: 'N/A', sub: (gpu && gpu.error) ? 'err' : 'no GPU', pct: 0, color: 'var(--text-muted)' });
}
// RAM
const ram = hw.ram || {};
cards.push({
label: 'RAM',
value: ram.percent == null ? 'N/A' : ram.percent + '%',
sub: ram.total_gb ? `${ram.used_gb ?? '?'} / ${ram.total_gb} GB` : '',
pct: ram.percent || 0,
color: ccLoadColor(ram.percent),
});
// Disk (total space monitor)
const disk = hw.disk || {};
cards.push({
label: 'Disk',
value: disk.percent == null ? 'N/A' : disk.percent + '%',
sub: disk.total_gb ? `${disk.used_gb ?? '?'} / ${disk.total_gb} GB` : '',
pct: disk.percent || 0,
color: ccLoadColor(disk.percent),
});
el.innerHTML = cards.map(c => `
<div class="cc-metric">
<div class="cc-metric-label">${c.label}</div>
<div class="cc-metric-value">${c.value}</div>
${c.sub ? `<div class="cc-metric-sub">${escapeHtml(c.sub)}</div>` : ''}
<div class="cc-bar"><div class="cc-fill" style="width:${Math.max(0, Math.min(100, c.pct))}%;background:${c.color}"></div></div>
</div>`).join('');
}
function ccTickUptime() {
const el = document.getElementById('ccUptime');
if (!el || !_cc.startTime) return;
const up = Math.floor((Date.now() - _cc.startTime) / 1000);
const h = String(Math.floor(up / 3600)).padStart(2, '0');
const m = String(Math.floor((up % 3600) / 60)).padStart(2, '0');
const s = String(up % 60).padStart(2, '0');
el.textContent = `Uptime: ${h}:${m}:${s}`;
}
function ccReset() {
const log = document.getElementById('ccLogStream');
const ev = document.getElementById('ccEventStream');
if (log) log.innerHTML = '';
if (ev) ev.innerHTML = '';
ccLog('console reset', 'ok');
ccEvent('console reset', 'ok');
}
// ── log / event helpers ───────────────────────────────────────────
function ccLog(msg, level = 'sys') {
const s = document.getElementById('ccLogStream');
if (!s) return;
const line = document.createElement('div');
line.className = 'cc-log-line ' + level;
line.innerHTML = `<span class="cc-time">[${ccStamp()}]</span><span class="cc-msg"></span>`;
line.querySelector('.cc-msg').textContent = msg;
s.appendChild(line);
s.scrollTop = s.scrollHeight;
}
function ccEvent(msg, level = 'sys') {
const s = document.getElementById('ccEventStream');
if (!s) return;
const line = document.createElement('div');
line.className = 'cc-log-line ' + level;
line.innerHTML = `<span class="cc-time">[${ccStamp()}]</span><span class="cc-msg"></span>`;
line.querySelector('.cc-msg').textContent = msg;
s.appendChild(line);
s.scrollTop = s.scrollHeight;
}
function ccStamp() { return new Date().toTimeString().slice(0, 8); }
// Clean up timers when navigating away (best-effort).
window.addEventListener('hashchange', () => {
if (_cc.tickTimer) { clearInterval(_cc.tickTimer); _cc.tickTimer = null; }
});