Some checks failed
Invert the agent model: the agent IS the computer. The message pump becomes the kernel, handlers are sandboxed apps, and all access is mediated by the platform. Phase 1 — Container foundation: - Multi-stage Dockerfile (python:3.12-slim, non-root user, /data volume) - deploy/entrypoint.py with --dry-run config validation - docker-compose.yml (cap_drop ALL, read_only, no-new-privileges) - docker-compose.dev.yml overlay for development - CI Docker build smoke test Phase 2 — Security hardening: - xml_pipeline/security/ module with default-deny container mode - Permission gate: per-listener tool allowlist enforcement - Network policy: egress control (only declared LLM backend domains) - Shell tool: disabled in container mode - File tool: restricted to /data and /config in container mode - Fetch tool: integrates network egress policy - Config loader: parses security and network YAML sections Phase 3 — Management plane: - Agent app (port 8080): minimal /health, /inject, /ws only - Management app (port 9090): full API, audit log, dashboard - SQLite-backed audit log for tool invocations and security events - Static web dashboard (no framework, WebSocket-driven) - CLI --split flag for dual-port serving All 439 existing tests pass with zero regressions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
/**
|
|
* AgentOS Dashboard — WebSocket-driven real-time updates.
|
|
*
|
|
* No framework, no build step. Pure vanilla JS.
|
|
* Connects to the management port WebSocket at /ws.
|
|
*/
|
|
|
|
// State
|
|
let ws = null;
|
|
let reconnectTimeout = null;
|
|
let messageCount = 0;
|
|
const API_BASE = ''; // Same origin as management server
|
|
|
|
// =========================================================================
|
|
// WebSocket Connection
|
|
// =========================================================================
|
|
|
|
function connect() {
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${location.host}/ws`;
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
setConnectionStatus(true);
|
|
// Request full state on connect
|
|
fetchInitialState();
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
handleEvent(data);
|
|
} catch (e) {
|
|
console.error('Failed to parse WebSocket message:', e);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setConnectionStatus(false);
|
|
// Reconnect after delay
|
|
reconnectTimeout = setTimeout(connect, 3000);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
ws.close();
|
|
};
|
|
}
|
|
|
|
function setConnectionStatus(connected) {
|
|
const el = document.getElementById('connection-status');
|
|
if (connected) {
|
|
el.textContent = 'Connected';
|
|
el.className = 'status-indicator connected';
|
|
} else {
|
|
el.textContent = 'Disconnected';
|
|
el.className = 'status-indicator disconnected';
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Event Handling
|
|
// =========================================================================
|
|
|
|
function handleEvent(data) {
|
|
const event = data.event;
|
|
|
|
switch (event) {
|
|
case 'connected':
|
|
if (data.state) {
|
|
updateFullState(data.state);
|
|
}
|
|
break;
|
|
case 'agent_state':
|
|
updateAgentState(data);
|
|
break;
|
|
case 'message':
|
|
addMessage(data);
|
|
messageCount++;
|
|
updateMessageCount();
|
|
break;
|
|
case 'thread_created':
|
|
case 'thread_completed':
|
|
fetchThreads();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// API Fetching
|
|
// =========================================================================
|
|
|
|
async function fetchJSON(path) {
|
|
try {
|
|
const resp = await fetch(`${API_BASE}${path}`);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
return await resp.json();
|
|
} catch (e) {
|
|
console.error(`Fetch ${path} failed:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchInitialState() {
|
|
const [organism, agents, threads, usage] = await Promise.all([
|
|
fetchJSON('/api/v1/organism'),
|
|
fetchJSON('/api/v1/agents'),
|
|
fetchJSON('/api/v1/threads'),
|
|
fetchJSON('/api/v1/usage'),
|
|
]);
|
|
|
|
if (organism) {
|
|
document.getElementById('organism-name').textContent = organism.name;
|
|
updateUptime(organism.uptimeSeconds || 0);
|
|
}
|
|
|
|
if (agents) {
|
|
renderAgents(agents.agents || []);
|
|
const count = (agents.agents || []).length;
|
|
const active = (agents.agents || []).filter(a => a.state === 'processing').length;
|
|
document.getElementById('agent-count').textContent = count;
|
|
document.getElementById('agent-detail').textContent = `${active} active`;
|
|
}
|
|
|
|
if (threads) {
|
|
renderThreads(threads.threads || []);
|
|
document.getElementById('thread-count').textContent = (threads.threads || []).length;
|
|
}
|
|
|
|
if (usage) {
|
|
updateUsageCards(usage);
|
|
}
|
|
|
|
// Fetch audit
|
|
refreshAudit();
|
|
}
|
|
|
|
async function fetchThreads() {
|
|
const threads = await fetchJSON('/api/v1/threads');
|
|
if (threads) {
|
|
renderThreads(threads.threads || []);
|
|
document.getElementById('thread-count').textContent = (threads.threads || []).length;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Rendering
|
|
// =========================================================================
|
|
|
|
function renderAgents(agents) {
|
|
const tbody = document.getElementById('agents-table');
|
|
if (!agents.length) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty">No agents registered</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = agents.map(a => `
|
|
<tr>
|
|
<td><code>${escapeHtml(a.name)}</code></td>
|
|
<td>${a.isAgent ? 'Agent' : 'Tool'}</td>
|
|
<td><span class="state-badge state-${(a.state || 'idle').toLowerCase()}">${a.state || 'idle'}</span></td>
|
|
<td>${(a.peers || []).map(p => `<code>${escapeHtml(p)}</code>`).join(', ') || '--'}</td>
|
|
<td>${a.messageCount || 0}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function renderThreads(threads) {
|
|
const tbody = document.getElementById('threads-table');
|
|
if (!threads.length) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty">No active threads</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = threads.map(t => `
|
|
<tr>
|
|
<td><code>${escapeHtml((t.threadId || t.id || '').substring(0, 8))}...</code></td>
|
|
<td><span class="state-badge state-${(t.status || 'active').toLowerCase()}">${t.status || 'active'}</span></td>
|
|
<td>${(t.participants || []).map(p => `<code>${escapeHtml(p)}</code>`).join(', ') || '--'}</td>
|
|
<td>${t.messageCount || 0}</td>
|
|
<td>${t.createdAt ? formatTime(t.createdAt) : '--'}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function addMessage(data) {
|
|
const log = document.getElementById('message-log');
|
|
const empty = log.querySelector('.empty');
|
|
if (empty) empty.remove();
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = 'log-entry';
|
|
entry.innerHTML = `
|
|
<span class="log-time">${formatTime(Date.now() / 1000)}</span>
|
|
<span class="log-from">${escapeHtml(data.from || data.fromId || '?')}</span>
|
|
<span class="log-content">${escapeHtml(data.payloadType || data.payload_type || JSON.stringify(data).substring(0, 200))}</span>
|
|
`;
|
|
|
|
log.insertBefore(entry, log.firstChild);
|
|
|
|
// Limit log entries
|
|
while (log.children.length > 500) {
|
|
log.removeChild(log.lastChild);
|
|
}
|
|
}
|
|
|
|
function updateAgentState(data) {
|
|
// Re-fetch agents on state change
|
|
fetchJSON('/api/v1/agents').then(agents => {
|
|
if (agents) {
|
|
renderAgents(agents.agents || []);
|
|
const count = (agents.agents || []).length;
|
|
const active = (agents.agents || []).filter(a => a.state === 'processing').length;
|
|
document.getElementById('agent-count').textContent = count;
|
|
document.getElementById('agent-detail').textContent = `${active} active`;
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateUsageCards(usage) {
|
|
if (usage.totals) {
|
|
const tokens = usage.totals.totalTokens || 0;
|
|
const cost = usage.totals.totalCost || usage.totals.estimatedCost || 0;
|
|
document.getElementById('token-count').textContent = formatNumber(tokens);
|
|
document.getElementById('token-cost').textContent = `$${cost.toFixed(4)}`;
|
|
}
|
|
}
|
|
|
|
function updateMessageCount() {
|
|
document.getElementById('message-count').textContent = formatNumber(messageCount);
|
|
}
|
|
|
|
function updateUptime(seconds) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
document.getElementById('uptime').textContent =
|
|
`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Audit Log
|
|
// =========================================================================
|
|
|
|
async function refreshAudit() {
|
|
const severity = document.getElementById('audit-severity').value;
|
|
const type = document.getElementById('audit-type').value;
|
|
|
|
let url = '/api/v1/audit/events?limit=200';
|
|
if (severity) url += `&severity=${severity}`;
|
|
if (type) url += `&event_type=${type}`;
|
|
|
|
const data = await fetchJSON(url);
|
|
if (!data) return;
|
|
|
|
const log = document.getElementById('audit-log');
|
|
if (!data.events || !data.events.length) {
|
|
log.innerHTML = '<div class="empty">No audit events matching filters</div>';
|
|
return;
|
|
}
|
|
|
|
log.innerHTML = data.events.map(e => `
|
|
<div class="log-entry">
|
|
<span class="log-time">${formatTime(e.timestamp)}</span>
|
|
<span class="log-from severity-${e.severity}">[${e.severity.toUpperCase()}]</span>
|
|
<span class="log-from">${escapeHtml(e.listener_name)}</span>
|
|
<span class="log-content">${escapeHtml(e.event_type)}: ${escapeHtml(JSON.stringify(e.details).substring(0, 300))}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Make refreshAudit available globally for the onclick handler
|
|
window.refreshAudit = refreshAudit;
|
|
|
|
// =========================================================================
|
|
// Tab Navigation
|
|
// =========================================================================
|
|
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
tab.classList.add('active');
|
|
const target = document.getElementById(`tab-${tab.dataset.tab}`);
|
|
if (target) target.classList.add('active');
|
|
|
|
// Refresh data for active tab
|
|
if (tab.dataset.tab === 'audit') refreshAudit();
|
|
if (tab.dataset.tab === 'threads') fetchThreads();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Utilities
|
|
// =========================================================================
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(str);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const d = new Date(timestamp * 1000);
|
|
return d.toLocaleTimeString();
|
|
}
|
|
|
|
function formatNumber(n) {
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
return String(n);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Periodic Refresh
|
|
// =========================================================================
|
|
|
|
setInterval(() => {
|
|
// Refresh uptime
|
|
fetchJSON('/health').then(data => {
|
|
if (data && data.uptime_seconds !== undefined) {
|
|
updateUptime(data.uptime_seconds);
|
|
}
|
|
});
|
|
}, 10000);
|
|
|
|
setInterval(() => {
|
|
// Refresh usage
|
|
fetchJSON('/api/v1/usage').then(data => {
|
|
if (data) updateUsageCards(data);
|
|
});
|
|
}, 30000);
|
|
|
|
// =========================================================================
|
|
// Boot
|
|
// =========================================================================
|
|
|
|
connect();
|