Initial commit with translated description
This commit is contained in:
671
src/index.js
Normal file
671
src/index.js
Normal file
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user