672 lines
23 KiB
JavaScript
672 lines
23 KiB
JavaScript
|
|
/**
|
||
|
|
* OpenClaw Command Center Dashboard Server
|
||
|
|
* Serves the dashboard UI and provides API endpoints for status data
|
||
|
|
*/
|
||
|
|
|
||
|
|
const http = require("http");
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// CLI ARGUMENT PARSING
|
||
|
|
// ============================================================================
|
||
|
|
const args = process.argv.slice(2);
|
||
|
|
let cliProfile = null;
|
||
|
|
let cliPort = null;
|
||
|
|
|
||
|
|
for (let i = 0; i < args.length; i++) {
|
||
|
|
switch (args[i]) {
|
||
|
|
case "--profile":
|
||
|
|
case "-p":
|
||
|
|
cliProfile = args[++i];
|
||
|
|
break;
|
||
|
|
case "--port":
|
||
|
|
cliPort = parseInt(args[++i], 10);
|
||
|
|
break;
|
||
|
|
case "--help":
|
||
|
|
case "-h":
|
||
|
|
console.log(`
|
||
|
|
OpenClaw Command Center
|
||
|
|
|
||
|
|
Usage: node lib/server.js [options]
|
||
|
|
|
||
|
|
Options:
|
||
|
|
--profile, -p <name> OpenClaw profile (uses ~/.openclaw-<name>)
|
||
|
|
--port <port> Server port (default: 3333)
|
||
|
|
--help, -h Show this help
|
||
|
|
|
||
|
|
Environment:
|
||
|
|
OPENCLAW_PROFILE Same as --profile
|
||
|
|
PORT Same as --port
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
node lib/server.js --profile production
|
||
|
|
node lib/server.js -p dev --port 3334
|
||
|
|
`);
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set profile in environment so CONFIG and all CLI calls pick it up
|
||
|
|
if (cliProfile) {
|
||
|
|
process.env.OPENCLAW_PROFILE = cliProfile;
|
||
|
|
}
|
||
|
|
if (cliPort) {
|
||
|
|
process.env.PORT = cliPort.toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// MODULE IMPORTS (after env vars are set)
|
||
|
|
// ============================================================================
|
||
|
|
const { getVersion } = require("./utils");
|
||
|
|
const { CONFIG, getOpenClawDir } = require("./config");
|
||
|
|
const { handleJobsRequest, isJobsRoute } = require("./jobs");
|
||
|
|
const { runOpenClaw, runOpenClawAsync, extractJSON } = require("./openclaw");
|
||
|
|
const { getSystemVitals, checkOptionalDeps, getOptionalDeps } = require("./vitals");
|
||
|
|
const { checkAuth, getUnauthorizedPage } = require("./auth");
|
||
|
|
const { loadPrivacySettings, savePrivacySettings } = require("./privacy");
|
||
|
|
const {
|
||
|
|
loadOperators,
|
||
|
|
saveOperators,
|
||
|
|
getOperatorBySlackId,
|
||
|
|
startOperatorsRefresh,
|
||
|
|
} = require("./operators");
|
||
|
|
const { createSessionsModule } = require("./sessions");
|
||
|
|
const { getCronJobs } = require("./cron");
|
||
|
|
const { getCerebroTopics, updateTopicStatus } = require("./cerebro");
|
||
|
|
const {
|
||
|
|
getDailyTokenUsage,
|
||
|
|
getTokenStats,
|
||
|
|
getCostBreakdown,
|
||
|
|
startTokenUsageRefresh,
|
||
|
|
refreshTokenUsageAsync,
|
||
|
|
} = require("./tokens");
|
||
|
|
const { getLlmUsage, getRoutingStats, startLlmUsageRefresh } = require("./llm-usage");
|
||
|
|
const { executeAction } = require("./actions");
|
||
|
|
const { migrateDataDir } = require("./data");
|
||
|
|
const { createStateModule } = require("./state");
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// CONFIGURATION
|
||
|
|
// ============================================================================
|
||
|
|
const PORT = CONFIG.server.port;
|
||
|
|
const DASHBOARD_DIR = path.join(__dirname, "../public");
|
||
|
|
const PATHS = CONFIG.paths;
|
||
|
|
|
||
|
|
const AUTH_CONFIG = {
|
||
|
|
mode: CONFIG.auth.mode,
|
||
|
|
token: CONFIG.auth.token,
|
||
|
|
allowedUsers: CONFIG.auth.allowedUsers,
|
||
|
|
allowedIPs: CONFIG.auth.allowedIPs,
|
||
|
|
publicPaths: CONFIG.auth.publicPaths,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Profile-aware data directory
|
||
|
|
const DATA_DIR = path.join(getOpenClawDir(), "command-center", "data");
|
||
|
|
const LEGACY_DATA_DIR = path.join(DASHBOARD_DIR, "data");
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// SSE (Server-Sent Events)
|
||
|
|
// ============================================================================
|
||
|
|
const sseClients = new Set();
|
||
|
|
|
||
|
|
function sendSSE(res, event, data) {
|
||
|
|
try {
|
||
|
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||
|
|
} catch (e) {
|
||
|
|
// Client disconnected
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function broadcastSSE(event, data) {
|
||
|
|
for (const client of sseClients) {
|
||
|
|
sendSSE(client, event, data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// INITIALIZE MODULES (wire up dependencies)
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
// Sessions module (factory pattern with dependency injection)
|
||
|
|
const sessions = createSessionsModule({
|
||
|
|
getOpenClawDir,
|
||
|
|
getOperatorBySlackId: (slackId) => getOperatorBySlackId(DATA_DIR, slackId),
|
||
|
|
runOpenClaw,
|
||
|
|
runOpenClawAsync,
|
||
|
|
extractJSON,
|
||
|
|
});
|
||
|
|
|
||
|
|
// State module (factory pattern)
|
||
|
|
const state = createStateModule({
|
||
|
|
CONFIG,
|
||
|
|
getOpenClawDir,
|
||
|
|
getSessions: (opts) => sessions.getSessions(opts),
|
||
|
|
getSystemVitals,
|
||
|
|
getCronJobs: () => getCronJobs(getOpenClawDir),
|
||
|
|
loadOperators: () => loadOperators(DATA_DIR),
|
||
|
|
getLlmUsage: () => getLlmUsage(PATHS.state),
|
||
|
|
getDailyTokenUsage: () => getDailyTokenUsage(getOpenClawDir),
|
||
|
|
getTokenStats,
|
||
|
|
getCerebroTopics: (opts) => getCerebroTopics(PATHS.cerebro, opts),
|
||
|
|
runOpenClaw,
|
||
|
|
extractJSON,
|
||
|
|
readTranscript: (sessionId) => sessions.readTranscript(sessionId),
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// STARTUP: Data migration + background tasks
|
||
|
|
// ============================================================================
|
||
|
|
process.nextTick(() => migrateDataDir(DATA_DIR, LEGACY_DATA_DIR));
|
||
|
|
startOperatorsRefresh(DATA_DIR, getOpenClawDir);
|
||
|
|
startLlmUsageRefresh();
|
||
|
|
startTokenUsageRefresh(getOpenClawDir);
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// STATIC FILE SERVER
|
||
|
|
// ============================================================================
|
||
|
|
function serveStatic(req, res) {
|
||
|
|
// Parse URL to safely extract pathname (ignoring query/hash)
|
||
|
|
const requestUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||
|
|
const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
|
||
|
|
|
||
|
|
// Reject any path containing ".." segments (path traversal)
|
||
|
|
if (pathname.includes("..")) {
|
||
|
|
res.writeHead(400);
|
||
|
|
res.end("Bad request");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Normalize and resolve to ensure path stays within DASHBOARD_DIR
|
||
|
|
const normalizedPath = path.normalize(pathname).replace(/^[/\\]+/, "");
|
||
|
|
const filePath = path.join(DASHBOARD_DIR, normalizedPath);
|
||
|
|
|
||
|
|
const resolvedDashboardDir = path.resolve(DASHBOARD_DIR);
|
||
|
|
const resolvedFilePath = path.resolve(filePath);
|
||
|
|
if (
|
||
|
|
!resolvedFilePath.startsWith(resolvedDashboardDir + path.sep) &&
|
||
|
|
resolvedFilePath !== resolvedDashboardDir
|
||
|
|
) {
|
||
|
|
res.writeHead(403);
|
||
|
|
res.end("Forbidden");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ext = path.extname(filePath);
|
||
|
|
const contentTypes = {
|
||
|
|
".html": "text/html",
|
||
|
|
".css": "text/css",
|
||
|
|
".js": "text/javascript",
|
||
|
|
".json": "application/json",
|
||
|
|
".png": "image/png",
|
||
|
|
".svg": "image/svg+xml",
|
||
|
|
};
|
||
|
|
|
||
|
|
fs.readFile(filePath, (err, content) => {
|
||
|
|
if (err) {
|
||
|
|
res.writeHead(404);
|
||
|
|
res.end("Not found");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const headers = { "Content-Type": contentTypes[ext] || "text/plain" };
|
||
|
|
|
||
|
|
// Avoid stale dashboards (users frequently hard-refresh while iterating)
|
||
|
|
if ([".html", ".css", ".js", ".json"].includes(ext)) {
|
||
|
|
headers["Cache-Control"] = "no-store";
|
||
|
|
}
|
||
|
|
|
||
|
|
res.writeHead(200, headers);
|
||
|
|
res.end(content);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// LEGACY API HANDLER
|
||
|
|
// ============================================================================
|
||
|
|
function handleApi(req, res) {
|
||
|
|
const sessionsList = sessions.getSessions();
|
||
|
|
const capacity = state.getCapacity();
|
||
|
|
const tokenStats = getTokenStats(sessionsList, capacity, CONFIG);
|
||
|
|
|
||
|
|
const data = {
|
||
|
|
sessions: sessionsList,
|
||
|
|
cron: getCronJobs(getOpenClawDir),
|
||
|
|
system: state.getSystemStatus(),
|
||
|
|
activity: state.getRecentActivity(),
|
||
|
|
tokenStats,
|
||
|
|
capacity,
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(data, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// HTTP SERVER
|
||
|
|
// ============================================================================
|
||
|
|
const server = http.createServer((req, res) => {
|
||
|
|
// CORS headers
|
||
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
|
|
|
||
|
|
const urlParts = req.url.split("?");
|
||
|
|
const pathname = urlParts[0];
|
||
|
|
const query = new URLSearchParams(urlParts[1] || "");
|
||
|
|
|
||
|
|
// Fast path for health check
|
||
|
|
if (pathname === "/api/health") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ status: "ok", port: PORT, timestamp: new Date().toISOString() }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auth check (unless public path)
|
||
|
|
const isPublicPath = AUTH_CONFIG.publicPaths.some(
|
||
|
|
(p) => pathname === p || pathname.startsWith(p + "/"),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!isPublicPath && AUTH_CONFIG.mode !== "none") {
|
||
|
|
const authResult = checkAuth(req, AUTH_CONFIG);
|
||
|
|
|
||
|
|
if (!authResult.authorized) {
|
||
|
|
console.log(`[AUTH] Denied: ${authResult.reason} (path: ${pathname})`);
|
||
|
|
res.writeHead(403, { "Content-Type": "text/html" });
|
||
|
|
res.end(getUnauthorizedPage(authResult.reason, authResult.user, AUTH_CONFIG));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
req.authUser = authResult.user;
|
||
|
|
|
||
|
|
if (authResult.user?.login || authResult.user?.email) {
|
||
|
|
console.log(
|
||
|
|
`[AUTH] Allowed: ${authResult.user.login || authResult.user.email} (path: ${pathname})`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.log(`[AUTH] Allowed: ${req.socket?.remoteAddress} (path: ${pathname})`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- API Routes ----
|
||
|
|
|
||
|
|
if (pathname === "/api/status") {
|
||
|
|
handleApi(req, res);
|
||
|
|
} else if (pathname === "/api/session") {
|
||
|
|
const sessionKey = query.get("key");
|
||
|
|
if (!sessionKey) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Missing session key" }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const detail = sessions.getSessionDetail(sessionKey);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(detail, null, 2));
|
||
|
|
} else if (pathname === "/api/cerebro") {
|
||
|
|
const offset = parseInt(query.get("offset") || "0", 10);
|
||
|
|
const limit = parseInt(query.get("limit") || "20", 10);
|
||
|
|
const statusFilter = query.get("status") || "all";
|
||
|
|
|
||
|
|
const data = getCerebroTopics(PATHS.cerebro, { offset, limit, status: statusFilter });
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(data, null, 2));
|
||
|
|
} else if (
|
||
|
|
pathname.startsWith("/api/cerebro/topic/") &&
|
||
|
|
pathname.endsWith("/status") &&
|
||
|
|
req.method === "POST"
|
||
|
|
) {
|
||
|
|
const topicId = decodeURIComponent(
|
||
|
|
pathname.replace("/api/cerebro/topic/", "").replace("/status", ""),
|
||
|
|
);
|
||
|
|
|
||
|
|
let body = "";
|
||
|
|
req.on("data", (chunk) => {
|
||
|
|
body += chunk;
|
||
|
|
});
|
||
|
|
req.on("end", () => {
|
||
|
|
try {
|
||
|
|
const { status: newStatus } = JSON.parse(body);
|
||
|
|
|
||
|
|
if (!newStatus || !["active", "resolved", "parked"].includes(newStatus)) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(
|
||
|
|
JSON.stringify({ error: "Invalid status. Must be: active, resolved, or parked" }),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = updateTopicStatus(PATHS.cerebro, topicId, newStatus);
|
||
|
|
|
||
|
|
if (result.error) {
|
||
|
|
res.writeHead(result.code || 500, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: result.error }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(result, null, 2));
|
||
|
|
} catch (e) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
} else if (pathname === "/api/llm-quota") {
|
||
|
|
const data = getLlmUsage(PATHS.state);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(data, null, 2));
|
||
|
|
} else if (pathname === "/api/cost-breakdown") {
|
||
|
|
const data = getCostBreakdown(CONFIG, (opts) => sessions.getSessions(opts), getOpenClawDir);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(data, null, 2));
|
||
|
|
} else if (pathname === "/api/subagents") {
|
||
|
|
const data = state.getSubagentStatus();
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ subagents: data }, null, 2));
|
||
|
|
} else if (pathname === "/api/action") {
|
||
|
|
const action = query.get("action");
|
||
|
|
if (!action) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Missing action parameter" }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const result = executeAction(action, { runOpenClaw, extractJSON, PORT });
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(result, null, 2));
|
||
|
|
} else if (pathname === "/api/events") {
|
||
|
|
// SSE endpoint
|
||
|
|
res.writeHead(200, {
|
||
|
|
"Content-Type": "text/event-stream",
|
||
|
|
"Cache-Control": "no-cache",
|
||
|
|
Connection: "keep-alive",
|
||
|
|
"X-Accel-Buffering": "no",
|
||
|
|
});
|
||
|
|
|
||
|
|
sseClients.add(res);
|
||
|
|
console.log(`[SSE] Client connected (total: ${sseClients.size})`);
|
||
|
|
|
||
|
|
sendSSE(res, "connected", { message: "Connected to Command Center", timestamp: Date.now() });
|
||
|
|
|
||
|
|
const cachedState = state.getFullState();
|
||
|
|
if (cachedState) {
|
||
|
|
sendSSE(res, "update", cachedState);
|
||
|
|
} else {
|
||
|
|
sendSSE(res, "update", { sessions: [], loading: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
req.on("close", () => {
|
||
|
|
sseClients.delete(res);
|
||
|
|
console.log(`[SSE] Client disconnected (total: ${sseClients.size})`);
|
||
|
|
});
|
||
|
|
|
||
|
|
return;
|
||
|
|
} else if (pathname === "/api/whoami") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
authMode: AUTH_CONFIG.mode,
|
||
|
|
user: req.authUser || null,
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else if (pathname === "/api/about") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
name: "OpenClaw Command Center",
|
||
|
|
version: getVersion(),
|
||
|
|
description: "A Starcraft-inspired dashboard for AI agent orchestration",
|
||
|
|
license: "MIT",
|
||
|
|
repository: "https://github.com/jontsai/openclaw-command-center",
|
||
|
|
builtWith: ["OpenClaw", "Node.js", "Vanilla JS"],
|
||
|
|
inspirations: ["Starcraft", "Inside Out", "iStatMenus", "DaisyDisk", "Gmail"],
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else if (pathname === "/api/state") {
|
||
|
|
const fullState = state.getFullState();
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(fullState, null, 2));
|
||
|
|
} else if (pathname === "/api/vitals") {
|
||
|
|
const vitals = getSystemVitals();
|
||
|
|
const optionalDeps = getOptionalDeps();
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ vitals, optionalDeps }, null, 2));
|
||
|
|
} else if (pathname === "/api/capacity") {
|
||
|
|
const capacity = state.getCapacity();
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(capacity, null, 2));
|
||
|
|
} else if (pathname === "/api/sessions") {
|
||
|
|
const page = parseInt(query.get("page")) || 1;
|
||
|
|
const pageSize = parseInt(query.get("pageSize")) || 20;
|
||
|
|
const statusFilter = query.get("status");
|
||
|
|
|
||
|
|
const allSessions = sessions.getSessions({ limit: null });
|
||
|
|
|
||
|
|
const statusCounts = {
|
||
|
|
all: allSessions.length,
|
||
|
|
live: allSessions.filter((s) => s.active).length,
|
||
|
|
recent: allSessions.filter((s) => !s.active && s.recentlyActive).length,
|
||
|
|
idle: allSessions.filter((s) => !s.active && !s.recentlyActive).length,
|
||
|
|
};
|
||
|
|
|
||
|
|
let filteredSessions = allSessions;
|
||
|
|
if (statusFilter === "live") {
|
||
|
|
filteredSessions = allSessions.filter((s) => s.active);
|
||
|
|
} else if (statusFilter === "recent") {
|
||
|
|
filteredSessions = allSessions.filter((s) => !s.active && s.recentlyActive);
|
||
|
|
} else if (statusFilter === "idle") {
|
||
|
|
filteredSessions = allSessions.filter((s) => !s.active && !s.recentlyActive);
|
||
|
|
}
|
||
|
|
|
||
|
|
const total = filteredSessions.length;
|
||
|
|
const totalPages = Math.ceil(total / pageSize);
|
||
|
|
const offset = (page - 1) * pageSize;
|
||
|
|
const displaySessions = filteredSessions.slice(offset, offset + pageSize);
|
||
|
|
|
||
|
|
const tokenStats = getTokenStats(allSessions, state.getCapacity(), CONFIG);
|
||
|
|
const capacity = state.getCapacity();
|
||
|
|
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
sessions: displaySessions,
|
||
|
|
pagination: {
|
||
|
|
page,
|
||
|
|
pageSize,
|
||
|
|
total,
|
||
|
|
totalPages,
|
||
|
|
hasPrev: page > 1,
|
||
|
|
hasNext: page < totalPages,
|
||
|
|
},
|
||
|
|
statusCounts,
|
||
|
|
tokenStats,
|
||
|
|
capacity,
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else if (pathname === "/api/cron") {
|
||
|
|
const cron = getCronJobs(getOpenClawDir);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ cron }, null, 2));
|
||
|
|
} else if (pathname === "/api/operators") {
|
||
|
|
const method = req.method;
|
||
|
|
const data = loadOperators(DATA_DIR);
|
||
|
|
|
||
|
|
if (method === "GET") {
|
||
|
|
const allSessions = sessions.getSessions({ limit: null });
|
||
|
|
const operatorsWithStats = data.operators.map((op) => {
|
||
|
|
const userSessions = allSessions.filter(
|
||
|
|
(s) => s.originator?.userId === op.id || s.originator?.userId === op.metadata?.slackId,
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
...op,
|
||
|
|
stats: {
|
||
|
|
activeSessions: userSessions.filter((s) => s.active).length,
|
||
|
|
totalSessions: userSessions.length,
|
||
|
|
lastSeen:
|
||
|
|
userSessions.length > 0
|
||
|
|
? new Date(
|
||
|
|
Date.now() - Math.min(...userSessions.map((s) => s.minutesAgo)) * 60000,
|
||
|
|
).toISOString()
|
||
|
|
: op.lastSeen,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(
|
||
|
|
JSON.stringify(
|
||
|
|
{
|
||
|
|
operators: operatorsWithStats,
|
||
|
|
roles: data.roles,
|
||
|
|
timestamp: Date.now(),
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else if (method === "POST") {
|
||
|
|
let body = "";
|
||
|
|
req.on("data", (chunk) => (body += chunk));
|
||
|
|
req.on("end", () => {
|
||
|
|
try {
|
||
|
|
const newOp = JSON.parse(body);
|
||
|
|
const existingIdx = data.operators.findIndex((op) => op.id === newOp.id);
|
||
|
|
if (existingIdx >= 0) {
|
||
|
|
data.operators[existingIdx] = { ...data.operators[existingIdx], ...newOp };
|
||
|
|
} else {
|
||
|
|
data.operators.push({
|
||
|
|
...newOp,
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (saveOperators(DATA_DIR, data)) {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: true, operator: newOp }));
|
||
|
|
} else {
|
||
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Failed to save" }));
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
} else {
|
||
|
|
res.writeHead(405, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Method not allowed" }));
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
} else if (pathname === "/api/llm-usage") {
|
||
|
|
const usage = getLlmUsage(PATHS.state);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(usage, null, 2));
|
||
|
|
} else if (pathname === "/api/routing-stats") {
|
||
|
|
const hours = parseInt(query.get("hours") || "24", 10);
|
||
|
|
const stats = getRoutingStats(PATHS.skills, PATHS.state, hours);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(stats, null, 2));
|
||
|
|
} else if (pathname === "/api/memory") {
|
||
|
|
const memory = state.getMemoryStats();
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ memory }, null, 2));
|
||
|
|
} else if (pathname === "/api/privacy") {
|
||
|
|
if (req.method === "GET") {
|
||
|
|
const settings = loadPrivacySettings(DATA_DIR);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(settings, null, 2));
|
||
|
|
} else if (req.method === "POST" || req.method === "PUT") {
|
||
|
|
let body = "";
|
||
|
|
req.on("data", (chunk) => (body += chunk));
|
||
|
|
req.on("end", () => {
|
||
|
|
try {
|
||
|
|
const updates = JSON.parse(body);
|
||
|
|
const current = loadPrivacySettings(DATA_DIR);
|
||
|
|
|
||
|
|
const merged = {
|
||
|
|
version: current.version || 1,
|
||
|
|
hiddenTopics: updates.hiddenTopics ?? current.hiddenTopics ?? [],
|
||
|
|
hiddenSessions: updates.hiddenSessions ?? current.hiddenSessions ?? [],
|
||
|
|
hiddenCrons: updates.hiddenCrons ?? current.hiddenCrons ?? [],
|
||
|
|
hideHostname: updates.hideHostname ?? current.hideHostname ?? false,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (savePrivacySettings(DATA_DIR, merged)) {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: true, settings: merged }));
|
||
|
|
} else {
|
||
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Failed to save privacy settings" }));
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Invalid JSON: " + e.message }));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
} else {
|
||
|
|
res.writeHead(405, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ error: "Method not allowed" }));
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
} else if (isJobsRoute(pathname)) {
|
||
|
|
handleJobsRequest(req, res, pathname, query, req.method);
|
||
|
|
} else {
|
||
|
|
serveStatic(req, res);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// START SERVER
|
||
|
|
// ============================================================================
|
||
|
|
server.listen(PORT, () => {
|
||
|
|
const profile = process.env.OPENCLAW_PROFILE;
|
||
|
|
console.log(`\u{1F99E} OpenClaw Command Center running at http://localhost:${PORT}`);
|
||
|
|
if (profile) {
|
||
|
|
console.log(` Profile: ${profile} (~/.openclaw-${profile})`);
|
||
|
|
}
|
||
|
|
console.log(` Press Ctrl+C to stop`);
|
||
|
|
|
||
|
|
// Pre-warm caches in background
|
||
|
|
setTimeout(async () => {
|
||
|
|
console.log("[Startup] Pre-warming caches in background...");
|
||
|
|
try {
|
||
|
|
await Promise.all([sessions.refreshSessionsCache(), refreshTokenUsageAsync(getOpenClawDir)]);
|
||
|
|
getSystemVitals();
|
||
|
|
console.log("[Startup] Caches warmed.");
|
||
|
|
} catch (e) {
|
||
|
|
console.log("[Startup] Cache warming error:", e.message);
|
||
|
|
}
|
||
|
|
// Check for optional system dependencies (once at startup)
|
||
|
|
checkOptionalDeps();
|
||
|
|
}, 100);
|
||
|
|
|
||
|
|
// Background cache refresh
|
||
|
|
const SESSIONS_CACHE_TTL = 10000;
|
||
|
|
setInterval(() => sessions.refreshSessionsCache(), SESSIONS_CACHE_TTL);
|
||
|
|
});
|
||
|
|
|
||
|
|
// SSE heartbeat
|
||
|
|
let sseRefreshing = false;
|
||
|
|
setInterval(() => {
|
||
|
|
if (sseClients.size > 0 && !sseRefreshing) {
|
||
|
|
sseRefreshing = true;
|
||
|
|
try {
|
||
|
|
const fullState = state.refreshState();
|
||
|
|
broadcastSSE("update", fullState);
|
||
|
|
broadcastSSE("heartbeat", { clients: sseClients.size, timestamp: Date.now() });
|
||
|
|
} catch (e) {
|
||
|
|
console.error("[SSE] Broadcast error:", e.message);
|
||
|
|
}
|
||
|
|
sseRefreshing = false;
|
||
|
|
}
|
||
|
|
}, 15000);
|