β ΠΠ°Π·Π°Π΄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}`);
});