mirror of
https://github.com/tugcantopaloglu/openclaw-dashboard.git
synced 2026-08-14 00:47:52 +00:00
2634 lines
101 KiB
JavaScript
2634 lines
101 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { exec } = require('child_process');
|
|
const crypto = require('crypto');
|
|
|
|
const PORT = parseInt(process.env.DASHBOARD_PORT || '7000');
|
|
const OPENCLAW_DIR = process.env.OPENCLAW_DIR || path.join(os.homedir(), '.openclaw');
|
|
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.env.OPENCLAW_WORKSPACE || process.cwd();
|
|
const AGENT_ID = process.env.OPENCLAW_AGENT || 'main';
|
|
const sessDir = path.join(OPENCLAW_DIR, 'agents', AGENT_ID, 'sessions');
|
|
const cronFile = path.join(OPENCLAW_DIR, 'cron', 'jobs.json');
|
|
const dataDir = path.join(WORKSPACE_DIR, 'data');
|
|
const memoryDir = path.join(WORKSPACE_DIR, 'memory');
|
|
const memoryMdPath = path.join(WORKSPACE_DIR, 'MEMORY.md');
|
|
const heartbeatPath = path.join(WORKSPACE_DIR, 'HEARTBEAT.md');
|
|
const healthHistoryFile = path.join(dataDir, 'health-history.json');
|
|
const AUTH_DATA_DIR = process.env.DASHBOARD_AUTH_DIR || dataDir;
|
|
const auditLogPath = path.join(AUTH_DATA_DIR, 'audit.log');
|
|
const credentialsFile = path.join(AUTH_DATA_DIR, 'credentials.json');
|
|
const mfaSecretFile = path.join(AUTH_DATA_DIR, 'mfa-secret.txt');
|
|
|
|
const skillsDir = path.join(WORKSPACE_DIR, 'skills');
|
|
const configFiles = [
|
|
{ name: 'openclaw-gateway.service', path: path.join(os.homedir(), '.config/systemd/user/openclaw-gateway.service') },
|
|
{ name: 'openclaw-config.json', path: path.join(os.homedir(), '.openclaw/config.json') },
|
|
];
|
|
const workspaceFilenames = ['AGENTS.md','HEARTBEAT.md','IDENTITY.md','MEMORY.md','SOUL.md','TOOLS.md','USER.md'];
|
|
const claudeUsageFile = path.join(dataDir, 'claude-usage.json');
|
|
const geminiUsageFile = path.join(dataDir, 'gemini-usage.json');
|
|
const scrapeScript = path.join(WORKSPACE_DIR, 'scripts', 'scrape-claude-usage.sh');
|
|
const geminiScrapeScript = path.join(WORKSPACE_DIR, 'scripts', 'scrape-gemini-usage.sh');
|
|
const pricingFile = path.join(WORKSPACE_DIR, 'data', 'model_pricing_usd_per_million.json');
|
|
|
|
const htmlPath = path.join(__dirname, 'index.html');
|
|
|
|
const DEFAULT_MODEL_PRICING = {
|
|
'anthropic/claude-opus-4-6': { input: 15.00, output: 75.00, cacheRead: 1.875, cacheWrite: 18.75 },
|
|
'anthropic/claude-opus-4-5': { input: 15.00, output: 75.00, cacheRead: 1.875, cacheWrite: 18.75 },
|
|
'anthropic/claude-sonnet-4-6': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
'anthropic/claude-sonnet-4-5': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
'anthropic/claude-3-5-haiku-latest': { input: 0.80, output: 4.00, cacheRead: 0.08, cacheWrite: 1.00 },
|
|
'openai/gpt-4o-mini': { input: 0.15, output: 0.60, cacheRead: 0.075, cacheWrite: 0.30 },
|
|
'openai/gpt-4.1-mini': { input: 0.40, output: 1.60, cacheRead: 0.20, cacheWrite: 0.80 },
|
|
'google/gemini-3-pro-preview': { input: 1.25, output: 10.00, cacheRead: 0.31, cacheWrite: 4.50 },
|
|
'google/gemini-3-flash-preview': { input: 0.15, output: 0.60, cacheRead: 0.04, cacheWrite: 0.15 },
|
|
'xai/grok-4-1-fast': { input: 0.20, output: 0.50, cacheRead: 0.05, cacheWrite: 0.20 }
|
|
};
|
|
|
|
function toNum(v) {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function normalizeProvider(provider) {
|
|
return String(provider || 'unknown').trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeModel(provider, model) {
|
|
const p = normalizeProvider(provider);
|
|
let m = String(model || 'unknown').trim();
|
|
const pref = p + '/';
|
|
if (m.toLowerCase().startsWith(pref)) m = m.slice(pref.length);
|
|
const ml = m.toLowerCase();
|
|
if (p === 'anthropic') {
|
|
if (ml.startsWith('claude-opus-4-6')) return 'claude-opus-4-6';
|
|
if (ml.startsWith('claude-opus-4-5')) return 'claude-opus-4-5';
|
|
if (ml.startsWith('claude-sonnet-4-6')) return 'claude-sonnet-4-6';
|
|
if (ml.startsWith('claude-sonnet-4-5')) return 'claude-sonnet-4-5';
|
|
if (ml.startsWith('claude-3-5-haiku')) return 'claude-3-5-haiku-latest';
|
|
}
|
|
if (p === 'openai') {
|
|
if (ml.startsWith('gpt-4o-mini')) return 'gpt-4o-mini';
|
|
if (ml.startsWith('gpt-4.1-mini')) return 'gpt-4.1-mini';
|
|
}
|
|
if (p === 'google' && ml.startsWith('gemini-3-flash-preview')) return 'gemini-3-flash-preview';
|
|
if (p === 'xai' && ml.startsWith('grok-4-1-fast')) return 'grok-4-1-fast';
|
|
if (p === 'nvidia' && ml.includes('kimi-k2.5')) return 'moonshotai/kimi-k2.5';
|
|
return m;
|
|
}
|
|
|
|
function loadModelPricing() {
|
|
try {
|
|
if (!fs.existsSync(pricingFile)) return { ...DEFAULT_MODEL_PRICING };
|
|
const parsed = JSON.parse(fs.readFileSync(pricingFile, 'utf8'));
|
|
const rates = parsed && parsed.rates_usd_per_million;
|
|
if (!rates || typeof rates !== 'object') return { ...DEFAULT_MODEL_PRICING };
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(rates)) {
|
|
if (!k.includes('/') || !v || typeof v !== 'object') continue;
|
|
out[String(k)] = {
|
|
input: toNum(v.input),
|
|
output: toNum(v.output),
|
|
cacheRead: toNum(v.cacheRead),
|
|
cacheWrite: toNum(v.cacheWrite)
|
|
};
|
|
}
|
|
return Object.keys(out).length ? out : { ...DEFAULT_MODEL_PRICING };
|
|
} catch {
|
|
return { ...DEFAULT_MODEL_PRICING };
|
|
}
|
|
}
|
|
|
|
const MODEL_PRICING = loadModelPricing();
|
|
|
|
function estimateMsgCost(msg) {
|
|
const usage = msg && msg.usage ? msg.usage : {};
|
|
const explicit = toNum(usage.cost && usage.cost.total);
|
|
if (explicit > 0) return explicit;
|
|
const provider = normalizeProvider(msg && msg.provider);
|
|
const modelNorm = normalizeModel(provider, msg && msg.model);
|
|
const rates = MODEL_PRICING[`${provider}/${modelNorm}`];
|
|
if (!rates) return 0;
|
|
const input = Math.max(0, toNum(usage.input)) / 1_000_000;
|
|
const output = Math.max(0, toNum(usage.output)) / 1_000_000;
|
|
const cacheRead = Math.max(0, toNum(usage.cacheRead)) / 1_000_000;
|
|
const cacheWrite = Math.max(0, toNum(usage.cacheWrite)) / 1_000_000;
|
|
return (
|
|
input * toNum(rates.input) +
|
|
output * toNum(rates.output) +
|
|
cacheRead * toNum(rates.cacheRead) +
|
|
cacheWrite * toNum(rates.cacheWrite)
|
|
);
|
|
}
|
|
|
|
try { fs.mkdirSync(dataDir, { recursive: true }); } catch {}
|
|
try { fs.mkdirSync(path.dirname(auditLogPath), { recursive: true }); } catch {}
|
|
try { fs.mkdirSync(path.dirname(credentialsFile), { recursive: true }); } catch {}
|
|
|
|
let DASHBOARD_TOKEN = process.env.DASHBOARD_TOKEN;
|
|
if (!DASHBOARD_TOKEN) {
|
|
DASHBOARD_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
console.log('');
|
|
console.log('═══════════════════════════════════════════════════════════');
|
|
console.log(' 🔐 Recovery Token');
|
|
console.log('═══════════════════════════════════════════════════════════');
|
|
console.log('');
|
|
console.log(' ' + DASHBOARD_TOKEN);
|
|
console.log('');
|
|
console.log(' Use this token to reset your password if forgotten.');
|
|
console.log(' Set DASHBOARD_TOKEN env variable for a custom token.');
|
|
console.log('═══════════════════════════════════════════════════════════');
|
|
console.log('');
|
|
|
|
let MFA_SECRET = process.env.DASHBOARD_MFA_SECRET;
|
|
if (!MFA_SECRET && fs.existsSync(mfaSecretFile)) {
|
|
try {
|
|
MFA_SECRET = fs.readFileSync(mfaSecretFile, 'utf8').trim();
|
|
} catch {}
|
|
}
|
|
|
|
const sessions = new Map();
|
|
const SESSION_ACTIVITY_TIMEOUT = 30 * 60 * 1000;
|
|
const SESSION_REMEMBER_LIFETIME = 3 * 60 * 60 * 1000;
|
|
|
|
function hashPassword(password, salt) {
|
|
if (!salt) salt = crypto.randomBytes(32).toString('hex');
|
|
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
return { hash, salt };
|
|
}
|
|
|
|
function verifyPassword(password, hash, salt) {
|
|
const result = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
return crypto.timingSafeEqual(Buffer.from(result), Buffer.from(hash));
|
|
}
|
|
|
|
function getCredentials() {
|
|
try {
|
|
if (!fs.existsSync(credentialsFile)) return null;
|
|
return JSON.parse(fs.readFileSync(credentialsFile, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveCredentials(creds) {
|
|
const tmp = credentialsFile + '.tmp.' + Date.now();
|
|
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), 'utf8');
|
|
fs.renameSync(tmp, credentialsFile);
|
|
}
|
|
|
|
function generateSessionToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
function createSession(username, ip, rememberMe = false) {
|
|
const token = generateSessionToken();
|
|
const now = Date.now();
|
|
const expiresAt = now + (rememberMe ? SESSION_REMEMBER_LIFETIME : SESSION_ACTIVITY_TIMEOUT);
|
|
sessions.set(token, {
|
|
username,
|
|
ip,
|
|
createdAt: now,
|
|
lastActivity: now,
|
|
expiresAt,
|
|
rememberMe
|
|
});
|
|
return token;
|
|
}
|
|
|
|
function validatePassword(password) {
|
|
if (password.length < 8) return 'Password must be at least 8 characters';
|
|
if (!/[a-zA-Z]/.test(password)) return 'Password must contain at least 1 letter';
|
|
if (!/\d/.test(password)) return 'Password must contain at least 1 number';
|
|
return null;
|
|
}
|
|
|
|
function safeCompare(a, b) {
|
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
const bufA = Buffer.from(a);
|
|
const bufB = Buffer.from(b);
|
|
if (bufA.length !== bufB.length) return false;
|
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
}
|
|
|
|
function base32Decode(input) {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
let bits = '';
|
|
for (const char of input.toUpperCase().replace(/=+$/, '')) {
|
|
const val = alphabet.indexOf(char);
|
|
if (val === -1) continue;
|
|
bits += val.toString(2).padStart(5, '0');
|
|
}
|
|
const bytes = [];
|
|
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
bytes.push(parseInt(bits.substring(i, i + 8), 2));
|
|
}
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
function base32Encode(buffer) {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
let bits = '';
|
|
for (const byte of buffer) {
|
|
bits += byte.toString(2).padStart(8, '0');
|
|
}
|
|
let result = '';
|
|
for (let i = 0; i < bits.length; i += 5) {
|
|
const chunk = bits.substring(i, i + 5).padEnd(5, '0');
|
|
result += alphabet[parseInt(chunk, 2)];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function generateTOTP(secret, timeStep = 30, digits = 6, window = 0) {
|
|
const epoch = Math.floor(Date.now() / 1000);
|
|
const counter = Math.floor(epoch / timeStep) + window;
|
|
const counterBuffer = Buffer.alloc(8);
|
|
counterBuffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
counterBuffer.writeUInt32BE(counter & 0xFFFFFFFF, 4);
|
|
|
|
const decodedSecret = base32Decode(secret);
|
|
const hmac = crypto.createHmac('sha1', decodedSecret);
|
|
hmac.update(counterBuffer);
|
|
const hash = hmac.digest();
|
|
|
|
const offset = hash[hash.length - 1] & 0x0f;
|
|
const binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
|
|
const otp = binary % (10 ** digits);
|
|
return otp.toString().padStart(digits, '0');
|
|
}
|
|
|
|
function verifyTOTP(secret, code) {
|
|
for (let w = -1; w <= 1; w++) {
|
|
if (generateTOTP(secret, 30, 6, w) === code) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const rateLimitStore = new Map();
|
|
const pendingMfaSecrets = new Map();
|
|
const MAX_FILE_BODY = 1024 * 1024;
|
|
const READ_ONLY_FILES = new Set(['openclaw-gateway.service', 'openclaw-config.json']);
|
|
|
|
function auditLog(event, ip, details = {}) {
|
|
try {
|
|
const timestamp = new Date().toISOString();
|
|
const entry = JSON.stringify({ timestamp, event, ip, ...details }) + '\n';
|
|
fs.appendFileSync(auditLogPath, entry, 'utf8');
|
|
const stats = fs.statSync(auditLogPath);
|
|
if (stats.size > 10 * 1024 * 1024) {
|
|
const lines = fs.readFileSync(auditLogPath, 'utf8').split('\n');
|
|
const keep = lines.slice(-5000).join('\n');
|
|
const tmpPath = auditLogPath + '.tmp';
|
|
fs.writeFileSync(tmpPath, keep, 'utf8');
|
|
fs.renameSync(tmpPath, auditLogPath);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function setSecurityHeaders(res) {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:");
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
}
|
|
|
|
function setSameSiteCORS(req, res) {
|
|
const origin = req.headers.origin || req.headers.referer;
|
|
const host = req.headers.host;
|
|
if (origin && origin.includes(host)) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
} else if (!origin) {
|
|
const proto = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
res.setHeader('Access-Control-Allow-Origin', `${proto}://${host}`);
|
|
}
|
|
}
|
|
|
|
function checkRateLimit(ip) {
|
|
const now = Date.now();
|
|
const attempts = rateLimitStore.get(ip) || [];
|
|
const recent = attempts.filter(t => now - t < 15 * 60 * 1000);
|
|
rateLimitStore.set(ip, recent);
|
|
if (recent.length >= 20) {
|
|
const lastAttempt = recent[recent.length - 1];
|
|
const lockoutRemaining = Math.ceil((15 * 60 * 1000 - (now - lastAttempt)) / 1000);
|
|
return { blocked: true, softLocked: true, remainingSeconds: lockoutRemaining };
|
|
}
|
|
if (recent.length >= 5) {
|
|
const lastAttempt = recent[recent.length - 1];
|
|
const lockoutRemaining = Math.ceil((15 * 60 * 1000 - (now - lastAttempt)) / 1000);
|
|
return { blocked: false, softLocked: true, remainingSeconds: lockoutRemaining };
|
|
}
|
|
return { blocked: false, softLocked: false };
|
|
}
|
|
|
|
function recordFailedAuth(ip) {
|
|
const now = Date.now();
|
|
const attempts = rateLimitStore.get(ip) || [];
|
|
attempts.push(now);
|
|
rateLimitStore.set(ip, attempts);
|
|
}
|
|
|
|
function clearFailedAuth(ip) {
|
|
rateLimitStore.delete(ip);
|
|
}
|
|
|
|
function getClientIP(req) {
|
|
return req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
function isLocalhost(ip) {
|
|
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
}
|
|
|
|
function isTailscaleIP(ip) {
|
|
const clean = ip.replace('::ffff:', '');
|
|
return clean.startsWith('100.') && parseInt(clean.split('.')[1]) >= 64 && parseInt(clean.split('.')[1]) <= 127;
|
|
}
|
|
|
|
function httpsEnforcement(req, res) {
|
|
if (process.env.DASHBOARD_ALLOW_HTTP === 'true') return true;
|
|
const ip = getClientIP(req);
|
|
if (isLocalhost(ip)) return true;
|
|
if (isTailscaleIP(ip)) return true;
|
|
if (req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https') return true;
|
|
setSecurityHeaders(res);
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
res.end('HTTPS required. Access via localhost, Tailscale, or enable HTTPS.');
|
|
return false;
|
|
}
|
|
|
|
function isAuthenticated(req) {
|
|
const authHeader = req.headers.authorization;
|
|
let token = null;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7);
|
|
} else {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
token = url.searchParams.get('token');
|
|
}
|
|
if (!token) return false;
|
|
|
|
const session = sessions.get(token);
|
|
if (!session) return false;
|
|
|
|
const now = Date.now();
|
|
if (now > session.expiresAt) {
|
|
sessions.delete(token);
|
|
return false;
|
|
}
|
|
|
|
if (!session.rememberMe) {
|
|
if (now - session.lastActivity > SESSION_ACTIVITY_TIMEOUT) {
|
|
sessions.delete(token);
|
|
return false;
|
|
}
|
|
session.lastActivity = now;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function requireAuth(req, res) {
|
|
const ip = getClientIP(req);
|
|
const limitCheck = checkRateLimit(ip);
|
|
if (limitCheck.blocked) {
|
|
setSecurityHeaders(res);
|
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Too many failed attempts', retryAfter: limitCheck.remainingSeconds }));
|
|
return false;
|
|
}
|
|
|
|
if (!isAuthenticated(req)) {
|
|
const sanitizedUrl = req.url.replace(/token=[^&]+/g, 'token=REDACTED');
|
|
setSecurityHeaders(res);
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getGitRepos() {
|
|
const repos = [];
|
|
const projDir = path.join(WORKSPACE_DIR, 'projects');
|
|
try {
|
|
if (fs.existsSync(projDir)) {
|
|
fs.readdirSync(projDir).forEach(d => {
|
|
const full = path.join(projDir, d);
|
|
if (fs.existsSync(path.join(full, '.git'))) repos.push({ path: full, name: d });
|
|
});
|
|
}
|
|
} catch {}
|
|
if (fs.existsSync(path.join(WORKSPACE_DIR, '.git'))) repos.push({ path: WORKSPACE_DIR, name: path.basename(WORKSPACE_DIR) });
|
|
return repos;
|
|
}
|
|
|
|
function resolveName(key) {
|
|
if (key.includes(':main:main')) return 'main';
|
|
if (key.includes('teleg')) return 'telegram-group';
|
|
if (key.includes('cron:')) {
|
|
try {
|
|
if (fs.existsSync(cronFile)) {
|
|
const crons = JSON.parse(fs.readFileSync(cronFile, 'utf8'));
|
|
const jobs = crons.jobs || [];
|
|
const cronPart = key.split('cron:')[1] || '';
|
|
const cronUuid = cronPart.split(':')[0];
|
|
const job = jobs.find(j => j.id === cronUuid);
|
|
if (job && job.name) return job.name;
|
|
}
|
|
} catch {}
|
|
const cronPart = key.split('cron:')[1] || '';
|
|
const cronUuid = cronPart.split(':')[0];
|
|
return 'Cron: ' + cronUuid.substring(0, 8);
|
|
}
|
|
if (key.includes('subagent')) {
|
|
const parts = key.split(':');
|
|
return parts[parts.length - 1].substring(0, 12);
|
|
}
|
|
return key.split(':').pop().substring(0, 12);
|
|
}
|
|
|
|
function getLastMessage(sessionId) {
|
|
try {
|
|
const filePath = path.join(sessDir, sessionId + '.jsonl');
|
|
if (!fs.existsSync(filePath)) return '';
|
|
const data = fs.readFileSync(filePath, 'utf8');
|
|
const lines = data.split('\n').filter(l => l.trim());
|
|
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 20); i--) {
|
|
try {
|
|
const d = JSON.parse(lines[i]);
|
|
if (d.type !== 'message') continue;
|
|
const msg = d.message;
|
|
if (!msg) continue;
|
|
const role = msg.role;
|
|
if (role !== 'user' && role !== 'assistant') continue;
|
|
let text = '';
|
|
if (typeof msg.content === 'string') {
|
|
text = msg.content;
|
|
} else if (Array.isArray(msg.content)) {
|
|
for (const b of msg.content) {
|
|
if (b.type === 'text' && b.text) { text = b.text; break; }
|
|
}
|
|
}
|
|
if (text) return text.replace(/\n/g, ' ').substring(0, 80);
|
|
} catch {}
|
|
}
|
|
return '';
|
|
} catch { return ''; }
|
|
}
|
|
|
|
function isSessionFile(f) { return f.endsWith('.jsonl') || f.includes('.jsonl.reset.'); }
|
|
function extractSessionId(f) { return f.replace(/\.jsonl(?:\.reset\.\d+)?$/, ''); }
|
|
|
|
let sessionCostCache = {};
|
|
let sessionCostCacheTime = 0;
|
|
|
|
function getSessionCost(sessionId) {
|
|
const now = Date.now();
|
|
if (now - sessionCostCacheTime > 60000) {
|
|
sessionCostCache = {};
|
|
sessionCostCacheTime = now;
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
for (const file of files) {
|
|
const sid = extractSessionId(file);
|
|
let total = 0;
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
const c = estimateMsgCost(d.message || {});
|
|
if (c > 0) total += c;
|
|
} catch {}
|
|
}
|
|
if (total > 0) sessionCostCache[sid] = Math.round(total * 100) / 100;
|
|
}
|
|
} catch {}
|
|
}
|
|
return sessionCostCache[sessionId] || 0;
|
|
}
|
|
|
|
function getSessionsJson() {
|
|
try {
|
|
const sFile = path.join(sessDir, 'sessions.json');
|
|
const data = JSON.parse(fs.readFileSync(sFile, 'utf8'));
|
|
return Object.entries(data).map(([key, s]) => ({
|
|
key,
|
|
label: s.label || resolveName(key),
|
|
model: s.modelOverride || s.model || '-',
|
|
totalTokens: s.totalTokens || 0,
|
|
contextTokens: s.contextTokens || 0,
|
|
kind: s.kind || (key.includes('group') ? 'group' : 'direct'),
|
|
updatedAt: s.updatedAt || 0,
|
|
createdAt: s.createdAt || s.updatedAt || 0,
|
|
aborted: s.abortedLastRun || false,
|
|
thinkingLevel: s.thinkingLevel || null,
|
|
channel: s.channel || '-',
|
|
sessionId: s.sessionId || '-',
|
|
lastMessage: getLastMessage(s.sessionId || key),
|
|
cost: getSessionCost(s.sessionId || key)
|
|
}));
|
|
} catch (e) { return []; }
|
|
}
|
|
|
|
function getCostData() {
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
const perModel = {};
|
|
const perDay = {};
|
|
const perSession = {};
|
|
let total = 0;
|
|
|
|
for (const file of files) {
|
|
const sid = extractSessionId(file);
|
|
let scost = 0;
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
const msg = d.message;
|
|
if (!msg || !msg.usage) continue;
|
|
const c = estimateMsgCost(msg);
|
|
if (c <= 0) continue;
|
|
const provider = normalizeProvider(msg.provider);
|
|
const model = normalizeModel(provider, msg.model);
|
|
if (model.includes('delivery-mirror')) continue;
|
|
const ts = d.timestamp || '';
|
|
const day = ts.substring(0, 10);
|
|
const modelKey = `${provider}/${model}`;
|
|
perModel[modelKey] = (perModel[modelKey] || 0) + c;
|
|
perDay[day] = (perDay[day] || 0) + c;
|
|
scost += c;
|
|
total += c;
|
|
} catch {}
|
|
}
|
|
if (scost > 0) perSession[sid] = scost;
|
|
}
|
|
|
|
const now = new Date();
|
|
const todayKey = now.toISOString().substring(0, 10);
|
|
const weekAgo = new Date(now - 7 * 86400000).toISOString().substring(0, 10);
|
|
let weekCost = 0;
|
|
for (const [d, c] of Object.entries(perDay)) {
|
|
if (d >= weekAgo) weekCost += c;
|
|
}
|
|
|
|
return {
|
|
total: Math.round(total * 100) / 100,
|
|
today: Math.round((perDay[todayKey] || 0) * 100) / 100,
|
|
week: Math.round(weekCost * 100) / 100,
|
|
perModel,
|
|
perDay: Object.fromEntries(Object.entries(perDay).sort((a, b) => b[0].localeCompare(a[0])).slice(0, 14)),
|
|
perSession: (() => {
|
|
let sidLabels = {};
|
|
try {
|
|
const sData = JSON.parse(fs.readFileSync(path.join(sessDir, 'sessions.json'), 'utf8'));
|
|
for (const [key, val] of Object.entries(sData)) {
|
|
if (val.sessionId) sidLabels[val.sessionId] = val.label || key.split(':').slice(2).join(':');
|
|
}
|
|
} catch {}
|
|
return Object.fromEntries(
|
|
Object.entries(perSession).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([sid, cost]) => {
|
|
let label = sidLabels[sid] || null;
|
|
if (!label) {
|
|
try {
|
|
const jf = path.join(sessDir, sid + '.jsonl');
|
|
if (!fs.existsSync(jf)) {
|
|
const del = fs.readdirSync(sessDir).find(f => f.startsWith(sid) && f.includes('.deleted'));
|
|
if (del) { }
|
|
}
|
|
if (fs.existsSync(jf)) {
|
|
const lines = fs.readFileSync(jf, 'utf8').split('\n');
|
|
for (const l of lines) {
|
|
if (!l.includes('"user"')) continue;
|
|
try {
|
|
const d = JSON.parse(l);
|
|
const c = d.message?.content;
|
|
const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.find(x => x.type === 'text')?.text || '' : '';
|
|
if (txt) {
|
|
let t = txt.replace(/\n/g, ' ').trim();
|
|
const bgMatch = t.match(/background task "([^"]+)"/i);
|
|
if (bgMatch) t = 'Sub: ' + bgMatch[1];
|
|
const cronMatch = t.match(/\[cron:([^\]]+)\]/);
|
|
if (cronMatch) {
|
|
let cronName = cronMatch[1].substring(0, 8);
|
|
try {
|
|
const cj = JSON.parse(fs.readFileSync(cronFile, 'utf8'));
|
|
const job = cj.jobs?.find(j => j.id?.startsWith(cronMatch[1].substring(0, 8)));
|
|
if (job?.name) cronName = job.name;
|
|
} catch {}
|
|
t = 'Cron: ' + cronName;
|
|
}
|
|
if (t.startsWith('System:')) t = t.substring(7).trim();
|
|
t = t.replace(/^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, '');
|
|
if (t.startsWith('You are running a boot')) t = 'Boot check';
|
|
if (t.match(/whatsapp/i)) t = 'WhatsApp session';
|
|
const subMatch2 = t.match(/background task "([^"]+)"/i);
|
|
if (!bgMatch && subMatch2) t = 'Sub: ' + subMatch2[1];
|
|
label = t.substring(0, 35); if (t.length > 35) label += '…';
|
|
break;
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
return [sid, { cost, label: label || ('session-' + sid.substring(0, 8)) }];
|
|
})
|
|
);
|
|
})()
|
|
};
|
|
} catch (e) { return { total: 0, today: 0, week: 0, perModel: {}, perDay: {}, perSession: {} }; }
|
|
}
|
|
|
|
let costCache = null;
|
|
let costCacheTime = 0;
|
|
|
|
function getUsageWindows() {
|
|
try {
|
|
const now = Date.now();
|
|
const fiveHoursMs = 5 * 3600000;
|
|
const oneWeekMs = 7 * 86400000;
|
|
const files = fs.readdirSync(sessDir).filter(f => {
|
|
if (!f.endsWith('.jsonl')) return false;
|
|
try { return fs.statSync(path.join(sessDir, f)).mtimeMs > now - oneWeekMs; } catch { return false; }
|
|
});
|
|
|
|
const perModel5h = {};
|
|
const perModelWeek = {};
|
|
const recentMessages = [];
|
|
|
|
for (const file of files) {
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
const msg = d.message;
|
|
if (!msg || !msg.usage) continue;
|
|
const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
|
|
if (!ts) continue;
|
|
const provider = normalizeProvider(msg.provider);
|
|
const model = normalizeModel(provider, msg.model);
|
|
const modelKey = `${provider}/${model}`;
|
|
const inTok = Math.max(0, toNum(msg.usage.input));
|
|
const outTok = Math.max(0, toNum(msg.usage.output));
|
|
const cacheReadTok = Math.max(0, toNum(msg.usage.cacheRead));
|
|
const cacheWriteTok = Math.max(0, toNum(msg.usage.cacheWrite));
|
|
const cost = estimateMsgCost(msg);
|
|
|
|
if (now - ts < fiveHoursMs) {
|
|
if (!perModel5h[modelKey]) perModel5h[modelKey] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, calls: 0 };
|
|
perModel5h[modelKey].input += inTok;
|
|
perModel5h[modelKey].output += outTok;
|
|
perModel5h[modelKey].cacheRead += cacheReadTok;
|
|
perModel5h[modelKey].cacheWrite += cacheWriteTok;
|
|
perModel5h[modelKey].cost += cost;
|
|
perModel5h[modelKey].calls++;
|
|
}
|
|
if (now - ts < oneWeekMs) {
|
|
if (!perModelWeek[modelKey]) perModelWeek[modelKey] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, calls: 0 };
|
|
perModelWeek[modelKey].input += inTok;
|
|
perModelWeek[modelKey].output += outTok;
|
|
perModelWeek[modelKey].cacheRead += cacheReadTok;
|
|
perModelWeek[modelKey].cacheWrite += cacheWriteTok;
|
|
perModelWeek[modelKey].cost += cost;
|
|
perModelWeek[modelKey].calls++;
|
|
}
|
|
if (now - ts < fiveHoursMs) {
|
|
recentMessages.push({ ts, model: modelKey, input: inTok, output: outTok, cacheRead: cacheReadTok, cacheWrite: cacheWriteTok, cost });
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
recentMessages.sort((a, b) => b.ts - a.ts);
|
|
|
|
const estimatedLimits = { opus: 88000, sonnet: 220000 };
|
|
|
|
let windowStart = null;
|
|
if (recentMessages.length > 0) {
|
|
windowStart = recentMessages[recentMessages.length - 1].ts;
|
|
}
|
|
const windowResetIn = windowStart ? Math.max(0, (windowStart + fiveHoursMs) - now) : 0;
|
|
|
|
const thirtyMinAgo = now - 30 * 60000;
|
|
const recent30 = recentMessages.filter(m => m.ts >= thirtyMinAgo);
|
|
let burnTokensPerMin = 0;
|
|
let burnCostPerMin = 0;
|
|
if (recent30.length > 0) {
|
|
const totalOut30 = recent30.reduce((s, m) => s + m.output, 0);
|
|
const totalCost30 = recent30.reduce((s, m) => s + m.cost, 0);
|
|
const spanMs = Math.max(now - Math.min(...recent30.map(m => m.ts)), 60000);
|
|
burnTokensPerMin = totalOut30 / (spanMs / 60000);
|
|
burnCostPerMin = totalCost30 / (spanMs / 60000);
|
|
}
|
|
|
|
const opusKey = Object.keys(perModel5h).find(k => k.includes('opus')) || '';
|
|
const opusOut = opusKey ? perModel5h[opusKey].output : 0;
|
|
const sonnetKey = Object.keys(perModel5h).find(k => k.includes('sonnet')) || '';
|
|
const sonnetOut = sonnetKey ? perModel5h[sonnetKey].output : 0;
|
|
|
|
const opusRemaining = estimatedLimits.opus - opusOut;
|
|
const timeToLimit = burnTokensPerMin > 0 ? (opusRemaining / burnTokensPerMin) * 60000 : null;
|
|
|
|
const perModelCost5h = {};
|
|
for (const [model, data] of Object.entries(perModel5h)) {
|
|
const slash = model.indexOf('/');
|
|
const provider = slash >= 0 ? model.slice(0, slash) : 'unknown';
|
|
const modelName = slash >= 0 ? model.slice(slash + 1) : model;
|
|
const rates = MODEL_PRICING[`${provider}/${modelName}`] || {};
|
|
const inputCost = (data.input || 0) / 1000000 * toNum(rates.input);
|
|
const outputCost = (data.output || 0) / 1000000 * toNum(rates.output);
|
|
const cacheReadCost = (data.cacheRead || 0) / 1000000 * toNum(rates.cacheRead);
|
|
const cacheWriteCost = (data.cacheWrite || 0) / 1000000 * toNum(rates.cacheWrite);
|
|
perModelCost5h[model] = {
|
|
inputCost,
|
|
outputCost,
|
|
cacheReadCost,
|
|
cacheWriteCost,
|
|
totalCost: data.cost || (inputCost + outputCost + cacheReadCost + cacheWriteCost)
|
|
};
|
|
}
|
|
|
|
const totalCost5h = Object.values(perModel5h).reduce((s, m) => s + (m.cost || 0), 0);
|
|
const totalCalls5h = Object.values(perModel5h).reduce((s, m) => s + (m.calls || 0), 0);
|
|
const costLimit = 35.0;
|
|
const messageLimit = 1000;
|
|
|
|
return {
|
|
fiveHour: {
|
|
perModel: perModel5h,
|
|
perModelCost: perModelCost5h,
|
|
windowStart,
|
|
windowResetIn,
|
|
recentCalls: recentMessages.slice(0, 20).map(m => ({
|
|
...m,
|
|
ago: Math.round((now - m.ts) / 60000) + 'm ago'
|
|
}))
|
|
},
|
|
weekly: {
|
|
perModel: perModelWeek
|
|
},
|
|
burnRate: { tokensPerMinute: Math.round(burnTokensPerMin * 100) / 100, costPerMinute: Math.round(burnCostPerMin * 10000) / 10000 },
|
|
estimatedLimits,
|
|
current: {
|
|
opusOutput: opusOut,
|
|
sonnetOutput: sonnetOut,
|
|
totalCost: Math.round(totalCost5h * 100) / 100,
|
|
totalCalls: totalCalls5h,
|
|
opusPct: Math.round((opusOut / estimatedLimits.opus) * 100),
|
|
sonnetPct: Math.round((sonnetOut / estimatedLimits.sonnet) * 100),
|
|
costPct: Math.round((totalCost5h / costLimit) * 100),
|
|
messagePct: Math.round((totalCalls5h / messageLimit) * 100),
|
|
costLimit,
|
|
messageLimit
|
|
},
|
|
predictions: { timeToLimit: timeToLimit ? Math.round(timeToLimit) : null, safe: !timeToLimit || timeToLimit > 3600000 }
|
|
};
|
|
} catch (e) {
|
|
return { fiveHour: { perModel: {} }, weekly: { perModel: {} } };
|
|
}
|
|
}
|
|
|
|
function getRateLimitEvents() {
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
const events = [];
|
|
const now = Date.now();
|
|
const fiveHoursMs = 5 * 3600000;
|
|
|
|
for (const file of files) {
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
|
|
if (now - ts > fiveHoursMs) continue;
|
|
if (d.type === 'error' || (d.message && d.message.stopReason === 'rate_limit')) {
|
|
const text = JSON.stringify(d);
|
|
if (text.includes('rate') || text.includes('overloaded') || text.includes('429') || text.includes('limit')) {
|
|
events.push({ ts, type: 'rate_limit', detail: text.substring(0, 200) });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
return events;
|
|
} catch { return []; }
|
|
}
|
|
|
|
let usageCache = null;
|
|
let usageCacheTime = 0;
|
|
|
|
function getMemoryStats() {
|
|
const totalMem = os.totalmem();
|
|
if (process.platform !== 'darwin') {
|
|
const freeMem = os.freemem();
|
|
const usedMem = totalMem - freeMem;
|
|
return { total: totalMem, used: usedMem, free: freeMem, percent: Math.round((usedMem / totalMem) * 100) };
|
|
}
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const out = execSync('vm_stat', { encoding: 'utf8', timeout: 2000 });
|
|
let pageSize = 4096;
|
|
const pageSizeMatch = out.match(/page size of (\d+) bytes/);
|
|
if (pageSizeMatch) pageSize = parseInt(pageSizeMatch[1], 10);
|
|
const num = (name) => {
|
|
const m = out.match(new RegExp(name + ':\\s*(\\d+)'));
|
|
return m ? parseInt(m[1], 10) * pageSize : 0;
|
|
};
|
|
const free = num('Pages free');
|
|
const active = num('Pages active');
|
|
const inactive = num('Pages inactive');
|
|
const wired = num('Pages wired');
|
|
const compressed = num('Pages occupied by compressor');
|
|
const usedMem = active + wired + (compressed || 0);
|
|
const availMem = free + inactive;
|
|
const usedDisplay = Math.min(usedMem, totalMem - free);
|
|
const memPercent = totalMem > 0 ? Math.min(100, Math.round((usedDisplay / totalMem) * 100)) : 0;
|
|
return {
|
|
total: totalMem,
|
|
used: usedDisplay,
|
|
free: free,
|
|
percent: memPercent
|
|
};
|
|
} catch (e) {
|
|
const freeMem = os.freemem();
|
|
const usedMem = totalMem - freeMem;
|
|
return { total: totalMem, used: usedMem, free: freeMem, percent: Math.round((usedMem / totalMem) * 100) };
|
|
}
|
|
}
|
|
|
|
function getSystemStats() {
|
|
try {
|
|
const mem = getMemoryStats();
|
|
const totalMem = mem.total;
|
|
const usedMem = mem.used;
|
|
const freeMem = mem.free;
|
|
const memPercent = mem.percent;
|
|
|
|
let cpuTemp = null;
|
|
if (process.platform === 'linux') {
|
|
try {
|
|
const tempRaw = fs.readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8').trim();
|
|
cpuTemp = parseInt(tempRaw, 10) / 1000;
|
|
} catch {}
|
|
} else if (process.platform === 'darwin') {
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const out = execSync('osx-cpu-temp 2>/dev/null || true', { encoding: 'utf8', timeout: 2000 }).trim();
|
|
const match = out.match(/(\d+(?:\.\d+)?)/);
|
|
if (match) cpuTemp = parseFloat(match[1]);
|
|
} catch {}
|
|
}
|
|
|
|
const loadAvg = os.loadavg();
|
|
const uptime = os.uptime();
|
|
|
|
let cpuUsage = 0;
|
|
try {
|
|
const loadAvg1m = os.loadavg()[0];
|
|
const numCpus = os.cpus().length;
|
|
cpuUsage = Math.min(Math.round((loadAvg1m / numCpus) * 100), 100);
|
|
} catch {
|
|
cpuUsage = 0;
|
|
}
|
|
|
|
let diskPercent = 0, diskUsed = '', diskTotal = '';
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
if (process.platform === 'darwin') {
|
|
const df = execSync("df -g / | tail -1", { encoding: 'utf8' }).trim();
|
|
const parts = df.split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 5) {
|
|
const totalGB = parseInt(parts[1], 10) || 0;
|
|
const usedGB = parseInt(parts[2], 10) || 0;
|
|
const pctStr = parts[4].replace('%', '');
|
|
diskPercent = parseInt(pctStr, 10) || 0;
|
|
diskUsed = usedGB + 'G';
|
|
diskTotal = totalGB + 'G';
|
|
}
|
|
} else {
|
|
const df = execSync("df / --output=pcent,used,size -B1G | tail -1", { encoding: 'utf8' }).trim();
|
|
const parts = df.split(/\s+/);
|
|
diskPercent = parseInt(parts[0], 10) || 0;
|
|
diskUsed = (parts[1] || '') + 'G';
|
|
diskTotal = (parts[2] || '') + 'G';
|
|
}
|
|
} catch {}
|
|
|
|
let crashCount = 0;
|
|
let crashesToday = 0;
|
|
if (process.platform === 'linux') {
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
// Try system scope first, then user scope
|
|
let logs = '';
|
|
try {
|
|
logs = execSync("journalctl -u openclaw --since '7 days ago' --no-pager -o short 2>/dev/null | grep -ci 'SIGABRT\\|SIGSEGV\\|exit code [1-9]\\|process crashed\\|fatal error' || echo 0", { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
// If system fails, try user scope
|
|
logs = execSync("journalctl --user -u openclaw --since '7 days ago' --no-pager -o short 2>/dev/null | grep -ci 'SIGABRT\\|SIGSEGV\\|exit code [1-9]\\|process crashed\\|fatal error' || echo 0", { encoding: 'utf8' }).trim();
|
|
}
|
|
crashCount = parseInt(logs, 10) || 0;
|
|
} catch {}
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
// Try system scope first, then user scope
|
|
let logs = '';
|
|
try {
|
|
logs = execSync("journalctl -u openclaw --since today --no-pager -o short 2>/dev/null | grep -ci 'SIGABRT\\|SIGSEGV\\|exit code [1-9]\\|process crashed\\|fatal error' || echo 0", { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
// If system fails, try user scope
|
|
logs = execSync("journalctl --user -u openclaw --since today --no-pager -o short 2>/dev/null | grep -ci 'SIGABRT\\|SIGSEGV\\|exit code [1-9]\\|process crashed\\|fatal error' || echo 0", { encoding: 'utf8' }).trim();
|
|
}
|
|
crashesToday = parseInt(logs, 10) || 0;
|
|
} catch {}
|
|
}
|
|
|
|
return {
|
|
cpu: { usage: cpuUsage, temp: cpuTemp },
|
|
disk: { percent: diskPercent, used: diskUsed, total: diskTotal },
|
|
crashCount,
|
|
crashesToday,
|
|
memory: {
|
|
total: totalMem,
|
|
used: usedMem,
|
|
free: freeMem,
|
|
percent: memPercent,
|
|
totalGB: (totalMem / 1073741824).toFixed(1),
|
|
usedGB: (usedMem / 1073741824).toFixed(1),
|
|
freeGB: (freeMem / 1073741824).toFixed(1)
|
|
},
|
|
loadAvg: { '1m': loadAvg[0].toFixed(2), '5m': loadAvg[1].toFixed(2), '15m': loadAvg[2].toFixed(2) },
|
|
uptime: uptime
|
|
};
|
|
} catch (e) {
|
|
return { cpu: { usage: 0, temp: null }, memory: { total: 0, used: 0, free: 0, percent: 0 }, loadAvg: { '1m': 0, '5m': 0, '15m': 0 }, uptime: 0 };
|
|
}
|
|
}
|
|
|
|
let liveClients = [];
|
|
let liveWatcher = null;
|
|
const _fileWatchers = {};
|
|
const _fileSizes = {};
|
|
|
|
function watchSessionFile(file) {
|
|
const filePath = path.join(sessDir, file);
|
|
const sessionKey = file.replace('.jsonl', '');
|
|
if (_fileWatchers[file]) return;
|
|
try {
|
|
_fileSizes[file] = fs.statSync(filePath).size;
|
|
} catch { _fileSizes[file] = 0; }
|
|
|
|
try {
|
|
_fileWatchers[file] = fs.watch(filePath, (eventType) => {
|
|
if (eventType !== 'change') return;
|
|
try {
|
|
const stats = fs.statSync(filePath);
|
|
if (stats.size <= (_fileSizes[file] || 0)) return;
|
|
const fd = fs.openSync(filePath, 'r');
|
|
const buffer = Buffer.allocUnsafe(stats.size - (_fileSizes[file] || 0));
|
|
fs.readSync(fd, buffer, 0, buffer.length, _fileSizes[file] || 0);
|
|
fs.closeSync(fd);
|
|
_fileSizes[file] = stats.size;
|
|
buffer.toString('utf8').split('\n').filter(l => l.trim()).forEach(line => {
|
|
try { const data = JSON.parse(line); data._sessionKey = sessionKey; broadcastLiveEvent(data); } catch {}
|
|
});
|
|
} catch {}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
function startLiveWatcher() {
|
|
if (liveWatcher) return;
|
|
try {
|
|
fs.readdirSync(sessDir).filter(f => isSessionFile(f)).forEach(watchSessionFile);
|
|
liveWatcher = fs.watch(sessDir, (eventType, filename) => {
|
|
if (filename && isSessionFile(filename) && !_fileWatchers[filename]) {
|
|
try { if (fs.existsSync(path.join(sessDir, filename))) watchSessionFile(filename); } catch {}
|
|
}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
function broadcastLiveEvent(data) {
|
|
if (liveClients.length === 0) return;
|
|
|
|
const event = formatLiveEvent(data);
|
|
if (!event) return;
|
|
|
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
|
liveClients.forEach(res => {
|
|
try {
|
|
res.write(message);
|
|
} catch {}
|
|
});
|
|
}
|
|
|
|
function formatLiveEvent(data) {
|
|
const timestamp = data.timestamp || new Date().toISOString();
|
|
const sessionKey = data._sessionKey || data.sessionId || 'unknown';
|
|
|
|
const sessions = getSessionsJson();
|
|
const session = sessions.find(s => s.sessionId === sessionKey || s.key.includes(sessionKey));
|
|
const label = session ? session.label : sessionKey.substring(0, 8);
|
|
|
|
if (data.type === 'message') {
|
|
const msg = data.message;
|
|
if (!msg) return null;
|
|
|
|
const role = msg.role || 'unknown';
|
|
let content = '';
|
|
|
|
if (Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
content = block.text.substring(0, 150);
|
|
break;
|
|
} else if (block.type === 'toolCall' || block.type === 'tool_use') {
|
|
content = `🔧 ${block.name || block.toolName || 'tool'}(${(JSON.stringify(block.arguments || block.input || {})).substring(0, 80)})`;
|
|
break;
|
|
} else if (block.type === 'toolResult' || block.type === 'tool_result') {
|
|
const rc = typeof block.content === 'string' ? block.content : JSON.stringify(block.content || '');
|
|
content = `📋 Result: ${rc.substring(0, 100)}`;
|
|
break;
|
|
} else if (block.type === 'thinking') {
|
|
content = `💭 ${(block.thinking || '').substring(0, 100)}`;
|
|
break;
|
|
}
|
|
}
|
|
if (!content && msg.content[0]) {
|
|
content = JSON.stringify(msg.content[0]).substring(0, 100);
|
|
}
|
|
} else if (typeof msg.content === 'string') {
|
|
content = msg.content.substring(0, 150);
|
|
}
|
|
|
|
if (!content && msg.type === 'tool_result') {
|
|
const rc = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || '');
|
|
content = `📋 ${rc.substring(0, 100)}`;
|
|
}
|
|
|
|
if (!content) return null;
|
|
|
|
return {
|
|
timestamp,
|
|
session: label,
|
|
role,
|
|
content: content.replace(/\n/g, ' ').trim()
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getCronJobs() {
|
|
try {
|
|
if (!fs.existsSync(cronFile)) return [];
|
|
const data = JSON.parse(fs.readFileSync(cronFile, 'utf8'));
|
|
return (data.jobs || []).map(j => {
|
|
let humanSchedule = j.schedule?.expr || '';
|
|
try {
|
|
const parts = humanSchedule.split(' ');
|
|
if (parts.length === 5) {
|
|
const [min, hour, dom, mon, dow] = parts;
|
|
const dowNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
|
let readable = '';
|
|
if (dow !== '*') readable = dowNames[parseInt(dow)] || dow;
|
|
if (hour !== '*' && min !== '*') readable += (readable ? ' ' : '') + `${hour.padStart(2,'0')}:${min.padStart(2,'0')}`;
|
|
if (j.schedule?.tz) readable += ` (${j.schedule.tz.split('/').pop()})`;
|
|
if (readable) humanSchedule = readable;
|
|
}
|
|
} catch {}
|
|
return {
|
|
id: j.id,
|
|
name: j.name || j.id.substring(0, 8),
|
|
schedule: humanSchedule,
|
|
enabled: j.enabled !== false,
|
|
lastStatus: j.state?.lastStatus || 'unknown',
|
|
lastRunAt: j.state?.lastRunAtMs || 0,
|
|
nextRunAt: j.state?.nextRunAtMs || 0,
|
|
lastDuration: j.state?.lastDurationMs || 0
|
|
};
|
|
});
|
|
} catch { return []; }
|
|
}
|
|
|
|
function getGitActivity() {
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const repos = getGitRepos();
|
|
const commits = [];
|
|
for (const repo of repos) {
|
|
try {
|
|
if (!fs.existsSync(path.join(repo.path, '.git'))) continue;
|
|
const log = execSync(`git -C ${repo.path} log --oneline --since='7 days ago' -10 --format='%H|%s|%at'`, { encoding: 'utf8', timeout: 5000 }).trim();
|
|
if (!log) continue;
|
|
log.split('\n').forEach(line => {
|
|
const [hash, msg, ts] = line.split('|');
|
|
commits.push({ repo: repo.name, hash: (hash || '').substring(0, 7), message: msg || '', timestamp: parseInt(ts || '0') * 1000 });
|
|
});
|
|
} catch {}
|
|
}
|
|
commits.sort((a, b) => b.timestamp - a.timestamp);
|
|
return commits.slice(0, 15);
|
|
} catch { return []; }
|
|
}
|
|
|
|
function getServicesStatus() {
|
|
const { execSync } = require('child_process');
|
|
const services = ['openclaw', 'agent-dashboard', 'tailscaled'];
|
|
|
|
const safePattern = (s) => /^[\w.\-\\/\[\]:space()^$|+]+$/.test(s);
|
|
const hasProcess = (pattern) => {
|
|
if (!safePattern(pattern)) return false;
|
|
try {
|
|
execSync(`pgrep -fa -- '${pattern}'`, { stdio: 'ignore', timeout: 3000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const safeName = (s) => /^[\w.-]+$/.test(s);
|
|
const isSystemdServiceActive = (name) => {
|
|
if (!safeName(name)) return false;
|
|
for (const cmd of [
|
|
`systemctl is-active --quiet ${name}`,
|
|
`systemctl --user is-active --quiet ${name}`
|
|
]) {
|
|
try {
|
|
execSync(cmd, { stdio: 'ignore', timeout: 3000 });
|
|
return true;
|
|
} catch {}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (os.platform() === 'linux') {
|
|
const serviceDetectors = {
|
|
openclaw: {
|
|
systemd: ['openclaw', 'openclaw-gateway', 'openclaw-webhooks'],
|
|
processes: [
|
|
'(^|[[:space:]])openclaw([[:space:]]|$)',
|
|
'openclaw-gateway',
|
|
'openclaw-webhooks'
|
|
]
|
|
},
|
|
'agent-dashboard': {
|
|
systemd: ['agent-dashboard'],
|
|
processes: ['agent-dashboard.*server\\.js', 'node.*server\\.js.*DASHBOARD']
|
|
},
|
|
tailscaled: {
|
|
systemd: ['tailscaled'],
|
|
processes: ['(^|[[:space:]])tailscaled([[:space:]]|$)']
|
|
}
|
|
};
|
|
|
|
return services.map(name => {
|
|
const detector = serviceDetectors[name];
|
|
if (!detector) return { name, active: false };
|
|
|
|
const activeBySystemd = detector.systemd.some(isSystemdServiceActive);
|
|
if (activeBySystemd) return { name, active: true };
|
|
|
|
const activeByProcess = detector.processes.some(hasProcess);
|
|
return { name, active: activeByProcess };
|
|
});
|
|
}
|
|
|
|
if (os.platform() === 'darwin') {
|
|
const gatewayUrl = process.env.GATEWAY_DASHBOARD_URL || 'http://localhost:18789';
|
|
let agentDashboardActive = false;
|
|
try {
|
|
const code = execSync(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 --max-time 3 "${gatewayUrl}" 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim();
|
|
agentDashboardActive = code.length >= 1 && (code[0] === '2' || code[0] === '3');
|
|
} catch { }
|
|
|
|
let tailscaledActive = false;
|
|
const tailscalePaths = ['/Applications/Tailscale.app/Contents/MacOS/Tailscale', 'tailscale'];
|
|
for (const t of tailscalePaths) {
|
|
try {
|
|
execSync(`${t} status 2>/dev/null`, { encoding: 'utf8', timeout: 3000 });
|
|
tailscaledActive = true;
|
|
break;
|
|
} catch { }
|
|
}
|
|
|
|
let listOut = '';
|
|
try {
|
|
listOut = execSync('launchctl list 2>/dev/null', { encoding: 'utf8', timeout: 3000 });
|
|
} catch { listOut = ''; }
|
|
const runningLabels = new Set();
|
|
for (const line of listOut.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const cols = trimmed.split(/\s+/);
|
|
const pid = cols[0];
|
|
const label = cols.length >= 3 ? cols[cols.length - 1] : '';
|
|
if (pid !== '-' && pid !== '0' && label) runningLabels.add(label.toLowerCase());
|
|
}
|
|
const openclawActive = Array.from(runningLabels).some(label =>
|
|
label === 'openclaw' || label.includes('openclaw')
|
|
);
|
|
|
|
return services.map(name => {
|
|
if (name === 'agent-dashboard') return { name, active: agentDashboardActive };
|
|
if (name === 'tailscaled') return { name, active: tailscaledActive };
|
|
return { name, active: openclawActive };
|
|
});
|
|
}
|
|
|
|
return services.map(name => ({ name, active: null }));
|
|
}
|
|
|
|
function getMemoryFiles() {
|
|
const files = [];
|
|
try {
|
|
if (fs.existsSync(memoryMdPath)) {
|
|
const stat = fs.statSync(memoryMdPath);
|
|
files.push({ name: 'MEMORY.md', modified: stat.mtimeMs, size: stat.size });
|
|
}
|
|
} catch {}
|
|
try {
|
|
if (fs.existsSync(heartbeatPath)) {
|
|
const stat = fs.statSync(heartbeatPath);
|
|
files.push({ name: 'HEARTBEAT.md', modified: stat.mtimeMs, size: stat.size });
|
|
}
|
|
} catch {}
|
|
try {
|
|
if (fs.existsSync(memoryDir)) {
|
|
const entries = fs.readdirSync(memoryDir).filter(f => f.endsWith('.md')).sort().reverse();
|
|
entries.forEach(e => {
|
|
try {
|
|
const stat = fs.statSync(path.join(memoryDir, e));
|
|
files.push({ name: 'memory/' + e, modified: stat.mtimeMs, size: stat.size });
|
|
} catch {}
|
|
});
|
|
}
|
|
} catch {}
|
|
return files;
|
|
}
|
|
|
|
function getKeyFiles() {
|
|
const files = [];
|
|
for (const fname of workspaceFilenames) {
|
|
const fpath = path.join(WORKSPACE_DIR, fname);
|
|
try {
|
|
if (fs.existsSync(fpath)) {
|
|
const stat = fs.statSync(fpath);
|
|
files.push({ name: fname, modified: stat.mtimeMs, size: stat.size, editable: true });
|
|
}
|
|
} catch {}
|
|
}
|
|
try {
|
|
if (fs.existsSync(skillsDir)) {
|
|
const entries = fs.readdirSync(skillsDir).sort();
|
|
for (const e of entries) {
|
|
const entryPath = path.join(skillsDir, e);
|
|
try {
|
|
const stat = fs.statSync(entryPath);
|
|
if (stat.isDirectory()) {
|
|
const skillMd = path.join(entryPath, 'SKILL.md');
|
|
if (fs.existsSync(skillMd)) {
|
|
const fstat = fs.statSync(skillMd);
|
|
files.push({ name: 'skills/' + e + '/SKILL.md', modified: fstat.mtimeMs, size: fstat.size, editable: true });
|
|
}
|
|
} else if (e.endsWith('.md')) {
|
|
files.push({ name: 'skills/' + e, modified: stat.mtimeMs, size: stat.size, editable: true });
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
for (const cf of configFiles) {
|
|
try {
|
|
if (fs.existsSync(cf.path)) {
|
|
const stat = fs.statSync(cf.path);
|
|
files.push({ name: cf.name, modified: stat.mtimeMs, size: stat.size, editable: !READ_ONLY_FILES.has(cf.name) });
|
|
}
|
|
} catch {}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function buildKeyFilesAllowed() {
|
|
const map = {};
|
|
for (const fname of workspaceFilenames) {
|
|
const fpath = path.join(WORKSPACE_DIR, fname);
|
|
if (fs.existsSync(fpath)) map[fname] = fpath;
|
|
}
|
|
try {
|
|
if (fs.existsSync(skillsDir)) {
|
|
for (const e of fs.readdirSync(skillsDir).sort()) {
|
|
const ep = path.join(skillsDir, e);
|
|
const stat = fs.statSync(ep);
|
|
if (stat.isDirectory()) {
|
|
const sm = path.join(ep, 'SKILL.md');
|
|
if (fs.existsSync(sm)) map['skills/' + e + '/SKILL.md'] = sm;
|
|
} else if (e.endsWith('.md')) {
|
|
map['skills/' + e] = ep;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
for (const cf of configFiles) {
|
|
if (fs.existsSync(cf.path)) map[cf.name] = cf.path;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getTodayTokens() {
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
const now = new Date();
|
|
const todayStr = now.toISOString().substring(0, 10);
|
|
const perModel = {};
|
|
let totalInput = 0, totalOutput = 0;
|
|
|
|
for (const file of files) {
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
const ts = d.timestamp || '';
|
|
if (!ts.startsWith(todayStr)) continue;
|
|
const msg = d.message;
|
|
if (!msg || !msg.usage) continue;
|
|
const model = (msg.model || 'unknown').split('/').pop();
|
|
if (model === 'delivery-mirror') continue;
|
|
const inTok = (msg.usage.input || 0) + (msg.usage.cacheRead || 0) + (msg.usage.cacheWrite || 0);
|
|
const outTok = msg.usage.output || 0;
|
|
if (!perModel[model]) perModel[model] = { input: 0, output: 0 };
|
|
perModel[model].input += inTok;
|
|
perModel[model].output += outTok;
|
|
totalInput += inTok;
|
|
totalOutput += outTok;
|
|
} catch {}
|
|
}
|
|
}
|
|
return { totalInput, totalOutput, perModel };
|
|
} catch { return { totalInput: 0, totalOutput: 0, perModel: {} }; }
|
|
}
|
|
|
|
function getAvgResponseTime() {
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
const now = new Date();
|
|
const todayStr = now.toISOString().substring(0, 10);
|
|
const diffs = [];
|
|
|
|
for (const file of files) {
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
let lastUserTs = null;
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
const ts = d.timestamp || '';
|
|
if (!ts.startsWith(todayStr)) continue;
|
|
const role = d.message?.role;
|
|
const msgTs = new Date(ts).getTime();
|
|
if (role === 'user') {
|
|
lastUserTs = msgTs;
|
|
} else if (role === 'assistant' && lastUserTs) {
|
|
const diff = msgTs - lastUserTs;
|
|
if (diff > 0 && diff < 600000) diffs.push(diff);
|
|
lastUserTs = null;
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
if (diffs.length === 0) return 0;
|
|
return Math.round(diffs.reduce((a, b) => a + b, 0) / diffs.length / 1000);
|
|
} catch { return 0; }
|
|
}
|
|
|
|
function trackDiskHistory(diskPercent) {
|
|
const histFile = path.join(__dirname, 'disk-history.json');
|
|
let history = [];
|
|
try { history = JSON.parse(fs.readFileSync(histFile, 'utf8')); } catch {}
|
|
const now = Date.now();
|
|
if (history.length > 0 && now - history[history.length - 1].t < 1800000) return history;
|
|
history.push({ t: now, v: diskPercent });
|
|
if (history.length > 48) history = history.slice(-48);
|
|
try { fs.writeFileSync(histFile, JSON.stringify(history)); } catch {}
|
|
return history;
|
|
}
|
|
|
|
let healthHistory = [];
|
|
try {
|
|
if (fs.existsSync(healthHistoryFile)) {
|
|
healthHistory = JSON.parse(fs.readFileSync(healthHistoryFile, 'utf8'));
|
|
}
|
|
} catch {}
|
|
|
|
function saveHealthSnapshot() {
|
|
try {
|
|
const stats = getSystemStats();
|
|
const now = Date.now();
|
|
healthHistory.push({
|
|
t: now,
|
|
cpu: stats.cpu?.usage || 0,
|
|
ram: stats.memory?.percent || 0,
|
|
temp: stats.cpu?.temp || 0,
|
|
disk: stats.disk?.percent || 0
|
|
});
|
|
if (healthHistory.length > 288) {
|
|
healthHistory = healthHistory.slice(-288);
|
|
}
|
|
const dir = path.dirname(healthHistoryFile);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(healthHistoryFile, JSON.stringify(healthHistory));
|
|
} catch (e) {
|
|
console.error('Health snapshot error:', e);
|
|
}
|
|
}
|
|
|
|
setInterval(saveHealthSnapshot, 5 * 60 * 1000);
|
|
saveHealthSnapshot();
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [token, sess] of sessions.entries()) {
|
|
if (now > sess.expiresAt) {
|
|
sessions.delete(token);
|
|
} else if (!sess.rememberMe && now - sess.lastActivity > SESSION_ACTIVITY_TIMEOUT) {
|
|
sessions.delete(token);
|
|
}
|
|
}
|
|
}, 60 * 1000);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (!httpsEnforcement(req, res)) return;
|
|
setSecurityHeaders(res);
|
|
// Prevent mobile Safari from serving stale dashboard/API responses.
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
const ip = getClientIP(req);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
setSameSiteCORS(req, res);
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/status') {
|
|
const creds = getCredentials();
|
|
const registered = !!creds;
|
|
const loggedIn = isAuthenticated(req);
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ registered, loggedIn }));
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/register' && req.method === 'POST') {
|
|
const creds = getCredentials();
|
|
if (creds) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Already registered' }));
|
|
return;
|
|
}
|
|
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 2048) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const ip = getClientIP(req);
|
|
const { username, password } = JSON.parse(body);
|
|
if (!username || !password) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Username and password required' }));
|
|
return;
|
|
}
|
|
|
|
const pwdError = validatePassword(password);
|
|
if (pwdError) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: pwdError }));
|
|
return;
|
|
}
|
|
|
|
const { hash, salt } = hashPassword(password);
|
|
const newCreds = { username, passwordHash: hash, salt, iterations: 100000 };
|
|
saveCredentials(newCreds);
|
|
|
|
const sessionToken = createSession(username, ip, false);
|
|
clearFailedAuth(ip);
|
|
auditLog('register', ip, { username });
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, sessionToken }));
|
|
} catch (e) {
|
|
console.error('Registration error:', e);
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/login' && req.method === 'POST') {
|
|
const limitCheck = checkRateLimit(ip);
|
|
if (limitCheck.softLocked) {
|
|
auditLog('login_locked', ip, { remainingSeconds: limitCheck.remainingSeconds, hardLocked: limitCheck.blocked });
|
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Too many failed login attempts', lockoutRemaining: limitCheck.remainingSeconds }));
|
|
return;
|
|
}
|
|
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 2048) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { username, password, totpCode, rememberMe } = JSON.parse(body);
|
|
const creds = getCredentials();
|
|
if (!creds) {
|
|
recordFailedAuth(ip);
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'No account registered' }));
|
|
return;
|
|
}
|
|
|
|
if (username !== creds.username) {
|
|
recordFailedAuth(ip);
|
|
auditLog('login_failed', ip, { username });
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid username or password' }));
|
|
return;
|
|
}
|
|
|
|
if (!verifyPassword(password, creds.passwordHash, creds.salt)) {
|
|
recordFailedAuth(ip);
|
|
auditLog('login_failed', ip, { username });
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid username or password' }));
|
|
return;
|
|
}
|
|
|
|
if (MFA_SECRET || creds.mfaSecret) {
|
|
const secret = creds.mfaSecret || MFA_SECRET;
|
|
if (!totpCode) {
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ requiresMfa: true }));
|
|
return;
|
|
}
|
|
|
|
if (!verifyTOTP(secret, totpCode)) {
|
|
recordFailedAuth(ip);
|
|
auditLog('login_mfa_failed', ip, { username });
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid TOTP code' }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const sessionToken = createSession(username, ip, rememberMe);
|
|
clearFailedAuth(ip);
|
|
auditLog('login_success', ip, { username });
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, sessionToken }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/logout' && req.method === 'POST') {
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const token = authHeader.substring(7);
|
|
sessions.delete(token);
|
|
}
|
|
auditLog('logout', ip);
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/reset-password' && req.method === 'POST') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 2048) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { recoveryToken, newPassword } = JSON.parse(body);
|
|
if (!safeCompare(recoveryToken, DASHBOARD_TOKEN)) {
|
|
recordFailedAuth(ip);
|
|
auditLog('password_reset_failed', ip);
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid recovery token' }));
|
|
return;
|
|
}
|
|
|
|
const pwdError = validatePassword(newPassword);
|
|
if (pwdError) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: pwdError }));
|
|
return;
|
|
}
|
|
|
|
const creds = getCredentials();
|
|
if (!creds) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'No account registered' }));
|
|
return;
|
|
}
|
|
|
|
const { hash, salt } = hashPassword(newPassword);
|
|
creds.passwordHash = hash;
|
|
creds.salt = salt;
|
|
saveCredentials(creds);
|
|
|
|
sessions.clear();
|
|
|
|
clearFailedAuth(ip);
|
|
auditLog('password_reset_success', ip);
|
|
setSameSiteCORS(req, res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/change-password' && req.method === 'POST') {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 2048) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { currentPassword, newPassword } = JSON.parse(body);
|
|
const creds = getCredentials();
|
|
if (!creds) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'No account registered' }));
|
|
return;
|
|
}
|
|
|
|
if (!verifyPassword(currentPassword, creds.passwordHash, creds.salt)) {
|
|
recordFailedAuth(ip);
|
|
auditLog('password_change_failed', ip);
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Current password is incorrect' }));
|
|
return;
|
|
}
|
|
|
|
const pwdError = validatePassword(newPassword);
|
|
if (pwdError) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: pwdError }));
|
|
return;
|
|
}
|
|
|
|
const { hash, salt } = hashPassword(newPassword);
|
|
creds.passwordHash = hash;
|
|
creds.salt = salt;
|
|
saveCredentials(creds);
|
|
|
|
const authHeader = req.headers.authorization;
|
|
const currentToken = authHeader ? authHeader.substring(7) : null;
|
|
for (const [token, sess] of sessions.entries()) {
|
|
if (token !== currentToken) sessions.delete(token);
|
|
}
|
|
|
|
clearFailedAuth(ip);
|
|
auditLog('password_change_success', ip);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/mfa-status') {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
const creds = getCredentials();
|
|
const enabled = !!(creds?.mfaSecret || MFA_SECRET);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ enabled }));
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/setup-mfa' && req.method === 'POST') {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
|
|
try {
|
|
const secret = base32Encode(crypto.randomBytes(20));
|
|
const otpauth_uri = `otpauth://totp/OpenClaw:Dashboard?secret=${secret}&issuer=OpenClaw&algorithm=SHA1&digits=6&period=30`;
|
|
pendingMfaSecrets.set(getClientIP(req), { secret, createdAt: Date.now() });
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ secret, otpauth_uri }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/confirm-mfa' && req.method === 'POST') {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { totpCode } = JSON.parse(body);
|
|
const ip = getClientIP(req);
|
|
const pending = pendingMfaSecrets.get(ip);
|
|
|
|
if (!pending || Date.now() - pending.createdAt > 10 * 60 * 1000) {
|
|
pendingMfaSecrets.delete(ip);
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'MFA setup expired. Please try again.' }));
|
|
return;
|
|
}
|
|
|
|
if (!totpCode || !verifyTOTP(pending.secret, totpCode)) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid TOTP code. Please try again.' }));
|
|
return;
|
|
}
|
|
|
|
const creds = getCredentials();
|
|
if (creds) {
|
|
creds.mfaSecret = pending.secret;
|
|
saveCredentials(creds);
|
|
}
|
|
pendingMfaSecrets.delete(ip);
|
|
auditLog('mfa_setup', ip);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/auth/disable-mfa' && req.method === 'POST') {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { totpCode } = JSON.parse(body);
|
|
|
|
const creds = getCredentials();
|
|
const mfaSecret = creds?.mfaSecret || MFA_SECRET;
|
|
|
|
if (!mfaSecret) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'MFA is not enabled' }));
|
|
return;
|
|
}
|
|
|
|
if (!totpCode || !verifyTOTP(mfaSecret, totpCode)) {
|
|
auditLog('mfa_disable_failed', getClientIP(req));
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid TOTP code' }));
|
|
return;
|
|
}
|
|
|
|
if (creds) {
|
|
delete creds.mfaSecret;
|
|
saveCredentials(creds);
|
|
}
|
|
|
|
auditLog('mfa_disabled', getClientIP(req));
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/' || req.url === '/index.html') {
|
|
try {
|
|
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(html);
|
|
} catch (e) {
|
|
res.writeHead(500);
|
|
res.end('Error loading dashboard');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url === '/api/health') {
|
|
setSecurityHeaders(res);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
|
|
return;
|
|
}
|
|
|
|
if (req.url.startsWith('/api/')) {
|
|
if (!requireAuth(req, res)) return;
|
|
setSameSiteCORS(req, res);
|
|
|
|
if (req.url === '/api/sessions') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getSessionsJson()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/usage') {
|
|
const now = Date.now();
|
|
if (!usageCache || now - usageCacheTime > 10000) {
|
|
usageCache = getUsageWindows();
|
|
usageCacheTime = now;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(usageCache));
|
|
return;
|
|
}
|
|
if (req.url === '/api/costs') {
|
|
const now = Date.now();
|
|
if (!costCache || now - costCacheTime > 60000) {
|
|
costCache = getCostData();
|
|
costCacheTime = now;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(costCache));
|
|
return;
|
|
}
|
|
if (req.url === '/api/system') {
|
|
const stats = getSystemStats();
|
|
if (stats.disk) stats.diskHistory = trackDiskHistory(stats.disk.percent || 0);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(stats));
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/session-messages?')) {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const rawId = params.get('id') || '';
|
|
const sessionId = rawId.replace(/[^a-zA-Z0-9\-_:.]/g, '');
|
|
const messages = [];
|
|
try {
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
let targetFile = files.find(f => f.includes(sessionId));
|
|
if (!targetFile) {
|
|
const sFile = path.join(sessDir, 'sessions.json');
|
|
const data = JSON.parse(fs.readFileSync(sFile, 'utf8'));
|
|
for (const [k, v] of Object.entries(data)) {
|
|
if (k === sessionId && v.sessionId) {
|
|
targetFile = files.find(f => f.includes(v.sessionId));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (targetFile) {
|
|
const lines = fs.readFileSync(path.join(sessDir, targetFile), 'utf8').split('\n').filter(l => l.trim());
|
|
for (let i = Math.max(0, lines.length - 30); i < lines.length; i++) {
|
|
try {
|
|
const d = JSON.parse(lines[i]);
|
|
if (d.type !== 'message') continue;
|
|
const msg = d.message;
|
|
if (!msg) continue;
|
|
let text = '';
|
|
if (typeof msg.content === 'string') text = msg.content;
|
|
else if (Array.isArray(msg.content)) {
|
|
for (const b of msg.content) {
|
|
if (b.type === 'text' && b.text) { text = b.text; break; }
|
|
if (b.type === 'tool_use' || b.type === 'toolCall') { text = '🔧 ' + (b.name || b.toolName || 'tool'); break; }
|
|
}
|
|
}
|
|
if (text) messages.push({ role: msg.role || 'unknown', content: text.substring(0, 300), timestamp: d.timestamp || '' });
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(messages));
|
|
return;
|
|
}
|
|
if (req.url === '/api/crons') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getCronJobs()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/git') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getGitActivity()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/services') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getServicesStatus()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/memory') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getMemoryFiles()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/tokens-today') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getTodayTokens()));
|
|
return;
|
|
}
|
|
if (req.url === '/api/config') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ name: 'OpenClaw Dashboard', version: '1.0.0' }));
|
|
return;
|
|
}
|
|
if (req.url === '/api/reauth' && req.method === 'POST') {
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const { password, totp } = JSON.parse(body);
|
|
const creds = getCredentials();
|
|
if (!creds) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No credentials configured' })); return; }
|
|
if (!verifyPassword(password, creds.passwordHash, creds.salt)) {
|
|
recordFailedAuth(ip);
|
|
auditLog('reauth_failed', ip, {});
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid password' }));
|
|
return;
|
|
}
|
|
const mfaSecret = creds.mfaSecret || MFA_SECRET;
|
|
if (mfaSecret) {
|
|
if (!totp) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'TOTP required', needsTotp: true })); return; }
|
|
if (!verifyTOTP(mfaSecret, totp)) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid TOTP code' })); return; }
|
|
}
|
|
auditLog('reauth_success', ip, {});
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
} catch(e) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/openclaw-config' && req.method === 'GET') {
|
|
if (!requireAuth(req, res)) return;
|
|
const configPath = process.env.OPENCLAW_CONFIG || path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json');
|
|
try {
|
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
JSON.parse(content);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ config: content, path: configPath }));
|
|
} catch(e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
auditLog('config_read', ip, {});
|
|
return;
|
|
}
|
|
if (req.url === '/api/openclaw-config' && req.method === 'PUT') {
|
|
if (!requireAuth(req, res)) return;
|
|
let body = '';
|
|
req.on('data', c => { body += c; if (body.length > 1048576) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { config } = JSON.parse(body);
|
|
JSON.parse(config);
|
|
const configPath = process.env.OPENCLAW_CONFIG || path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json');
|
|
const backupPath = configPath + '.bak.' + Date.now();
|
|
fs.copyFileSync(configPath, backupPath);
|
|
fs.writeFileSync(configPath, config, 'utf8');
|
|
auditLog('config_saved', ip, { backup: backupPath });
|
|
const { execSync } = require('child_process');
|
|
try { execSync('openclaw gateway restart', { timeout: 15000 }); } catch(e) {}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, backup: backupPath }));
|
|
} catch(e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid JSON: ' + e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/notifications')) {
|
|
if (!requireAuth(req, res)) return;
|
|
const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '50');
|
|
try {
|
|
const raw = fs.readFileSync(auditLogPath, 'utf8').trim();
|
|
const lines = raw.split('\n').filter(Boolean).slice(-Math.min(limit, 200));
|
|
const events = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ events }));
|
|
} catch(e) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ events: [] })); }
|
|
return;
|
|
}
|
|
if (req.url === '/api/docker') {
|
|
if (!requireAuth(req, res)) return;
|
|
const { execSync } = require('child_process');
|
|
const run = (cmd) => { try { return execSync(cmd, { timeout: 10000 }).toString(); } catch(e) { return e.stdout ? e.stdout.toString() : 'Error: ' + e.message; } };
|
|
try {
|
|
const containers = JSON.parse(run('docker ps -a --format "{{json .}}" | jq -s "."') || '[]');
|
|
const images = JSON.parse(run('docker images --format "{{json .}}" | jq -s "."') || '[]');
|
|
const system = run('docker system df 2>&1');
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ containers, images, system }));
|
|
} catch(e) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ containers: [], images: [], system: 'Docker not available: ' + e.message }));
|
|
}
|
|
auditLog('docker_view', ip, {});
|
|
return;
|
|
}
|
|
if (req.url === '/api/docker/action' && req.method === 'POST') {
|
|
if (!requireAuth(req, res)) return;
|
|
let body = '';
|
|
req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { action, id } = JSON.parse(body);
|
|
const { execSync } = require('child_process');
|
|
const allowed = { 'start': 1, 'stop': 1, 'restart': 1, 'prune-containers': 1, 'prune-images': 1 };
|
|
if (!allowed[action]) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return; }
|
|
let result;
|
|
if (action === 'prune-containers') { result = execSync('docker container prune -f', { timeout: 30000 }).toString(); }
|
|
else if (action === 'prune-images') { result = execSync('docker image prune -f', { timeout: 30000 }).toString(); }
|
|
else {
|
|
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid container ID' })); return; }
|
|
result = execSync('docker ' + action + ' ' + id, { timeout: 15000 }).toString();
|
|
}
|
|
auditLog('docker_action', ip, { action, id });
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, result }));
|
|
} catch(e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/sys-security') {
|
|
const { execSync } = require('child_process');
|
|
const run = (cmd) => { try { return execSync(cmd, { timeout: 10000 }).toString().replace(/</g, '<').replace(/>/g, '>'); } catch(e) { return e.stdout ? e.stdout.toString().replace(/</g, '<').replace(/>/g, '>') : 'Error: ' + e.message; } };
|
|
const data = {
|
|
ufw: run('ufw status verbose 2>&1'),
|
|
ports: run('ss -ltnp 2>&1'),
|
|
fail2ban: run('fail2ban-client status 2>&1 && echo "---" && fail2ban-client status sshd 2>&1'),
|
|
ssh: run('journalctl -u ssh --no-pager -n 50 --grep="Failed\\|Invalid\\|Accepted" 2>&1 || journalctl -u sshd --no-pager -n 50 --grep="Failed\\|Invalid\\|Accepted" 2>&1'),
|
|
audit: run('openclaw security audit 2>&1'),
|
|
};
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
auditLog('sys_security_view', ip, {});
|
|
return;
|
|
}
|
|
if (req.url === '/api/claude-usage-scrape' && req.method === 'POST') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
if (fs.existsSync(scrapeScript)) {
|
|
exec(`bash ${scrapeScript}`, { timeout: 60000 }, (err) => {});
|
|
res.end(JSON.stringify({ status: 'started' }));
|
|
} else {
|
|
res.end(JSON.stringify({ status: 'error', message: 'Scrape script not found' }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/claude-usage') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(claudeUsageFile, 'utf8'));
|
|
res.end(JSON.stringify(data));
|
|
} catch {
|
|
res.end(JSON.stringify({ error: 'No usage data. Run scrape-claude-usage.sh first.' }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/gemini-usage-scrape' && req.method === 'POST') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
if (fs.existsSync(geminiScrapeScript)) {
|
|
exec(`bash ${geminiScrapeScript}`, { timeout: 60000 }, (err) => {});
|
|
res.end(JSON.stringify({ status: 'started' }));
|
|
} else {
|
|
res.end(JSON.stringify({ status: 'error', message: 'Gemini scrape script not found' }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/gemini-usage') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(geminiUsageFile, 'utf8'));
|
|
res.end(JSON.stringify(data));
|
|
} catch {
|
|
res.end(JSON.stringify({ error: 'No usage data. Run scrape-gemini-usage.sh first.' }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/response-time') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ avgSeconds: getAvgResponseTime() }));
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/logs?')) {
|
|
try {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const allowedServices = ['openclaw', 'agent-dashboard', 'tailscaled', 'sshd', 'nginx'];
|
|
const service = params.get('service') || 'openclaw';
|
|
if (!allowedServices.includes(service)) {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
res.end('Invalid service name');
|
|
return;
|
|
}
|
|
if (process.platform !== 'linux') {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('Logs (journalctl) are only available on Linux.\nOn macOS use Console.app or: log show --predicate \'processImagePath contains "openclaw"\' --last 1h');
|
|
return;
|
|
}
|
|
const lines = Math.min(Math.max(parseInt(params.get('lines')) || 100, 1), 1000);
|
|
const { execSync } = require('child_process');
|
|
const serviceUnitCandidates = {
|
|
openclaw: ['openclaw', 'openclaw-gateway', 'openclaw-webhooks'],
|
|
'agent-dashboard': ['agent-dashboard'],
|
|
tailscaled: ['tailscaled'],
|
|
sshd: ['sshd'],
|
|
nginx: ['nginx']
|
|
};
|
|
const units = serviceUnitCandidates[service] || [service];
|
|
const scopes = ['system', 'user'];
|
|
const sourceLogs = [];
|
|
|
|
// Collect logs from all scopes and units
|
|
for (const scope of scopes) {
|
|
for (const unit of units) {
|
|
try {
|
|
const scopeFlag = scope === 'user' ? '--user ' : '';
|
|
const out = execSync(`journalctl ${scopeFlag}-u ${unit} --no-pager -n ${lines} -o short 2>/dev/null`, { encoding: 'utf8', timeout: 10000 });
|
|
if (out && out.trim() && !out.includes('-- No entries --')) {
|
|
const linesArray = out.split('\n').filter(l => l.trim());
|
|
// Get last timestamp for sorting (newest source first)
|
|
const lastTimestamp = linesArray[linesArray.length - 1]?.substring(0, 15) || '';
|
|
sourceLogs.push({
|
|
scope,
|
|
unit,
|
|
logs: out,
|
|
lastTimestamp,
|
|
lineCount: linesArray.length
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
let logs = '';
|
|
if (sourceLogs.length === 0) {
|
|
logs = `No logs available for "${service}". Tried units: ${units.join(', ')} in system + user journal.`;
|
|
} else if (sourceLogs.length === 1) {
|
|
// Single source
|
|
logs = `[source ${sourceLogs[0].scope}:${sourceLogs[0].unit}]\n${sourceLogs[0].logs}`;
|
|
} else {
|
|
// Multiple sources - sort by recency (oldest first, newest last)
|
|
sourceLogs.sort((a, b) => a.lastTimestamp.localeCompare(b.lastTimestamp));
|
|
|
|
// Show each source as separate block
|
|
logs = `${sourceLogs.length} log sources found (chronological by latest entry):\n`;
|
|
for (const entry of sourceLogs) {
|
|
logs += `\n═══════════════════════════════════════════════════════════\n`;
|
|
logs += `[source ${entry.scope}:${entry.unit}] (${entry.lineCount} lines, latest: ${entry.lastTimestamp})\n`;
|
|
logs += `═══════════════════════════════════════════════════════════\n`;
|
|
logs += entry.logs;
|
|
if (!entry.logs.endsWith('\n')) logs += '\n';
|
|
}
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end(logs);
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('Error fetching logs');
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/restart-openclaw' && req.method === 'POST') {
|
|
try {
|
|
auditLog('action_restart_openclaw', ip);
|
|
// Try system scope first, then user scope
|
|
exec('systemctl restart openclaw', (err) => {
|
|
if (err) {
|
|
// System scope failed, try user scope
|
|
exec('systemctl --user restart openclaw', (err2) => {
|
|
if (err2) {
|
|
// Also try openclaw-gateway (common user service name)
|
|
exec('systemctl --user restart openclaw-gateway', (err3) => {});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/restart-dashboard' && req.method === 'POST') {
|
|
try {
|
|
auditLog('action_restart_dashboard', ip);
|
|
setTimeout(() => {
|
|
exec('systemctl restart agent-dashboard', (err) => {});
|
|
}, 2000);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, message: 'Restarting in 2 seconds...' }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/clear-cache' && req.method === 'POST') {
|
|
try {
|
|
costCache = null;
|
|
usageCache = null;
|
|
costCacheTime = 0;
|
|
usageCacheTime = 0;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/restart-tailscale' && req.method === 'POST') {
|
|
auditLog('action_restart_tailscale', ip);
|
|
exec('systemctl restart tailscaled', (err) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: !err, error: err?.message }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/update-openclaw' && req.method === 'POST') {
|
|
auditLog('action_update_openclaw', ip);
|
|
exec('npm update -g openclaw', { timeout: 120000 }, (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: !err, output: stdout?.trim(), error: err?.message }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/kill-tmux' && req.method === 'POST') {
|
|
exec('tmux kill-session -t claude-persistent 2>/dev/null; echo ok', (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/gc' && req.method === 'POST') {
|
|
const projDir = path.join(WORKSPACE_DIR, 'projects');
|
|
exec(`if [ -d "${projDir}" ]; then for d in ${projDir}/*/; do cd "$d" && git gc --quiet 2>/dev/null; done; fi; cd ${WORKSPACE_DIR} && git gc --quiet 2>/dev/null; echo ok`, (err) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/check-update' && req.method === 'POST') {
|
|
exec('npm outdated -g openclaw 2>/dev/null || echo "up to date"', { timeout: 30000 }, (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, output: (stdout || '').trim() || 'All packages up to date' }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/sys-update' && req.method === 'POST') {
|
|
auditLog('action_sys_update', ip);
|
|
exec('apt update -qq && apt upgrade -y -qq 2>&1 | tail -5', { timeout: 300000 }, (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: !err, output: (stdout || '').trim(), error: err?.message }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/disk-cleanup' && req.method === 'POST') {
|
|
exec('apt autoremove -y -qq 2>/dev/null; apt clean 2>/dev/null; journalctl --vacuum-time=7d 2>/dev/null; echo "Cleanup done"', { timeout: 60000 }, (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, output: (stdout || '').trim() }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/action/restart-claude' && req.method === 'POST') {
|
|
exec(`tmux kill-session -t claude-persistent 2>/dev/null; sleep 1; tmux new-session -d -s claude-persistent -x 200 -y 60 && tmux send-keys -t claude-persistent "cd ${WORKSPACE_DIR} && claude" Enter && echo "Claude session started"`, { timeout: 20000 }, (err, stdout) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: !err, output: (stdout || '').trim() }));
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === '/api/tailscale') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const statusJson = execSync('tailscale status --json 2>/dev/null', { encoding: 'utf8', timeout: 5000 });
|
|
const status = JSON.parse(statusJson);
|
|
const self = status.Self || {};
|
|
const peers = Object.values(status.Peer || {}).filter(p => p.Online).length;
|
|
let routes = [];
|
|
try {
|
|
const serveStatus = execSync('tailscale serve status 2>/dev/null', { encoding: 'utf8', timeout: 3000 });
|
|
if (serveStatus && !serveStatus.includes('No serve config')) {
|
|
routes = serveStatus.split('\n').filter(l => l.includes('http')).map(l => l.trim());
|
|
}
|
|
} catch {}
|
|
res.end(JSON.stringify({
|
|
hostname: self.HostName || 'unknown',
|
|
ip: self.TailscaleIPs?.[0] || 'unknown',
|
|
online: self.Online || false,
|
|
peers,
|
|
routes
|
|
}));
|
|
} catch (e) {
|
|
res.end(JSON.stringify({ error: 'Tailscale not available', hostname: '--', ip: '--', online: false, peers: 0, routes: [] }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/lifetime-stats') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
try {
|
|
const now = Date.now();
|
|
const cacheKey = 'lifetimeStats';
|
|
const cacheTime = global[cacheKey + 'Time'] || 0;
|
|
if (global[cacheKey] && now - cacheTime < 300000) {
|
|
res.end(JSON.stringify(global[cacheKey]));
|
|
return;
|
|
}
|
|
const files = fs.readdirSync(sessDir).filter(f => isSessionFile(f));
|
|
let totalTokens = 0, totalMessages = 0, totalCost = 0, totalSessions = files.length;
|
|
let firstSessionDate = null;
|
|
const activeDays = new Set();
|
|
for (const file of files) {
|
|
const lines = fs.readFileSync(path.join(sessDir, file), 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.type !== 'message') continue;
|
|
totalMessages++;
|
|
const msg = d.message;
|
|
if (msg?.usage) {
|
|
const inTok = (msg.usage.input || 0) + (msg.usage.cacheRead || 0) + (msg.usage.cacheWrite || 0);
|
|
const outTok = msg.usage.output || 0;
|
|
totalTokens += inTok + outTok;
|
|
totalCost += estimateMsgCost(msg);
|
|
}
|
|
if (d.timestamp) {
|
|
const ts = new Date(d.timestamp).getTime();
|
|
if (!firstSessionDate || ts < firstSessionDate) firstSessionDate = ts;
|
|
const day = d.timestamp.substring(0, 10);
|
|
activeDays.add(day);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
const result = {
|
|
totalTokens,
|
|
totalMessages,
|
|
totalCost: Math.round(totalCost * 100) / 100,
|
|
totalSessions,
|
|
firstSessionDate,
|
|
daysActive: activeDays.size
|
|
};
|
|
global[cacheKey] = result;
|
|
global[cacheKey + 'Time'] = now;
|
|
res.end(JSON.stringify(result));
|
|
} catch (e) {
|
|
res.end(JSON.stringify({ totalTokens: 0, totalMessages: 0, totalCost: 0, totalSessions: 0, firstSessionDate: null, daysActive: 0 }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/health-history') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(healthHistory));
|
|
return;
|
|
}
|
|
if (req.url === '/api/memory-files') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getMemoryFiles()));
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/memory-file?')) {
|
|
try {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const fname = params.get('path') || '';
|
|
let fpath = '';
|
|
if (fname === 'MEMORY.md') fpath = memoryMdPath;
|
|
else if (fname === 'HEARTBEAT.md') fpath = heartbeatPath;
|
|
else if (fname.startsWith('memory/') && !fname.includes('..')) fpath = path.join(WORKSPACE_DIR, fname);
|
|
else throw new Error('Invalid path');
|
|
|
|
if (fs.existsSync(fpath)) {
|
|
const content = fs.readFileSync(fpath, 'utf8');
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end(content);
|
|
} else {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('File not found');
|
|
}
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
res.end('Bad request');
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/key-files') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(getKeyFiles()));
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/key-file') && req.method === 'GET') {
|
|
try {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const name = params.get('path') || '';
|
|
const allowed = buildKeyFilesAllowed();
|
|
if (!allowed[name]) {
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
const fpath = allowed[name];
|
|
if (!fs.existsSync(fpath)) {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('File not found');
|
|
return;
|
|
}
|
|
const content = fs.readFileSync(fpath, 'utf8');
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end(content);
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
res.end('Bad request');
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/key-file' && req.method === 'POST') {
|
|
let body = '';
|
|
let overflow = false;
|
|
req.on('data', chunk => {
|
|
body += chunk;
|
|
if (body.length > MAX_FILE_BODY) { overflow = true; req.destroy(); }
|
|
});
|
|
req.on('end', () => {
|
|
if (overflow) {
|
|
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Payload too large (max 1MB)' }));
|
|
return;
|
|
}
|
|
try {
|
|
const { path: name, content } = JSON.parse(body);
|
|
if (typeof name !== 'string' || typeof content !== 'string') {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
return;
|
|
}
|
|
if (READ_ONLY_FILES.has(name)) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'File is read-only' }));
|
|
return;
|
|
}
|
|
const allowed = buildKeyFilesAllowed();
|
|
if (!allowed[name]) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
const fpath = allowed[name];
|
|
auditLog('file_edit', ip, { file: name });
|
|
try {
|
|
if (fs.existsSync(fpath)) {
|
|
fs.copyFileSync(fpath, fpath + '.bak');
|
|
}
|
|
} catch {}
|
|
const tmp = fpath + '.tmp.' + Date.now();
|
|
fs.writeFileSync(tmp, content, 'utf8');
|
|
fs.renameSync(tmp, fpath);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/api/cron/') && req.method === 'POST') {
|
|
try {
|
|
const parts = req.url.split('/');
|
|
const action = parts[parts.length - 1];
|
|
const id = parts[parts.length - 2].replace(/[^a-zA-Z0-9\-_]/g, '');
|
|
if (!id) { res.writeHead(400); res.end('Invalid id'); return; }
|
|
|
|
if (action === 'toggle') {
|
|
const { execSync } = require('child_process');
|
|
if (!fs.existsSync(cronFile)) throw new Error('No cron file');
|
|
const data = JSON.parse(fs.readFileSync(cronFile, 'utf8'));
|
|
const job = (data.jobs || []).find(j => j.id === id);
|
|
if (!job) throw new Error('Job not found');
|
|
job.enabled = !job.enabled;
|
|
fs.writeFileSync(cronFile, JSON.stringify(data, null, 2));
|
|
auditLog('cron_toggle', ip, { cronId: id, enabled: job.enabled });
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, enabled: job.enabled }));
|
|
} else if (action === 'run') {
|
|
exec(`openclaw cron run ${id}`, { timeout: 60000 }, (err) => {});
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('Not found');
|
|
}
|
|
} catch (e) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
if (req.url === '/api/live' || req.url.startsWith('/api/live?')) {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
});
|
|
|
|
liveClients.push(res);
|
|
startLiveWatcher();
|
|
|
|
res.write('data: {"status":"connected"}\n\n');
|
|
|
|
try {
|
|
const cutoff = Date.now() - 3600000;
|
|
const files = fs.readdirSync(sessDir).filter(f => {
|
|
if (!f.endsWith('.jsonl')) return false;
|
|
try { return fs.statSync(path.join(sessDir, f)).mtimeMs > cutoff; } catch { return false; }
|
|
});
|
|
const recentEvents = [];
|
|
files.forEach(file => {
|
|
const sessionKey = file.replace('.jsonl', '');
|
|
const content = fs.readFileSync(path.join(sessDir, file), 'utf8');
|
|
const lines = content.split('\n').filter(l => l.trim());
|
|
lines.slice(-5).forEach(line => {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
data._sessionKey = sessionKey;
|
|
const event = formatLiveEvent(data);
|
|
if (event) recentEvents.push(event);
|
|
} catch {}
|
|
});
|
|
});
|
|
recentEvents.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
recentEvents.slice(0, 20).forEach(event => {
|
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
});
|
|
} catch {}
|
|
|
|
req.on('close', () => {
|
|
liveClients = liveClients.filter(client => client !== res);
|
|
if (liveClients.length === 0) {
|
|
if (liveWatcher) { try { liveWatcher.close(); } catch {} liveWatcher = null; }
|
|
Object.keys(_fileWatchers).forEach(k => { try { _fileWatchers[k].close(); } catch {} delete _fileWatchers[k]; });
|
|
}
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(html);
|
|
} catch (e) {
|
|
res.writeHead(500);
|
|
res.end('Error loading dashboard');
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log('Dashboard: http://0.0.0.0:' + PORT);
|
|
});
|