310 lines
9.0 KiB
JavaScript
310 lines
9.0 KiB
JavaScript
|
|
/**
|
||
|
|
* OpenClaw Command Center - Main Application
|
||
|
|
*
|
||
|
|
* Uses morphdom for efficient DOM updates (only patches what changed).
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Import morphdom (loaded as UMD, available as global `morphdom`)
|
||
|
|
// <script src="/js/lib/morphdom.min.js"></script> must be loaded first
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// STATE MANAGEMENT
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
const state = {
|
||
|
|
vitals: null,
|
||
|
|
sessions: [],
|
||
|
|
tokenStats: {},
|
||
|
|
statusCounts: { all: 0, live: 0, recent: 0, idle: 0 },
|
||
|
|
capacity: { main: { active: 0, max: 12 }, subagent: { active: 0, max: 24 } },
|
||
|
|
operators: { operators: [], roles: {} },
|
||
|
|
llmUsage: null,
|
||
|
|
cron: [],
|
||
|
|
memory: null,
|
||
|
|
cerebro: null,
|
||
|
|
subagents: [],
|
||
|
|
lastUpdated: null,
|
||
|
|
connected: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// SSE CONNECTION
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
let eventSource = null;
|
||
|
|
let reconnectAttempts = 0;
|
||
|
|
const MAX_RECONNECT_DELAY = 30000;
|
||
|
|
|
||
|
|
function connectSSE() {
|
||
|
|
if (typeof EventSource === "undefined") {
|
||
|
|
console.warn("[SSE] Not supported, falling back to polling");
|
||
|
|
startPolling();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateConnectionStatus("connecting");
|
||
|
|
|
||
|
|
eventSource = new EventSource("/api/events");
|
||
|
|
|
||
|
|
eventSource.onopen = () => {
|
||
|
|
console.log("[SSE] Connected");
|
||
|
|
state.connected = true;
|
||
|
|
reconnectAttempts = 0;
|
||
|
|
updateConnectionStatus("connected");
|
||
|
|
};
|
||
|
|
|
||
|
|
eventSource.addEventListener("connected", (e) => {
|
||
|
|
const data = JSON.parse(e.data);
|
||
|
|
console.log("[SSE] Server greeting:", data.message);
|
||
|
|
});
|
||
|
|
|
||
|
|
eventSource.addEventListener("update", (e) => {
|
||
|
|
const data = JSON.parse(e.data);
|
||
|
|
handleStateUpdate(data);
|
||
|
|
});
|
||
|
|
|
||
|
|
eventSource.addEventListener("heartbeat", (e) => {
|
||
|
|
const data = JSON.parse(e.data);
|
||
|
|
state.lastUpdated = new Date();
|
||
|
|
updateTimestamp();
|
||
|
|
});
|
||
|
|
|
||
|
|
eventSource.onerror = () => {
|
||
|
|
console.error("[SSE] Connection error");
|
||
|
|
state.connected = false;
|
||
|
|
eventSource.close();
|
||
|
|
updateConnectionStatus("disconnected");
|
||
|
|
|
||
|
|
// Exponential backoff
|
||
|
|
reconnectAttempts++;
|
||
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
|
||
|
|
console.log(`[SSE] Reconnecting in ${delay}ms`);
|
||
|
|
setTimeout(connectSSE, delay);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// STATE UPDATES
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function handleStateUpdate(data) {
|
||
|
|
// Merge new data into state
|
||
|
|
if (data.vitals) state.vitals = data.vitals;
|
||
|
|
if (data.sessions) state.sessions = data.sessions;
|
||
|
|
if (data.tokenStats) state.tokenStats = data.tokenStats;
|
||
|
|
if (data.statusCounts) state.statusCounts = data.statusCounts;
|
||
|
|
if (data.capacity) state.capacity = data.capacity;
|
||
|
|
if (data.operators) state.operators = data.operators;
|
||
|
|
if (data.llmUsage) state.llmUsage = data.llmUsage;
|
||
|
|
if (data.cron) state.cron = data.cron;
|
||
|
|
if (data.memory) state.memory = data.memory;
|
||
|
|
if (data.cerebro) state.cerebro = data.cerebro;
|
||
|
|
if (data.subagents) state.subagents = data.subagents;
|
||
|
|
|
||
|
|
state.lastUpdated = new Date();
|
||
|
|
|
||
|
|
// Re-render affected components using morphdom
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// RENDERING (with morphdom)
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function renderAll() {
|
||
|
|
// Each render function generates HTML and morphdom patches the DOM
|
||
|
|
renderVitals();
|
||
|
|
renderTokenStats();
|
||
|
|
renderLlmUsage();
|
||
|
|
renderSessions();
|
||
|
|
renderCron();
|
||
|
|
renderMemory();
|
||
|
|
renderCerebro();
|
||
|
|
renderOperators();
|
||
|
|
updateTimestamp();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Utility: safely patch a container using morphdom
|
||
|
|
function patchElement(containerId, newHtml) {
|
||
|
|
const container = document.getElementById(containerId);
|
||
|
|
if (!container) return;
|
||
|
|
|
||
|
|
// Create a temporary element with the new content
|
||
|
|
const temp = document.createElement("div");
|
||
|
|
temp.innerHTML = newHtml;
|
||
|
|
|
||
|
|
// Use morphdom to efficiently patch only what changed
|
||
|
|
if (typeof morphdom !== "undefined") {
|
||
|
|
// Patch each child
|
||
|
|
while (container.firstChild && temp.firstChild) {
|
||
|
|
morphdom(container.firstChild, temp.firstChild);
|
||
|
|
temp.removeChild(temp.firstChild);
|
||
|
|
}
|
||
|
|
// Add any new children
|
||
|
|
while (temp.firstChild) {
|
||
|
|
container.appendChild(temp.firstChild);
|
||
|
|
}
|
||
|
|
// Remove extra children
|
||
|
|
while (container.childNodes.length > temp.childNodes.length) {
|
||
|
|
container.removeChild(container.lastChild);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Fallback: direct innerHTML replacement
|
||
|
|
container.innerHTML = newHtml;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// COMPONENT RENDERERS (to be extracted to separate files)
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function renderVitals() {
|
||
|
|
if (!state.vitals) return;
|
||
|
|
const v = state.vitals;
|
||
|
|
|
||
|
|
// Update individual elements (simpler than full morphdom for now)
|
||
|
|
setText("vitals-hostname", v.hostname || "-");
|
||
|
|
setText("vitals-uptime", v.uptime || "-");
|
||
|
|
|
||
|
|
if (v.cpu) {
|
||
|
|
const cpuPct = v.cpu.usage || 0;
|
||
|
|
setText("cpu-percent", cpuPct + "%");
|
||
|
|
setWidth("cpu-bar", cpuPct + "%");
|
||
|
|
setText("cpu-user", (v.cpu.userPercent?.toFixed(1) || "-") + "%");
|
||
|
|
setText("cpu-sys", (v.cpu.sysPercent?.toFixed(1) || "-") + "%");
|
||
|
|
setText("cpu-idle", (v.cpu.idlePercent?.toFixed(1) || "-") + "%");
|
||
|
|
setText("cpu-chip", v.cpu.chip || v.cpu.brand || "");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (v.memory) {
|
||
|
|
const memPct = v.memory.percent || 0;
|
||
|
|
setText("mem-percent", memPct + "% used");
|
||
|
|
setWidth("mem-bar", memPct + "%");
|
||
|
|
setText("mem-summary", `${v.memory.usedFormatted || "-"} of ${v.memory.totalFormatted || "-"}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (v.disk) {
|
||
|
|
const diskPct = v.disk.percent || 0;
|
||
|
|
setText("disk-percent", diskPct + "% used");
|
||
|
|
setWidth("disk-bar", diskPct + "%");
|
||
|
|
setText("disk-summary", `${v.disk.usedFormatted || "-"} of ${v.disk.totalFormatted || "-"}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderTokenStats() {
|
||
|
|
if (!state.tokenStats) return;
|
||
|
|
const t = state.tokenStats;
|
||
|
|
|
||
|
|
setText("stat-total-tokens", t.total || "-");
|
||
|
|
setText("stat-input", t.input || "-");
|
||
|
|
setText("stat-output", t.output || "-");
|
||
|
|
setText("stat-active", t.activeCount || "0");
|
||
|
|
setText("stat-cost", t.estCost || "-");
|
||
|
|
setText("stat-main", `${t.activeMainCount || 0}/${t.mainLimit || 12}`);
|
||
|
|
setText("stat-subagents", `${t.activeSubagentCount || 0}/${t.subagentLimit || 24}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderLlmUsage() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSessions() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderCron() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderMemory() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderCerebro() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderOperators() {
|
||
|
|
// Placeholder - will be extracted to component
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// UTILITIES
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function setText(id, text) {
|
||
|
|
const el = document.getElementById(id);
|
||
|
|
if (el && el.textContent !== text) {
|
||
|
|
el.textContent = text;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setWidth(id, width) {
|
||
|
|
const el = document.getElementById(id);
|
||
|
|
if (el && el.style.width !== width) {
|
||
|
|
el.style.width = width;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateTimestamp() {
|
||
|
|
const now = state.lastUpdated || new Date();
|
||
|
|
const timeStr = now.toLocaleTimeString();
|
||
|
|
const indicator = state.connected ? " ⚡" : "";
|
||
|
|
setText("last-updated", timeStr + indicator);
|
||
|
|
setText("sidebar-updated", state.connected ? `Live: ${timeStr}` : `Updated: ${timeStr}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateConnectionStatus(status) {
|
||
|
|
const el = document.getElementById("connection-status");
|
||
|
|
if (!el) return;
|
||
|
|
|
||
|
|
el.className = "connection-status " + status;
|
||
|
|
el.textContent =
|
||
|
|
status === "connected"
|
||
|
|
? "🟢 Live"
|
||
|
|
: status === "connecting"
|
||
|
|
? "🟡 Connecting..."
|
||
|
|
: "🔴 Disconnected";
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// POLLING FALLBACK
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
let pollInterval = null;
|
||
|
|
|
||
|
|
function startPolling() {
|
||
|
|
if (pollInterval) return;
|
||
|
|
pollInterval = setInterval(fetchState, 5000);
|
||
|
|
fetchState();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchState() {
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/state");
|
||
|
|
const data = await response.json();
|
||
|
|
handleStateUpdate(data);
|
||
|
|
} catch (e) {
|
||
|
|
console.error("[Polling] Failed:", e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// INITIALIZATION
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function init() {
|
||
|
|
console.log("[App] Initializing OpenClaw Command Center");
|
||
|
|
connectSSE();
|
||
|
|
|
||
|
|
// Initial fetch to populate immediately
|
||
|
|
setTimeout(fetchState, 100);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start when DOM is ready
|
||
|
|
if (document.readyState === "loading") {
|
||
|
|
document.addEventListener("DOMContentLoaded", init);
|
||
|
|
} else {
|
||
|
|
setTimeout(init, 0);
|
||
|
|
}
|