← Назад
const express = require('express'); const cors = require('cors'); const si = require('systeminformation'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { execSync } = require('child_process'); const TaskManager = require('./taskManager'); const IdeasManager = require('./ideasManager'); require('dotenv').config(); // ── Auth helpers ────────────────────────────────────────────────────────────── const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD || ''; const DASHBOARD_SECRET = process.env.DASHBOARD_SECRET || 'changeme'; // Stateless token: hmac(password, secret) β€” valid as long as password doesn't change const makeToken = (password) => crypto.createHmac('sha256', DASHBOARD_SECRET).update(password).digest('hex'); const VALID_TOKEN = DASHBOARD_PASSWORD ? makeToken(DASHBOARD_PASSWORD) : null; const requireAuth = (req, res, next) => { if (!VALID_TOKEN) return next(); // auth disabled if no password set const header = req.headers['authorization'] || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (token && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(VALID_TOKEN))) { return next(); } return res.status(401).json({ error: 'Unauthorized' }); }; // ── PM2 helper: get process info by name ───────────────────────────────────── const getPm2Process = (name) => { try { const list = JSON.parse(execSync('pm2 jlist', { timeout: 3000 }).toString()); return list.find(p => p.name === name) || null; } catch (_) { return null; } }; const formatUptime = (ms) => { const s = Math.floor(ms / 1000); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; }; // ── Bender Bot: real KPIs from PM2 ────────────────────────────────────────── const fetchBenderBotKpis = () => { try { const proc = getPm2Process('bender-bot'); const status = proc?.pm2_env?.status === 'online' ? 'running' : 'stopped'; const uptime = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : 0; const restarts = proc?.pm2_env?.restart_time || 0; const memory = proc?.monit?.memory || 0; const cpu = proc?.monit?.cpu || 0; return { _realData: true, pm2Status: status, bot: { status }, uptime: { ms: uptime, formatted: formatUptime(uptime) }, memory: { bytes: memory, formatted: `${(memory / 1024 / 1024).toFixed(1)} MB` }, cpu: { percent: cpu }, restarts, platform: 'Telegram', name: '@Bender137_bot', }; } catch (err) { console.warn('[BenderBot] KPI read error:', err.message); return { bot: { status: 'unknown' }, uptime: { ms: 0, formatted: '--' }, memory: { bytes: 0, formatted: '--' }, cpu: { percent: 0 }, restarts: 0, platform: 'Telegram', name: '@Bender137_bot', }; } }; // ── Vancouver time helper (PDT UTC-7 / PST UTC-8) ─────────────────────────── const toVancouver = (date) => { return new Date(date.toLocaleString('en-US', { timeZone: 'America/Vancouver' })); }; const fmtVancouverTime = (h, m = 0) => { const hh = String(h).padStart(2, '0'); const mm = String(m).padStart(2, '0'); return `${hh}:${mm} PDT`; }; // ── AlphaPulse: real KPIs from SQLite + PM2 ────────────────────────────────── const fetchAlphaPulseKpis = () => { try { const Database = require('better-sqlite3'); const dbPath = path.join( process.env.PROJECT_PATH_ALPHAPULSE || '/home/app/alphapulsexp', 'posts.db' ); const db = new Database(dbPath, { readonly: true }); const postsToday = db.prepare( "SELECT COUNT(*) as cnt FROM posted WHERE date(posted_at) = date('now')" ).get().cnt; const postsTotal = db.prepare('SELECT COUNT(*) as cnt FROM posted').get().cnt; const lastRow = db.prepare( 'SELECT posted_at, post_type, source FROM posted ORDER BY posted_at DESC LIMIT 1' ).get(); db.close(); // #4: Next scheduled post in Vancouver time // Schedule (UTC): 02:00=trending, 08:00=news/watchlist, 11:00=history, // 15:00=snapshot, 18:00=funding, 21:00=positioning const scheduleUTC = [2, 8, 11, 15, 18, 21]; const now = new Date(); const nowMinsUTC = now.getUTCHours() * 60 + now.getUTCMinutes(); const nextUTC = scheduleUTC.find(h => h * 60 > nowMinsUTC) ?? scheduleUTC[0]; // Convert next post UTC hour to Vancouver const nextPostDate = new Date(now); nextPostDate.setUTCHours(nextUTC, 0, 0, 0); if (nextUTC * 60 <= nowMinsUTC) nextPostDate.setUTCDate(nextPostDate.getUTCDate() + 1); const vanTime = toVancouver(nextPostDate); const nextPost = fmtVancouverTime(vanTime.getHours(), vanTime.getMinutes()); // PM2 real status const proc = getPm2Process('alphapulse'); const pm2Status = proc?.pm2_env?.status === 'online' ? 'running' : 'stopped'; return { _realData: true, pm2Status, bot: { status: pm2Status }, posts: { today: postsToday, total: postsTotal }, channel:{ subscribers: null }, timing: { lastPost: lastRow?.posted_at ?? null, lastType: lastRow?.post_type ?? null, nextPost, }, }; } catch (err) { console.warn('[AlphaPulse] KPI read error:', err.message); return { bot: { status: 'unknown' }, posts: { today: '--', total: '--' }, channel:{ subscribers: '--' }, timing: { lastPost: null, lastType: null, nextPost: '--' }, }; } }; // ── #3: Futures Screener: real KPIs from localhost:3200 ────────────────────── const fetchFuturesScreenerKpis = async () => { try { const [healthRes, symbolsRes, densRes] = await Promise.all([ fetch('http://localhost:3200/health', { signal: AbortSignal.timeout(3000) }), fetch('http://localhost:3200/symbols', { signal: AbortSignal.timeout(3000) }), fetch('http://localhost:3200/densities/simple?minNotional=50000&minScore=60&limitSymbols=20&windowPct=3', { signal: AbortSignal.timeout(10000) }), ]); const health = healthRes.ok ? await healthRes.json() : null; const symbols = symbolsRes.ok ? await symbolsRes.json() : null; const densities = densRes.ok ? await densRes.json() : []; // Count strong levels as "signals" const levels = Array.isArray(densities) ? densities : []; const strongLevels = levels.filter(l => l.score >= 70); // Top symbols by notional const symMap = {}; for (const l of strongLevels) { symMap[l.symbol] = (symMap[l.symbol] || 0) + (l.notional || 0); } const topPairs = Object.entries(symMap) .sort((a, b) => b[1] - a[1]) .slice(0, 4) .map(([s]) => s.replace('USDT', '/USDT')); return { _realData: true, service: { status: health?.status || 'unknown' }, symbols: { total: symbols?.count || 0 }, signals: { totalLevels: levels.length, strongLevels: strongLevels.length, topPairs, }, url: 'https://futures-screener.szhub.space', }; } catch (err) { console.warn('[FuturesScreener] KPI fetch error:', err.message); return { service: { status: 'offline' }, symbols: { total: 0 }, signals: { totalLevels: 0, strongLevels: 0, topPairs: [] }, url: 'https://futures-screener.szhub.space', }; } }; // ── #5: Service uptime pinger ──────────────────────────────────────────────── const SERVICE_ENDPOINTS = [ { id: 'options-api', name: 'Options API', url: 'http://localhost:8080/health' }, { id: 'futures-screener', name: 'Futures Screener', url: 'http://localhost:3200/health' }, { id: 'dashboard', name: 'Dashboard', url: 'http://localhost:3000/health' }, { id: 'piewell', name: 'Piewell.com', url: 'https://piewell.com/', method: 'HEAD' }, ]; const serviceHealthCache = { data: null, ts: 0 }; const fetchServiceHealth = async () => { // Cache for 30s if (serviceHealthCache.data && Date.now() - serviceHealthCache.ts < 30000) { return serviceHealthCache.data; } const results = await Promise.all(SERVICE_ENDPOINTS.map(async (svc) => { const start = Date.now(); try { const res = await fetch(svc.url, { method: svc.method || 'GET', signal: AbortSignal.timeout(5000), }); return { id: svc.id, name: svc.name, status: res.ok ? 'up' : 'down', responseMs: Date.now() - start, statusCode: res.status, }; } catch (err) { return { id: svc.id, name: svc.name, status: 'down', responseMs: Date.now() - start, error: err.message, }; } })); serviceHealthCache.data = results; serviceHealthCache.ts = Date.now(); return results; }; // ── #6: All PM2 processes ──────────────────────────────────────────────────── const getAllPm2Processes = () => { try { const list = JSON.parse(execSync('pm2 jlist', { timeout: 3000 }).toString()); return list.map(p => ({ name: p.name, id: p.pm_id, status: p.pm2_env?.status || 'unknown', cpu: p.monit?.cpu || 0, memory: p.monit?.memory || 0, memoryMB: ((p.monit?.memory || 0) / 1024 / 1024).toFixed(1), uptime: p.pm2_env?.pm_uptime ? formatUptime(Date.now() - p.pm2_env.pm_uptime) : '--', restarts: p.pm2_env?.restart_time || 0, })); } catch (_) { return []; } }; const app = express(); const port = process.env.PORT || 4000; const USE_REAL_DATA = process.env.USE_REAL_DATA === 'true'; // Middleware app.use(cors()); app.use(express.json()); // Serve static files from dist (frontend build) app.use(express.static(path.join(__dirname, '..', 'dist'))); // Health endpoint (no auth, for uptime monitoring) app.get('/health', (req, res) => res.json({ status: 'ok', service: 'dashboard', ts: Date.now() })); // Initialize Task Manager const dataDir = path.join(__dirname, 'data'); const taskManager = new TaskManager(dataDir); // Initialize Ideas Manager const ideasManager = new IdeasManager(); // Mock dynamic stats for projects const generateMockKpis = (projectId) => { switch (projectId) { case 'bender-bot': return fetchBenderBotKpis(); case 'piewell': return { wordpress: { status: 'up', posts: 66 }, search: { impressions: 1007, impressionsTrend: '+206%' }, pinterest: { impressions: 190000 }, affiliate: { iherbStatus: 'pending', program: 'iHerb via CJ' } }; case 'futures-screener': return { service: { status: 'unknown' }, symbols: { total: 0 }, signals: { totalLevels: 0, strongLevels: 0, topPairs: [] }, url: 'https://futures-screener.szhub.space', }; case 'affiliate': return { status: 'planned', phase: 'ΠŸΠΎΠ΄Π³ΠΎΡ‚ΠΎΠ²ΠΊΠ°', roadmap: [ { step: 'Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΎΡ„Ρ„Π΅Ρ€Ρ‹ (Nutra/Crypto)', done: false }, { step: 'ΠΠ°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ PropellerAds', done: false }, { step: 'Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ тСст кампанию', done: false }, ], target: '$50-100/дСнь', }; case 'ideas': return {}; case 'alphapulse': return fetchAlphaPulseKpis(); case 'options-screener': return {}; case 'youtube-ai-channel': return {}; case 'trading-bot': return { phase: 'Setup', deposit: '$500', strategies: [ { name: 'Funding Rate Contrarian', status: 'research' }, { name: 'OI + Price Divergence', status: 'research' }, { name: 'Liquidation Cascade', status: 'research' }, ], roadmap: [ { step: 'Bybit testnet API keys', done: false }, { step: 'БэктСст стратСгий', done: false }, { step: 'Π‘ΠΈΠ³Π½Π°Π»ΡŒΠ½Ρ‹ΠΉ Π±ΠΎΡ‚ (Telegram)', done: false }, { step: 'TV Webhook β†’ auto-execute', done: false }, { step: 'Live trading (mainnet)', done: false }, ], }; default: return {}; } }; const getProjectStatus = (projectId) => { switch (projectId) { case 'bender-bot': { const proc = getPm2Process('bender-bot'); return proc?.pm2_env?.status === 'online' ? 'running' : 'stopped'; } case 'piewell': return 'up'; case 'futures-screener': return 'up'; case 'affiliate': return 'planned'; case 'ideas': return 'active'; case 'alphapulse': { const proc = getPm2Process('alphapulse'); return proc?.pm2_env?.status === 'online' ? 'running' : 'stopped'; } case 'options-screener': return 'active'; case 'youtube-ai-channel': return 'active'; case 'trading-bot': return 'planned'; default: return 'unknown'; } }; // Fetch real Piewell KPIs from WordPress REST API const fetchPiewellKpis = async () => { const base = generateMockKpis('piewell'); try { // Real post count from public WP REST API const postsRes = await fetch('https://piewell.com/wp-json/wp/v2/posts?per_page=1&_fields=id', { signal: AbortSignal.timeout(5000) }); const totalPosts = postsRes.ok ? parseInt(postsRes.headers.get('X-WP-Total') || '66', 10) : 66; // Real site status ping const pingRes = await fetch('https://piewell.com/', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); const siteStatus = pingRes.ok ? 'up' : 'down'; return { wordpress: { status: siteStatus, posts: totalPosts }, search: { impressions: 1007, impressionsTrend: '+206%' }, pinterest: { impressions: 190000 }, affiliate: { iherbStatus: 'pending', program: 'iHerb via CJ' } }; } catch (err) { console.warn('[Piewell] WP API fetch failed, using cached values:', err.message); return base; } }; // Fetch real metrics from the external services running on this server const fetchRealKpis = async (projectId) => { try { if (projectId === 'piewell') { return fetchPiewellKpis(); } if (projectId === 'alphapulse') { return fetchAlphaPulseKpis(); } if (projectId === 'bender-bot') { return fetchBenderBotKpis(); } if (projectId === 'futures-screener') { return fetchFuturesScreenerKpis(); } // For projects without dedicated real-data fetchers, use mock/static KPIs return generateMockKpis(projectId); } catch (error) { console.warn(`[Secretary] Failed to fetch real KPIs for ${projectId}: ${error.message}`); return generateMockKpis(projectId); } }; // ========================================== // AUTH API // ========================================== app.post('/api/auth/login', (req, res) => { const { password } = req.body || {}; if (!VALID_TOKEN) return res.json({ token: 'no-auth' }); // auth disabled if (!password || password !== DASHBOARD_PASSWORD) { return res.status(401).json({ error: 'Invalid password' }); } res.json({ token: VALID_TOKEN }); }); app.get('/api/auth/check', requireAuth, (req, res) => { res.json({ ok: true }); }); // Protect all other /api/ routes app.use('/api/', requireAuth); // ========================================== // SYSTEM METRICS API // ========================================== async function getSystemMetrics() { const [cpuInfo, memInfo, fsSizeInfo, services] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize(), fetchServiceHealth(), ]); const cpuUsage = cpuInfo.currentLoad.toFixed(1); const memUsage = ((memInfo.active / memInfo.total) * 100).toFixed(1); const isError = parseFloat(cpuUsage) > 90 || parseFloat(memUsage) > 95; const pm2Processes = getAllPm2Processes(); const downServices = services.filter(s => s.status === 'down'); return { status: isError || downServices.length > 0 ? 'warning' : 'ok', timestamp: new Date().toISOString(), kpis: { cpu: { usage: `${cpuUsage}%` }, memory: { usedPercent: `${memUsage}%`, totalGB: (memInfo.total / 1073741824).toFixed(1), usedGB: (memInfo.active / 1073741824).toFixed(1) }, disk: fsSizeInfo.map(disk => ({ mount: disk.mount, usePercent: `${disk.use.toFixed(1)}%` })), pm2: pm2Processes, services, } }; } // /api/system β€” used by frontend useSystem() hook app.get('/api/system', async (req, res) => { try { res.json(await getSystemMetrics()); } catch (err) { console.error('System Info Error:', err); res.status(500).json({ error: 'Failed to fetch system metrics' }); } }); // /api/system/health β€” legacy route, kept for compatibility app.get('/api/system/health', async (req, res) => { try { res.json(await getSystemMetrics()); } catch (err) { console.error('System Info Error:', err); res.status(500).json({ error: 'Failed to fetch system metrics' }); } }); // ========================================== // PROJECT METRICS API // ========================================== const ALL_PROJECT_IDS = ['bender-bot', 'piewell', 'futures-screener', 'affiliate', 'ideas', 'alphapulse', 'options-screener', 'youtube-ai-channel', 'trading-bot']; // List all projects app.get('/api/projects', async (req, res) => { try { const projects = await Promise.all( ALL_PROJECT_IDS.map(async (id) => { const kpis = USE_REAL_DATA ? await fetchRealKpis(id) : generateMockKpis(id); return { id, name: id.toUpperCase(), status: getProjectStatus(id), kpis, links: { Dashboard: `https://${id}.local` }, }; }) ); res.json(projects); } catch (err) { res.status(500).json({ error: 'Failed to fetch projects' }); } }); app.get('/api/projects/:id', async (req, res) => { const { id } = req.params; try { let kpis; if (USE_REAL_DATA) { kpis = await fetchRealKpis(id); } else { kpis = generateMockKpis(id); } const status = getProjectStatus(id); res.json({ id, name: id.toUpperCase(), status, kpis, links: { Dashboard: `https://${id}.local` } }); } catch (err) { res.status(500).json({ error: 'Failed to fetch project info' }); } }); // ========================================== // TASK MANAGEMENT API (Kanban) // ========================================== // Get all tasks for a project app.get('/api/projects/:id/kanban-tasks', async (req, res) => { try { const { id } = req.params; // Use IdeasManager for ideas project if (id === 'ideas') { const ideasData = await ideasManager.getAllIdeas(); res.json(ideasData); return; } const tasksData = await taskManager.getTasks(id); res.json(tasksData); } catch (err) { res.status(500).json({ error: err.message }); } }); // Update an entire task board (batch save) app.post('/api/projects/:id/kanban-tasks', async (req, res) => { try { const savedData = await taskManager.saveTasks(req.params.id, req.body); res.json(savedData); } catch (err) { res.status(500).json({ error: err.message }); } }); // Update a specific task's status app.post('/api/projects/:id/tasks/:taskId/status', async (req, res) => { const { id, taskId } = req.params; const { status } = req.body; if (!status) { return res.status(400).json({ error: 'Status is required' }); } try { // Use IdeasManager for ideas project if (id === 'ideas') { const updatedTask = await ideasManager.updateTaskStatus(taskId, status); res.json({ success: true, task: updatedTask }); return; } const updatedTask = await taskManager.updateTaskStatus(id, taskId, status); res.json({ success: true, task: updatedTask }); } catch (err) { if (err.message.includes('not found')) { res.status(404).json({ error: err.message }); } else { res.status(500).json({ error: err.message }); } } }); // Get archived tasks app.get('/api/projects/:id/archived-tasks', async (req, res) => { try { const archivedTasks = await taskManager.getArchivedTasks(req.params.id); res.json({ archivedTasks }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Restore a task from archive app.post('/api/projects/:id/tasks/:taskId/restore', async (req, res) => { const { id, taskId } = req.params; try { const task = await taskManager.restoreTask(id, taskId); res.json({ success: true, task }); } catch (err) { if (err.message.includes('not found')) { res.status(404).json({ error: err.message }); } else { res.status(500).json({ error: err.message }); } } }); // Update task fields app.patch('/api/projects/:id/tasks/:taskId', async (req, res) => { const { id, taskId } = req.params; try { const updatedTask = await taskManager.updateTask(id, taskId, req.body); res.json({ success: true, task: updatedTask }); } catch (err) { if (err.message.includes('not found')) { res.status(404).json({ error: err.message }); } else { res.status(500).json({ error: err.message }); } } }); // Delete a task permanently app.delete('/api/projects/:id/tasks/:taskId', async (req, res) => { const { id, taskId } = req.params; try { const result = await taskManager.deleteTask(id, taskId); res.json({ success: true, ...result }); } catch (err) { if (err.message.includes('not found')) { res.status(404).json({ error: err.message }); } else { res.status(500).json({ error: err.message }); } } }); // Archive a task app.post('/api/projects/:id/tasks/:taskId/archive', async (req, res) => { const { id, taskId } = req.params; try { const result = await taskManager.archiveTask(id, taskId); res.json({ success: true, ...result }); } catch (err) { if (err.message.includes('not found')) { res.status(404).json({ error: err.message }); } else { res.status(500).json({ error: err.message }); } } }); // Create a new task app.post('/api/projects/:id/tasks', async (req, res) => { const { id } = req.params; const taskInput = req.body; if (!taskInput.title) { return res.status(400).json({ error: 'Title is required' }); } try { const newTask = await taskManager.createTask(id, taskInput); res.json({ success: true, task: newTask }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ========================================== // FILE BROWSER API // ========================================== const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', 'dist', 'build', '.next', 'vendor', 'venv', '.venv', 'coverage', '.cache']); const TEXT_EXTENSIONS = new Set([ '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.json', '.md', '.txt', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash', '.zsh', '.py', '.rb', '.php', '.go', '.rs', '.css', '.scss', '.sass', '.less', '.html', '.xml', '.svg', '.sql', '.graphql', '.prisma', '.c', '.cpp', '.h', '.java', '.cs', '.dockerfile', '.lock', '.gitignore', '.dockerignore', '.editorconfig', '.nvmrc', ]); const MAX_FILE_SIZE = 500 * 1024; // 500 KB const MAX_TREE_DEPTH = 5; function getProjectFilesPath(projectId) { const envKey = `PROJECT_FILES_${projectId.toUpperCase().replace(/-/g, '_')}`; return process.env[envKey] || null; } function buildFileTree(dirPath, rootPath, depth = 0) { if (depth > MAX_TREE_DEPTH) return []; let entries; try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return []; } const nodes = []; for (const entry of entries) { // Skip hidden files/dirs (except .env files which are useful to see) if (entry.name.startsWith('.') && !entry.name.startsWith('.env')) continue; const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(rootPath, fullPath); if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; nodes.push({ name: entry.name, type: 'dir', path: relativePath, children: buildFileTree(fullPath, rootPath, depth + 1), }); } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); let size = 0; try { size = fs.statSync(fullPath).size; } catch { /* ignore */ } nodes.push({ name: entry.name, type: 'file', path: relativePath, size, ext, isText: TEXT_EXTENSIONS.has(ext) || ext === '', }); } } // Directories first, then files; both sorted alphabetically nodes.sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); return nodes; } // GET /api/projects/:id/files β€” file tree app.get('/api/projects/:id/files', (req, res) => { const projectPath = getProjectFilesPath(req.params.id); if (!projectPath) { const envKey = `PROJECT_FILES_${req.params.id.toUpperCase().replace(/-/g, '_')}`; return res.status(404).json({ error: `Project path not configured. Set the ${envKey} environment variable.`, }); } if (!fs.existsSync(projectPath)) { return res.status(404).json({ error: `Path does not exist: ${projectPath}` }); } const tree = buildFileTree(projectPath, projectPath); res.json({ path: projectPath, tree }); }); // GET /api/projects/:id/files/content?path=relative/path β€” file content app.get('/api/projects/:id/files/content', (req, res) => { const projectPath = getProjectFilesPath(req.params.id); if (!projectPath) { const envKey = `PROJECT_FILES_${req.params.id.toUpperCase().replace(/-/g, '_')}`; return res.status(404).json({ error: `Project path not configured. Set the ${envKey} environment variable.`, }); } const filePath = req.query.path; if (!filePath) { return res.status(400).json({ error: 'path query param required' }); } // Security: prevent path traversal const fullPath = path.resolve(projectPath, filePath); if (!fullPath.startsWith(path.resolve(projectPath) + path.sep) && fullPath !== path.resolve(projectPath)) { return res.status(403).json({ error: 'Access denied' }); } if (!fs.existsSync(fullPath)) { return res.status(404).json({ error: 'File not found' }); } let stat; try { stat = fs.statSync(fullPath); } catch { return res.status(500).json({ error: 'Cannot stat file' }); } if (stat.size > MAX_FILE_SIZE) { return res.status(413).json({ error: `File too large (${(stat.size / 1024).toFixed(1)} KB). Limit: 500 KB.`, }); } try { const content = fs.readFileSync(fullPath, 'utf8'); res.json({ content, size: stat.size, path: filePath }); } catch { res.status(422).json({ error: 'Cannot read file (binary or encoding issue)' }); } }); // ========================================== // DEPLOY LOGS // ========================================== const deployLogsFile = path.join(dataDir, 'deploy-logs.json'); async function readDeployLogs() { try { const content = await fs.readFile(deployLogsFile, 'utf8'); return JSON.parse(content); } catch { return {}; } } async function writeDeployLogs(logs) { await fs.mkdir(dataDir, { recursive: true }); const tempPath = `${deployLogsFile}.tmp`; await fs.writeFile(tempPath, JSON.stringify(logs, null, 2), 'utf8'); await fs.rename(tempPath, deployLogsFile); } // GitHub Actions webhook β€” POST /api/webhooks/github // Expected headers: x-github-event, optional x-project-id app.post('/api/webhooks/github', async (req, res) => { const event = req.headers['x-github-event'] || 'push'; const projectId = req.headers['x-project-id'] || req.body?.repository?.name || 'unknown'; const payload = req.body; const logEntry = { event, projectId, status: payload?.workflow_run?.conclusion || payload?.check_run?.conclusion || 'success', branch: payload?.workflow_run?.head_branch || payload?.ref?.replace('refs/heads/', '') || 'main', commit: payload?.workflow_run?.head_sha?.slice(0, 7) || payload?.after?.slice(0, 7) || '', message: payload?.head_commit?.message?.split('\n')[0] || payload?.workflow_run?.name || '', actor: payload?.sender?.login || payload?.pusher?.name || '', timestamp: new Date().toISOString(), }; try { const logs = await readDeployLogs(); if (!logs[projectId]) logs[projectId] = []; logs[projectId].unshift(logEntry); if (logs[projectId].length > 10) logs[projectId] = logs[projectId].slice(0, 10); await writeDeployLogs(logs); console.log(`[Deploy] ${projectId}: ${logEntry.status} on ${logEntry.branch}`); res.json({ received: true }); } catch (err) { console.error('[Deploy webhook] Error:', err); res.status(500).json({ error: err.message }); } }); // GET /api/deploy-logs β€” all projects app.get('/api/deploy-logs', async (req, res) => { try { res.json(await readDeployLogs()); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/deploy-logs/:projectId β€” specific project app.get('/api/deploy-logs/:projectId', async (req, res) => { try { const logs = await readDeployLogs(); res.json(logs[req.params.projectId] || []); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Brain (Second Brain / Obsidian vault) ───────────────────────────────────── const BRAIN_PATH = '/home/app/brain'; function buildNoteTree(dir, base) { const items = []; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return items; } for (const e of entries) { if (e.name.startsWith('.')) continue; const fullPath = path.join(dir, e.name); const relPath = path.relative(base, fullPath); if (e.isDirectory()) { items.push({ name: e.name, type: 'dir', path: relPath, children: buildNoteTree(fullPath, base) }); } else if (e.name.endsWith('.md')) { items.push({ name: e.name, type: 'file', path: relPath }); } } return items.sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); } // GET /api/brain/tree app.get('/api/brain/tree', requireAuth, (req, res) => { const tree = buildNoteTree(BRAIN_PATH, BRAIN_PATH); res.json({ success: true, data: tree }); }); // GET /api/brain/note?path=projects/options-screener.md app.get('/api/brain/note', requireAuth, (req, res) => { const rel = req.query.path; if (!rel) return res.status(400).json({ error: 'path required' }); const full = path.resolve(BRAIN_PATH, rel); if (!full.startsWith(BRAIN_PATH + path.sep) && full !== BRAIN_PATH) { return res.status(403).json({ error: 'Access denied' }); } if (!fs.existsSync(full)) return res.status(404).json({ error: 'Not found' }); const content = fs.readFileSync(full, 'utf8'); res.json({ success: true, data: { path: rel, content } }); }); // POST /api/brain/note β€” { path, content } app.post('/api/brain/note', requireAuth, express.json(), (req, res) => { const { path: rel, content } = req.body || {}; if (!rel || content === undefined) return res.status(400).json({ error: 'path and content required' }); if (!rel.endsWith('.md')) return res.status(400).json({ error: 'Only .md files' }); const full = path.resolve(BRAIN_PATH, rel); if (!full.startsWith(BRAIN_PATH + path.sep)) { return res.status(403).json({ error: 'Access denied' }); } fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, content, 'utf8'); // auto-commit try { const { execSync } = require('child_process'); execSync(`cd ${BRAIN_PATH} && git add -A && git diff --cached --quiet || git commit -m "brain: update ${rel}"`, { stdio: 'pipe' }); } catch { /* ignore git errors */ } res.json({ success: true, data: { path: rel } }); }); // ── File download ───────────────────────────────────────────────────────────── // GET /api/download?file=youtube-ai-channel/content/SCRIPT_002_iran_voiceover.txt const DOWNLOAD_BASE = '/home/app'; app.get('/api/download', requireAuth, (req, res) => { const rel = req.query.file; if (!rel) return res.status(400).json({ error: 'file param required' }); const full = path.resolve(DOWNLOAD_BASE, rel); if (!full.startsWith(DOWNLOAD_BASE + path.sep)) { return res.status(403).json({ error: 'Access denied' }); } if (!fs.existsSync(full)) return res.status(404).json({ error: 'File not found' }); res.download(full, path.basename(full)); }); // ── #5: Service health endpoint ────────────────────────────────────────────── app.get('/api/services/health', async (req, res) => { try { const services = await fetchServiceHealth(); res.json({ success: true, data: services }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── #8: Alerts endpoint (for Bender to poll) ──────────────────────────────── app.get('/api/alerts', async (req, res) => { try { const alerts = []; // Check services const services = await fetchServiceHealth(); for (const svc of services) { if (svc.status === 'down') { alerts.push({ level: 'critical', message: `${svc.name} is DOWN`, service: svc.id, ts: Date.now() }); } else if (svc.responseMs > 3000) { alerts.push({ level: 'warning', message: `${svc.name} slow (${svc.responseMs}ms)`, service: svc.id, ts: Date.now() }); } } // Check system const [cpuInfo, memInfo, fsSizeInfo] = await Promise.all([si.currentLoad(), si.mem(), si.fsSize()]); if (cpuInfo.currentLoad > 90) alerts.push({ level: 'warning', message: `CPU high: ${cpuInfo.currentLoad.toFixed(1)}%`, ts: Date.now() }); const memPct = (memInfo.active / memInfo.total) * 100; if (memPct > 90) alerts.push({ level: 'warning', message: `RAM high: ${memPct.toFixed(1)}%`, ts: Date.now() }); const mainDisk = fsSizeInfo.find(d => d.mount === '/'); if (mainDisk && mainDisk.use > 80) alerts.push({ level: 'warning', message: `Disk high: ${mainDisk.use.toFixed(1)}%`, ts: Date.now() }); // Check PM2 const procs = getAllPm2Processes(); for (const p of procs) { if (p.status !== 'online') alerts.push({ level: 'critical', message: `PM2 ${p.name} is ${p.status}`, ts: Date.now() }); if (p.restarts > 10) alerts.push({ level: 'warning', message: `PM2 ${p.name}: ${p.restarts} restarts`, ts: Date.now() }); } res.json({ success: true, alerts, count: alerts.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Catch-all for SPA (serve index.html for all non-API routes) app.use((req, res, next) => { if (req.path.startsWith('/api/')) { return next(); } res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); }); // Start server app.listen(port, () => { console.log(`OpenClaw Secretary Local Server running on port ${port}`); console.log(`Mode: ${USE_REAL_DATA ? 'PRODUCTION (Real Data)' : 'DEVELOPMENT (Mock Data)'}`); console.log(`Data directory default fallback: ${dataDir}`); });