Files
crontab-ui/server.js
T
JianMiauandClaude Fable 5 41ab88abbb 新增 Windows 版 crontab-ui
摘要:
從 macOS 版移植的網頁排程與程序管理面板,可在 Windows 原生執行。

說明:
- Windows 無系統 cron,改為本地檔案 crontab.txt 儲存 + 伺服器內建排程器執行(支援五欄位語法與 @ 巨集,@reboot 於伺服器啟動時執行)
- lsof → netstat -ano、ps aux → PowerShell Get-CimInstance、kill → taskkill
- PM2 / Docker 指令跨平台,維持不變
- 前端:PM2 頁籤設為首頁;script 路徑僅顯示檔名,滑鼠停留顯示完整路徑;port 連結改用目前主機名

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

536 lines
20 KiB
JavaScript
Raw 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, execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3789;
// Windows 版:crontab 內容存在本地檔案,由內建排程器執行
const CRONTAB_FILE = process.env.CRONTAB_FILE || path.join(__dirname, 'crontab.txt');
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, 'logs');
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(' ');
}
// ── 內建排程器 ────────────────────────────────────────────
// macro → 5 欄位(@reboot 於伺服器啟動時執行)
const MACRO_MAP = {
'@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *', '@weekly': '0 0 * * 0',
'@daily': '0 0 * * *', '@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
};
// 解析單一 cron 欄位(*、*/n、a-b、a-b/n、a,b,c),回傳允許值 Setnull 表示格式錯誤
function parseField(field, min, max) {
const values = new Set();
for (const part of field.split(',')) {
const m = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/);
if (!m) return null;
const step = m[2] ? parseInt(m[2]) : 1;
if (step < 1) return null;
let lo, hi;
if (m[1] === '*') { lo = min; hi = max; }
else if (m[1].includes('-')) { [lo, hi] = m[1].split('-').map(Number); }
else { lo = hi = parseInt(m[1]); if (m[2]) hi = max; } // "a/n" 視為 a-max/n
if (lo < min || hi > max || lo > hi) return null;
for (let v = lo; v <= hi; v += step) values.add(v);
}
return values;
}
function cronMatches(schedule, date) {
const fields = schedule.trim().split(/\s+/);
if (fields.length !== 5) return false;
const [min, hour, day, month, weekday] = fields;
const fMin = parseField(min, 0, 59);
const fHour = parseField(hour, 0, 23);
const fDay = parseField(day, 1, 31);
const fMonth = parseField(month, 1, 12);
const fWd = parseField(weekday, 0, 7);
if (!fMin || !fHour || !fDay || !fMonth || !fWd) return false;
if (fWd.has(7)) fWd.add(0); // 7 也代表週日
if (!fMin.has(date.getMinutes())) return false;
if (!fHour.has(date.getHours())) return false;
if (!fMonth.has(date.getMonth() + 1)) return false;
// 標準 cron 規則:day 與 weekday 都有限制時,符合其一即可
const dayRestricted = day !== '*';
const wdRestricted = weekday !== '*';
const dayOk = fDay.has(date.getDate());
const wdOk = fWd.has(date.getDay());
if (dayRestricted && wdRestricted) return dayOk || wdOk;
if (dayRestricted) return dayOk;
if (wdRestricted) return wdOk;
return true;
}
function cronLog(message) {
const line = `[${new Date().toLocaleString('sv-SE')}] ${message}\n`;
try {
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
fs.appendFileSync(path.join(LOG_DIR, 'cron.log'), line);
} catch {}
console.log(line.trim());
}
function runJob(schedule, command) {
cronLog(`▶ 執行 [${schedule}] ${command}`);
exec(command, { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
const out = [stdout, stderr].filter(s => s && s.trim()).join('\n').trim();
if (err) cronLog(`✖ 失敗 [${command}]${err.message}${out ? '\n' + out : ''}`);
else cronLog(`✔ 完成 [${command}]${out ? '\n' + out : ''}`);
});
}
// 取得目前啟用中的 cron 任務
function activeJobs() {
return parseCrontab(readCrontabRaw())
.filter(e => e.type === 'cron' && !e.disabled)
.map(e => ({
schedule: MACRO_MAP[e.schedule] || e.schedule,
isReboot: e.schedule === '@reboot',
command: e.command,
}));
}
let lastMinuteKey = '';
function schedulerTick() {
const now = new Date();
const key = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}`;
if (key === lastMinuteKey) return; // 同一分鐘不重複執行
lastMinuteKey = key;
for (const job of activeJobs()) {
if (job.isReboot) continue;
if (cronMatches(job.schedule, now)) runJob(job.schedule, job.command);
}
}
function startScheduler() {
// @reboot:伺服器啟動時執行一次
for (const job of activeJobs()) {
if (job.isReboot) runJob('@reboot', job.command);
}
lastMinuteKey = ''; // 啟動當下這一分鐘也要檢查
setInterval(schedulerTick, 5000);
schedulerTick();
}
// ── 工具 ─────────────────────────────────────────────────
function readCrontabRaw() {
try {
return fs.readFileSync(CRONTAB_FILE, 'utf-8');
} catch {
return '';
}
}
function getCrontabLines(cb) {
cb(readCrontabRaw().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 {
fs.writeFileSync(CRONTAB_FILE, content);
res.json({ success: true });
} catch (e) {
console.error('[applyLines] error:', e.message);
res.status(500).json({ error: e.message });
}
}
// 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) => {
const raw = readCrontabRaw();
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', { windowsHide: true }, (err, stdout) => {
if (err) return res.json({ processes: [], error: err.message });
try {
// pm2 jlist 前面可能夾雜非 JSON 訊息,從第一個 [ 開始取
const jsonStart = stdout.indexOf('[');
const list = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : '[]');
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: [],
}));
const pids = new Set(processes.filter(p => p.pid).map(p => String(p.pid)));
if (pids.size === 0) return res.json({ processes });
// 用 netstat 取得所有 LISTENING port,再依 PID 對應
exec('netstat -ano', { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (e2, netOut) => {
const portMap = {}; // pid → [port, ...]
(netOut || '').split('\n').forEach(line => {
// 格式: TCP 0.0.0.0:3789 0.0.0.0:0 LISTENING 12345
const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
if (!m) return;
const port = m[1];
const pid = m[2];
if (pids.has(pid)) {
if (!portMap[pid]) portMap[pid] = [];
if (!portMap[pid].includes(port)) portMap[pid].push(port);
}
});
processes.forEach(p => {
if (portMap[String(p.pid)]) p.ports = portMap[String(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)}`, { windowsHide: true }, (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) => {
const script = `Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|bun)' } | ` +
`Select-Object ProcessId, CommandLine, ` +
`@{n='MemMB';e={[math]::Round($_.WorkingSetSize/1MB,1)}}, ` +
`@{n='Started';e={if ($_.CreationDate) {$_.CreationDate.ToString('MM/dd HH:mm')} else {'-'}}} | ` +
`ConvertTo-Json -Compress`;
execFile('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
if (err) return res.json({ processes: [], error: err.message });
let list = [];
try {
const parsed = JSON.parse(stdout.trim() || '[]');
list = Array.isArray(parsed) ? parsed : [parsed];
} catch {}
const processes = list.map(p => ({
pid: String(p.ProcessId),
cpu: '-',
mem: `${p.MemMB}MB`,
started: p.Started || '-',
time: '-',
cmd: p.CommandLine || '',
})).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 args = force ? `/F /T /PID ${parseInt(pid)}` : `/PID ${parseInt(pid)}`;
exec(`taskkill ${args}`, { windowsHide: true }, (err, stdout, stderr) => {
if (err) return res.status(500).json({ error: (stderr || stdout || err.message).trim() });
res.json({ success: true });
});
});
// ── GET /api/docker ──────────────────────────────────────
app.get('/api/docker', (req, res) => {
const format = '{{json .}}';
exec(`docker ps -a --format ${JSON.stringify(format)}`, { windowsHide: true }, (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)}`, { windowsHide: true }, (err, stdout) => {
if (err) return res.status(500).json({ error: (stdout || '').trim() || err.message });
res.json({ success: true });
});
});
// ── Log API ───────────────────────────────────────────────
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 (Windows) 已啟動:http://localhost:${PORT}`);
console.log(` 排程檔:${CRONTAB_FILE}`);
console.log(` Log 目錄:${LOG_DIR}`);
startScheduler();
});