const express = require('express'); const { exec } = require('child_process'); const path = require('path'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3789; app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // ── 判斷是否為 cron 格式 ────────────────────────────────── function isCronFormat(content) { if (!content) return false; if (content.startsWith('@')) return /^@\w+\s+\S/.test(content); return /^[\d\*\/\-,]+\s+[\d\*\/\-,]+\s+[\d\*\/\-,]+\s+[\d\*\/\-,]+\s+[\d\*\/\-,]+\s+\S/.test(content); } // ── 解析 crontab 文字 ───────────────────────────────────── function parseCrontab(raw) { const lines = raw.split('\n'); const labelLineSet = new Set(); // 屬於某個 cron 任務的 label 行 // 第一輪:標記所有緊接在 cron 行前面的連續 comment 行 for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (!trimmed) continue; const disabled = trimmed.startsWith('#'); const content = disabled ? trimmed.replace(/^#+\s*/, '').trim() : trimmed; if (!isCronFormat(content)) continue; // 往前找所有連續的 comment 行(中間沒有空行) let j = i - 1; while (j >= 0) { const t = lines[j].trim(); if (!t) break; // 空行就停 if (t.startsWith('#') && !isCronFormat(t.replace(/^#+\s*/, '').trim())) { labelLineSet.add(j); j--; } else { break; } } } // 第二輪:建立 entries const entries = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed) continue; if (labelLineSet.has(i)) continue; // label 行由 cron entry 帶走 const disabled = trimmed.startsWith('#'); const content = disabled ? trimmed.replace(/^#+\s*/, '').trim() : trimmed; if (isCronFormat(content)) { const entry = parseCronLine(i, line, content, disabled); // 收集緊接在上方的 label 行(由小到大排列) const labelIndices = []; let j = i - 1; while (j >= 0 && labelLineSet.has(j)) { labelIndices.unshift(j); j--; } if (labelIndices.length > 0) { entry.label = labelIndices .map(idx => lines[idx].trim().replace(/^#+\s*/, '')) .join('\n'); entry.labelIndices = labelIndices; } entries.push(entry); } else { entries.push({ index: i, type: 'comment', raw: line, text: trimmed }); } } return entries; } function parseCronLine(index, raw, content, disabled) { let schedule, command, scheduleDesc; if (content.startsWith('@')) { const spaceIdx = content.indexOf(' '); schedule = content.slice(0, spaceIdx); command = content.slice(spaceIdx + 1).trim(); const macros = { '@reboot': '開機時執行', '@yearly': '每年', '@annually': '每年', '@monthly': '每月1日', '@weekly': '每週日', '@daily': '每天', '@midnight': '每天', '@hourly': '每小時', }; scheduleDesc = macros[schedule] || schedule; } else { const parts = content.split(/\s+/); const [min, hour, day, month, weekday, ...rest] = parts; schedule = `${min} ${hour} ${day} ${month} ${weekday}`; command = rest.join(' '); scheduleDesc = describeSchedule(min, hour, day, month, weekday); } return { index, type: 'cron', raw, schedule, command, scheduleDesc, disabled, label: '', labelIndices: [] }; } function describeSchedule(min, hour, day, month, weekday) { const wd = ['日', '一', '二', '三', '四', '五', '六']; const parts = []; if (weekday !== '*') { parts.push(`每週${weekday.split(',').map(n => wd[parseInt(n)] || n).join('、')}`); } else if (day !== '*') { parts.push(`每月 ${day} 日`); } else { parts.push('每天'); } if (hour !== '*' && min !== '*') parts.push(`${hour}:${String(min).padStart(2, '0')}`); else if (hour !== '*') parts.push(`${hour} 時`); else if (min !== '*') parts.push(`每小時第 ${min} 分`); if (month !== '*') parts.unshift(`${month} 月`); return parts.join(' '); } // ── 工具 ───────────────────────────────────────────────── function getCrontabLines(cb) { exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => { cb((stdout || '').split('\n')); }); } function applyLines(lines, res) { const content = lines.join('\n'); console.log('[applyLines] writing crontab:'); lines.forEach((l, i) => console.log(` [${i}] ${JSON.stringify(l)}`)); try { const tmpFile = `/tmp/crontab_ui_${Date.now()}.txt`; fs.writeFileSync(tmpFile, content); const result = require('child_process').execSync(`crontab ${tmpFile} 2>&1`).toString(); fs.unlinkSync(tmpFile); res.json({ success: true }); } catch (e) { // 讀出檔案內容給 error message const tmpFiles = require('child_process') .execSync('ls /tmp/crontab_ui_*.txt 2>/dev/null || echo ""') .toString().trim().split('\n').filter(Boolean); let fileContent = ''; try { if (tmpFiles.length > 0) fileContent = fs.readFileSync(tmpFiles[tmpFiles.length - 1], 'utf-8'); } catch {} console.error('[applyLines] error:', e.message); if (fileContent) console.error('[applyLines] file content:\n' + fileContent); res.status(500).json({ error: e.message, fileContent }); } } // label 文字 → 陣列的 # 行 function labelToLines(label) { if (!label || !label.trim()) return []; return label.split('\n').filter(l => l.trim()).map(l => `# ${l.trim()}`); } // ── GET /api/crontab ────────────────────────────────────── app.get('/api/crontab', (req, res) => { exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => { const raw = stdout || ''; res.json({ raw, entries: parseCrontab(raw) }); }); }); // ── POST /api/crontab/toggle ────────────────────────────── app.post('/api/crontab/toggle', (req, res) => { const { index } = req.body; if (index === undefined) return res.status(400).json({ error: 'index required' }); getCrontabLines((lines) => { if (index < 0 || index >= lines.length) return res.status(404).json({ error: 'not found' }); const t = lines[index].trim(); lines[index] = t.startsWith('#') ? t.replace(/^#+\s*/, '') : '# ' + lines[index]; applyLines(lines, res); }); }); // ── DELETE /api/crontab/:index ──────────────────────────── // body: { labelIndices?: number[] } app.delete('/api/crontab/:index', (req, res) => { const cronIndex = parseInt(req.params.index); const labelIndices = req.body?.labelIndices || []; getCrontabLines((lines) => { if (isNaN(cronIndex) || cronIndex < 0 || cronIndex >= lines.length) return res.status(404).json({ error: 'not found' }); const toDelete = [...labelIndices, cronIndex].sort((a, b) => b - a); toDelete.forEach(idx => { if (idx >= 0 && idx < lines.length) lines.splice(idx, 1); }); applyLines(lines, res); }); }); // ── PUT /api/crontab/:index ─────────────────────────────── // body: { schedule, command, label, labelIndices?: number[] } app.put('/api/crontab/:index', (req, res) => { const cronIndex = parseInt(req.params.index); const { schedule, command, label, labelIndices = [] } = req.body; console.log('[PUT] index=%d schedule=%j command=%j label=%j labelIndices=%j', cronIndex, schedule, command, label, labelIndices); if (!schedule || !command) return res.status(400).json({ error: 'schedule and command required' }); getCrontabLines((lines) => { if (isNaN(cronIndex) || cronIndex < 0 || cronIndex >= lines.length) return res.status(404).json({ error: 'not found' }); const wasDisabled = lines[cronIndex].trim().startsWith('#'); const newCronLine = wasDisabled ? `# ${schedule} ${command}` : `${schedule} ${command}`; // 所有要移除的行(倒序) const allRemove = [...labelIndices, cronIndex].sort((a, b) => b - a); const insertAt = Math.min(...allRemove); allRemove.forEach(idx => lines.splice(idx, 1)); // 重建:新 label 行 + cron 行 const newLines = [...labelToLines(label), newCronLine]; lines.splice(insertAt, 0, ...newLines); applyLines(lines, res); }); }); // ── POST /api/crontab/add ───────────────────────────────── // body: { schedule, command, label? } app.post('/api/crontab/add', (req, res) => { const { schedule, command, label } = req.body; if (!schedule || !command) return res.status(400).json({ error: 'schedule and command required' }); getCrontabLines((lines) => { while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); labelToLines(label).forEach(l => lines.push(l)); lines.push(`${schedule} ${command}`); lines.push(''); applyLines(lines, res); }); }); // ── GET /api/pm2 ───────────────────────────────────────── app.get('/api/pm2', (req, res) => { exec('pm2 jlist 2>/dev/null', (err, stdout) => { if (err) return res.json({ processes: [], error: err.message }); try { const list = JSON.parse(stdout || '[]'); const processes = list.map(p => ({ id: p.pm_id, name: p.name, status: p.pm2_env?.status || 'unknown', pid: p.pid, cpu: p.monit?.cpu ?? '-', memory: p.monit?.memory ?? 0, restarts: p.pm2_env?.restart_time ?? 0, uptime: p.pm2_env?.pm_uptime ?? null, mode: p.pm2_env?.exec_mode || 'fork', script: p.pm2_env?.pm_exec_path || p.pm2_env?.script || '', ports: [], })); // 用 lsof 取得所有 LISTEN port,再依 PID 對應 const pids = processes.filter(p => p.pid).map(p => p.pid).join(','); if (!pids) return res.json({ processes }); exec(`lsof -iTCP -sTCP:LISTEN -nP -p ${pids} 2>/dev/null`, (e2, lsofOut) => { // 解析 lsof 輸出:每行取 PID 和 NAME(*:PORT 或 addr:PORT) const portMap = {}; // pid → [port, ...] (lsofOut || '').split('\n').forEach(line => { // 格式: COMMAND PID USER ... NAME (LISTEN) // NAME 可能是 *:3000 或 localhost:3000,後面跟著 (LISTEN) const m = line.match(/\s+(\d+)\s+\S+\s+.*?:(\d+)\s+\(LISTEN\)/); if (!m) return; const pid = parseInt(m[1]); const port = m[2]; if (pid && port) { if (!portMap[pid]) portMap[pid] = []; if (!portMap[pid].includes(port)) portMap[pid].push(port); } }); processes.forEach(p => { if (portMap[p.pid]) p.ports = portMap[p.pid]; }); res.json({ processes }); }); } catch (e) { res.json({ processes: [], error: e.message }); } }); }); // ── POST /api/pm2/:action ───────────────────────────────── // action: start | stop | restart | delete app.post('/api/pm2/:action', (req, res) => { const { action } = req.params; const { name } = req.body; if (!name) return res.status(400).json({ error: 'name required' }); const allowed = ['start', 'stop', 'restart', 'delete']; if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' }); exec(`pm2 ${action} ${JSON.stringify(name)} 2>&1`, (err, stdout) => { if (err) return res.status(500).json({ error: stdout || err.message }); res.json({ success: true }); }); }); // ── GET /api/node ───────────────────────────────────────── app.get('/api/node', (req, res) => { exec("ps aux | grep -E '[n]ode|[b]un' | grep -v grep", (err, stdout) => { const lines = (stdout || '').trim().split('\n').filter(Boolean); const processes = lines.map(line => { const cols = line.trim().split(/\s+/); const pid = cols[1]; const cpu = cols[2]; const mem = cols[3]; const started = cols[8]; const time = cols[9]; const cmd = cols.slice(10).join(' '); return { pid, cpu, mem, started, time, cmd }; }).filter(p => p.pid && p.pid !== String(process.pid)); // 排除自己 res.json({ processes }); }); }); // ── POST /api/node/kill ─────────────────────────────────── app.post('/api/node/kill', (req, res) => { const { pid, force } = req.body; if (!pid) return res.status(400).json({ error: 'pid required' }); const sig = force ? '-9' : '-15'; exec(`kill ${sig} ${parseInt(pid)} 2>&1`, (err, stdout) => { if (err) return res.status(500).json({ error: stdout || err.message }); res.json({ success: true }); }); }); // ── GET /api/docker ────────────────────────────────────── app.get('/api/docker', (req, res) => { const format = '{{json .}}'; exec(`docker ps -a --format ${JSON.stringify(format)} 2>&1`, (err, stdout) => { if (err) return res.json({ containers: [], error: stdout.trim() || err.message }); try { const containers = (stdout || '') .split('\n') .filter(Boolean) .map(line => JSON.parse(line)) .map(container => ({ id: container.ID, name: container.Names, image: container.Image, status: container.Status, state: (container.State || 'unknown').toLowerCase(), ports: container.Ports || '', command: container.Command || '', createdAt: container.CreatedAt || '', runningFor: container.RunningFor || '', })); res.json({ containers }); } catch (e) { res.json({ containers: [], error: e.message }); } }); }); // ── POST /api/docker/:action ───────────────────────────── // action: start | stop | restart app.post('/api/docker/:action', (req, res) => { const { action } = req.params; const { id } = req.body; const allowed = ['start', 'stop', 'restart']; if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' }); if (!id) return res.status(400).json({ error: 'id required' }); exec(`docker ${action} ${JSON.stringify(id)} 2>&1`, (err, stdout) => { if (err) return res.status(500).json({ error: stdout.trim() || err.message }); res.json({ success: true }); }); }); // ── Log API ─────────────────────────────────────────────── const LOG_DIR = process.env.LOG_DIR || '/Volumes/HDD/Claude/logs'; app.get('/api/logs', (req, res) => { try { if (!fs.existsSync(LOG_DIR)) return res.json([]); const files = fs.readdirSync(LOG_DIR) .filter(f => f.endsWith('.log')) .map(f => { const stat = fs.statSync(path.join(LOG_DIR, f)); return { name: f, size: stat.size, mtime: stat.mtimeMs }; }) .sort((a, b) => b.mtime - a.mtime); res.json(files); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/logs/:filename', (req, res) => { const filename = path.basename(req.params.filename); // 防止路徑穿越 const filepath = path.join(LOG_DIR, filename); if (!filepath.startsWith(LOG_DIR)) return res.status(403).json({ error: 'forbidden' }); try { if (!fs.existsSync(filepath)) return res.status(404).json({ error: '找不到檔案' }); const lines = parseInt(req.query.lines) || 200; const content = fs.readFileSync(filepath, 'utf-8'); const allLines = content.split('\n'); const tail = allLines.slice(-lines).join('\n'); res.json({ filename, lines: allLines.length, content: tail }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.use('/api', (req, res) => { res.status(404).json({ error: `API not found: ${req.method} ${req.originalUrl}` }); }); app.listen(PORT, () => { console.log(`✅ Crontab UI 已啟動:http://localhost:${PORT}`); });