Files
JianMiauandClaude Fable 5 6bc6a8758d 新增 macOS 版 crontab-ui
摘要:
網頁版排程與程序管理面板(macOS 原版),操作系統 crontab 並整合 PM2、Node.js 程序、Docker 與 log 檢視。

說明:
- Crontab 頁籤直接讀寫系統 crontab,任務由系統 cron 執行
- 監聽 port 以 lsof 偵測、程序清單用 ps aux、結束程序用 kill
- 附 README 說明安裝、PM2 常駐與運作方式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:32:43 +08:00

425 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});