1 Commits
Author SHA1 Message Date
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
5 changed files with 245 additions and 112 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/ node_modules/
logs/ logs/
.DS_Store crontab.txt
*.stackdump *.stackdump
+39 -19
View File
@@ -1,24 +1,22 @@
# Crontab UI (macOS) # Crontab UI (Windows)
網頁版的排程與程序管理面板,直接操作 macOS 的系統 `crontab`,並整合 PM2、Node.js 程序、Docker 與 log 檢視 網頁版的排程與程序管理面板,移植自 macOS crontab-ui,改為在 Windows 上原生執行
單一 Node.js 伺服器(Express+ 單一 HTML 前端,無框架、無建置步驟。 單一 Node.js 伺服器(Express+ 單一 HTML 前端,無框架、無建置步驟。
> Windows 移植版請見 `master` 分支。
## 功能 ## 功能
| 頁籤 | 說明 | | 頁籤 | 說明 |
|---|---| |---|---|
| ⏰ Crontab | 讀寫**系統 crontab**`crontab -l` / `crontab <file>`),支援新增、編輯、啟用/停用、刪除、任務備註(label) | | 🟢 PM2(首頁) | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 |
| 🟢 PM2 | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 | | ⏰ Crontab | 以 cron 語法管理排程任務,支援新增、編輯、啟用/停用、刪除、任務備註(label) |
| 🟡 Node.js | 列出系統上所有 node / bun 程序`ps aux`),可結束程序(`kill -15` / `kill -9` | | 🟡 Node.js | 列出系統上所有 node / bun 程序,可結束程序(taskkill |
| 🐳 Docker | 列出容器並可啟動/停止/重啟(需安裝 Docker) | | 🐳 Docker | 列出容器並可啟動/停止/重啟(需安裝 Docker) |
| 📋 Log | 瀏覽 log 目錄下的 `.log` 檔案,支援 tail 與自動刷新 | | 📋 Log | 瀏覽 log 目錄下的 `.log` 檔案,支援 tail 與自動刷新 |
## 安裝與執行 ## 安裝與執行
需求:Node.js 18+、macOS(或其他有 crontab 的 Unix 系統) 需求:Node.js 18+、Windows
```bash ```bash
npm install npm install
@@ -30,10 +28,10 @@ node server.js
### 用 PM2 背景常駐(建議) ### 用 PM2 背景常駐(建議)
```bash ```bash
npm install -g pm2 npm install -g pm2 pm2-windows-startup
pm2 start server.js --name crontab-ui pm2 start server.js --name crontab-ui
pm2 save pm2 save
pm2 startup # 依照輸出的指令設定開機自動啟動(launchd) pm2-startup install # 登入 Windows 後自動啟動
``` ```
## 環境變數 ## 環境變數
@@ -41,20 +39,42 @@ pm2 startup # 依照輸出的指令設定開機自動啟動(launchd)
| 變數 | 預設值 | 說明 | | 變數 | 預設值 | 說明 |
|---|---|---| |---|---|---|
| `PORT` | `3789` | 網頁伺服器 port | | `PORT` | `3789` | 網頁伺服器 port |
| `LOG_DIR` | `/Volumes/HDD/Claude/logs` | Log 頁籤讀取的目錄 | | `CRONTAB_FILE` | `<專案目錄>\crontab.txt` | 排程任務儲存檔 |
| `LOG_DIR` | `<專案目錄>\logs` | Log 頁籤讀取的目錄,排程執行紀錄 `cron.log` 也寫在這裡 |
## 運作方式 ## 排程機制(與 macOS 版的差異)
- 排程任務由**系統 cron** 執行,本工具只負責讀寫 crontab 內容;伺服器停止不影響排程執行 Windows 沒有系統 cron,本版本改為:
- 任務上方緊鄰的 `#` 註解行會被視為該任務的備註(label),在 UI 中一併顯示與編輯
- 停用任務 = 在該行前面加 `#`,啟用 = 移除 - 任務儲存在本地檔案 `crontab.txt`(格式與 crontab 完全相同,含 `#` 停用與備註行)
- 監聽 port 以 `lsof -iTCP -sTCP:LISTEN` 偵測後對應到 PM2 程序 - 伺服器**內建排程器**,每分鐘比對一次並以 `cmd.exe` 執行到期的指令
- 執行結果(stdout / stderr / 錯誤)寫入 `logs\cron.log`,可直接在 UI 的 Log 頁籤查看
支援的 cron 語法:
- 五欄位:`分 時 日 月 週`,可用 `*`、清單 `1,15`、範圍 `9-17`、步進 `*/5`、組合 `1-5/2`
- 巨集:`@hourly``@daily``@midnight``@weekly``@monthly``@yearly``@annually`
- `@reboot`:改為「伺服器啟動時」執行一次
- 日與週同時指定時,符合其一即執行(標準 cron 行為)
> ⚠️ 排程器跟著伺服器運行:伺服器停止期間的任務不會補跑。請搭配 PM2 常駐。
其他系統層替換:
| 功能 | macOS 版 | Windows 版 |
|---|---|---|
| 排程儲存/執行 | 系統 `crontab` + cron | 本地檔案 + 內建排程器 |
| 監聽 port 偵測 | `lsof` | `netstat -ano` |
| 程序清單 | `ps aux` | PowerShell `Get-CimInstance Win32_Process` |
| 結束程序 | `kill -15` / `-9` | `taskkill` / `taskkill /F /T` |
## 專案結構 ## 專案結構
``` ```
crontab-ui/ crontab-ui/
├── server.js # Express APIcrontab / pm2 / ps / docker / logs ├── server.js # Express API + 內建 cron 排程器
── public/ ── public/
└── index.html # 前端(單檔,無建置) └── index.html # 前端(單檔,無建置)
├── crontab.txt # 排程任務(執行時自動建立,不進版控)
└── logs/ # log 目錄(不進版控)
``` ```
+25 -24
View File
@@ -31,9 +31,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@@ -44,7 +44,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -204,9 +204,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -231,14 +231,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -257,7 +257,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -383,9 +383,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -578,12 +578,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -694,14 +695,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
+8 -7
View File
@@ -340,15 +340,15 @@
</header> </header>
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" onclick="switchTab('cron')">⏰ Crontab</button> <button class="tab-btn active" onclick="switchTab('pm2')">🟢 PM2</button>
<button class="tab-btn" onclick="switchTab('pm2')">🟢 PM2</button> <button class="tab-btn" onclick="switchTab('cron')">⏰ Crontab</button>
<button class="tab-btn" onclick="switchTab('node')">🟡 Node.js</button> <button class="tab-btn" onclick="switchTab('node')">🟡 Node.js</button>
<button class="tab-btn" onclick="switchTab('docker')">🐳 Docker</button> <button class="tab-btn" onclick="switchTab('docker')">🐳 Docker</button>
<button class="tab-btn" onclick="switchTab('logs')">📋 Log</button> <button class="tab-btn" onclick="switchTab('logs')">📋 Log</button>
</div> </div>
<!-- ── Crontab 分頁 ── --> <!-- ── Crontab 分頁 ── -->
<div class="tab-panel active" id="tab-cron"> <div class="tab-panel" id="tab-cron">
<main> <main>
<div class="stats"> <div class="stats">
<div class="stat-card"> <div class="stat-card">
@@ -378,7 +378,7 @@
</div> </div>
<!-- ── PM2 分頁 ── --> <!-- ── PM2 分頁 ── -->
<div class="tab-panel" id="tab-pm2"> <div class="tab-panel active" id="tab-pm2">
<main> <main>
<div class="section-header" style="margin-top:4px"> <div class="section-header" style="margin-top:4px">
<div class="section-title">PM2 進程列表</div> <div class="section-title">PM2 進程列表</div>
@@ -566,7 +566,7 @@
<script> <script>
let showRaw = false; let showRaw = false;
let currentTab = 'cron'; let currentTab = 'pm2';
async function fetchJson(url, options) { async function fetchJson(url, options) {
const res = await fetch(url, options); const res = await fetch(url, options);
@@ -694,10 +694,10 @@
const mem = p.memory ? fmtBytes(p.memory) : '-'; const mem = p.memory ? fmtBytes(p.memory) : '-';
const status = p.status || 'unknown'; const status = p.status || 'unknown';
return `<tr> return `<tr>
<td><strong>${esc(p.name)}</strong><br><span style="font-size:11px;color:#475569">${esc(p.script.split('/').pop())}</span></td> <td><strong>${esc(p.name)}</strong><br><span style="font-size:11px;color:#475569;cursor:help" title="${esc(p.script)}">${esc(p.script.split(/[\\/]/).pop())}</span></td>
<td><span class="badge badge-${status}">${status}</span></td> <td><span class="badge badge-${status}">${status}</span></td>
<td>${p.ports && p.ports.length <td>${p.ports && p.ports.length
? p.ports.map(port => `<a href="http://mac-mini.local:${port}" target="_blank" style="display:inline-block;background:rgba(88,101,242,0.15);color:#5865f2;padding:2px 8px;border-radius:4px;font-size:12px;font-family:monospace;margin:1px;text-decoration:none;cursor:pointer" onmouseover="this.style.background='rgba(88,101,242,0.3)'" onmouseout="this.style.background='rgba(88,101,242,0.15)'">${port}</a>`).join(' ') ? p.ports.map(port => `<a href="http://${location.hostname}:${port}" target="_blank" style="display:inline-block;background:rgba(88,101,242,0.15);color:#5865f2;padding:2px 8px;border-radius:4px;font-size:12px;font-family:monospace;margin:1px;text-decoration:none;cursor:pointer" onmouseover="this.style.background='rgba(88,101,242,0.3)'" onmouseout="this.style.background='rgba(88,101,242,0.15)'">${port}</a>`).join(' ')
: '<span style="color:#475569;font-size:12px">-</span>' : '<span style="color:#475569;font-size:12px">-</span>'
}</td> }</td>
<td style="font-family:monospace;color:#94a3b8">${p.pid || '-'}</td> <td style="font-family:monospace;color:#94a3b8">${p.pid || '-'}</td>
@@ -1195,6 +1195,7 @@
document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEditModal(); }); document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEditModal(); });
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); }); document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
switchTab('pm2');
loadCrontab(); loadCrontab();
</script> </script>
</body> </body>
+172 -61
View File
@@ -1,11 +1,15 @@
const express = require('express'); const express = require('express');
const { exec } = require('child_process'); const { exec, execFile } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const app = express(); const app = express();
const PORT = process.env.PORT || 3789; 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.json());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
@@ -121,36 +125,136 @@ function describeSchedule(min, hour, day, month, weekday) {
return parts.join(' '); return parts.join(' ');
} }
// ── 工具 ───────────────────────────────────────────────── // ── 內建排程器 ────────────────────────────────────────────
function getCrontabLines(cb) { // macro → 5 欄位(@reboot 於伺服器啟動時執行)
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => { const MACRO_MAP = {
cb((stdout || '').split('\n')); '@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) { function applyLines(lines, res) {
const content = lines.join('\n'); const content = lines.join('\n');
console.log('[applyLines] writing crontab:'); console.log('[applyLines] writing crontab:');
lines.forEach((l, i) => console.log(` [${i}] ${JSON.stringify(l)}`)); lines.forEach((l, i) => console.log(` [${i}] ${JSON.stringify(l)}`));
try { try {
const tmpFile = `/tmp/crontab_ui_${Date.now()}.txt`; fs.writeFileSync(CRONTAB_FILE, content);
fs.writeFileSync(tmpFile, content);
const result = require('child_process').execSync(`crontab ${tmpFile} 2>&1`).toString();
fs.unlinkSync(tmpFile);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } 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); console.error('[applyLines] error:', e.message);
if (fileContent) console.error('[applyLines] file content:\n' + fileContent); res.status(500).json({ error: e.message });
res.status(500).json({ error: e.message, fileContent });
} }
} }
@@ -162,10 +266,8 @@ function labelToLines(label) {
// ── GET /api/crontab ────────────────────────────────────── // ── GET /api/crontab ──────────────────────────────────────
app.get('/api/crontab', (req, res) => { app.get('/api/crontab', (req, res) => {
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => { const raw = readCrontabRaw();
const raw = stdout || ''; res.json({ raw, entries: parseCrontab(raw) });
res.json({ raw, entries: parseCrontab(raw) });
});
}); });
// ── POST /api/crontab/toggle ────────────────────────────── // ── POST /api/crontab/toggle ──────────────────────────────
@@ -242,10 +344,12 @@ app.post('/api/crontab/add', (req, res) => {
// ── GET /api/pm2 ───────────────────────────────────────── // ── GET /api/pm2 ─────────────────────────────────────────
app.get('/api/pm2', (req, res) => { app.get('/api/pm2', (req, res) => {
exec('pm2 jlist 2>/dev/null', (err, stdout) => { exec('pm2 jlist', { windowsHide: true }, (err, stdout) => {
if (err) return res.json({ processes: [], error: err.message }); if (err) return res.json({ processes: [], error: err.message });
try { try {
const list = JSON.parse(stdout || '[]'); // pm2 jlist 前面可能夾雜非 JSON 訊息,從第一個 [ 開始取
const jsonStart = stdout.indexOf('[');
const list = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : '[]');
const processes = list.map(p => ({ const processes = list.map(p => ({
id: p.pm_id, id: p.pm_id,
name: p.name, name: p.name,
@@ -260,28 +364,26 @@ app.get('/api/pm2', (req, res) => {
ports: [], ports: [],
})); }));
// 用 lsof 取得所有 LISTEN port,再依 PID 對應 const pids = new Set(processes.filter(p => p.pid).map(p => String(p.pid)));
const pids = processes.filter(p => p.pid).map(p => p.pid).join(','); if (pids.size === 0) return res.json({ processes });
if (!pids) return res.json({ processes });
exec(`lsof -iTCP -sTCP:LISTEN -nP -p ${pids} 2>/dev/null`, (e2, lsofOut) => { // 用 netstat 取得所有 LISTENING port,再依 PID 對應
// 解析 lsof 輸出:每行取 PID 和 NAME*:PORT 或 addr:PORT exec('netstat -ano', { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (e2, netOut) => {
const portMap = {}; // pid → [port, ...] const portMap = {}; // pid → [port, ...]
(lsofOut || '').split('\n').forEach(line => { (netOut || '').split('\n').forEach(line => {
// 格式: COMMAND PID USER ... NAME (LISTEN) // 格式: TCP 0.0.0.0:3789 0.0.0.0:0 LISTENING 12345
// NAME 可能是 *:3000 或 localhost:3000,後面跟著 (LISTEN) const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
const m = line.match(/\s+(\d+)\s+\S+\s+.*?:(\d+)\s+\(LISTEN\)/);
if (!m) return; if (!m) return;
const pid = parseInt(m[1]); const port = m[1];
const port = m[2]; const pid = m[2];
if (pid && port) { if (pids.has(pid)) {
if (!portMap[pid]) portMap[pid] = []; if (!portMap[pid]) portMap[pid] = [];
if (!portMap[pid].includes(port)) portMap[pid].push(port); if (!portMap[pid].includes(port)) portMap[pid].push(port);
} }
}); });
processes.forEach(p => { processes.forEach(p => {
if (portMap[p.pid]) p.ports = portMap[p.pid]; if (portMap[String(p.pid)]) p.ports = portMap[String(p.pid)];
}); });
res.json({ processes }); res.json({ processes });
}); });
@@ -300,7 +402,7 @@ app.post('/api/pm2/:action', (req, res) => {
const allowed = ['start', 'stop', 'restart', 'delete']; const allowed = ['start', 'stop', 'restart', 'delete'];
if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' }); if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' });
exec(`pm2 ${action} ${JSON.stringify(name)} 2>&1`, (err, stdout) => { exec(`pm2 ${action} ${JSON.stringify(name)}`, { windowsHide: true }, (err, stdout) => {
if (err) return res.status(500).json({ error: stdout || err.message }); if (err) return res.status(500).json({ error: stdout || err.message });
res.json({ success: true }); res.json({ success: true });
}); });
@@ -308,18 +410,26 @@ app.post('/api/pm2/:action', (req, res) => {
// ── GET /api/node ───────────────────────────────────────── // ── GET /api/node ─────────────────────────────────────────
app.get('/api/node', (req, res) => { app.get('/api/node', (req, res) => {
exec("ps aux | grep -E '[n]ode|[b]un' | grep -v grep", (err, stdout) => { const script = `Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|bun)' } | ` +
const lines = (stdout || '').trim().split('\n').filter(Boolean); `Select-Object ProcessId, CommandLine, ` +
const processes = lines.map(line => { `@{n='MemMB';e={[math]::Round($_.WorkingSetSize/1MB,1)}}, ` +
const cols = line.trim().split(/\s+/); `@{n='Started';e={if ($_.CreationDate) {$_.CreationDate.ToString('MM/dd HH:mm')} else {'-'}}} | ` +
const pid = cols[1]; `ConvertTo-Json -Compress`;
const cpu = cols[2]; execFile('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
const mem = cols[3]; if (err) return res.json({ processes: [], error: err.message });
const started = cols[8]; let list = [];
const time = cols[9]; try {
const cmd = cols.slice(10).join(' '); const parsed = JSON.parse(stdout.trim() || '[]');
return { pid, cpu, mem, started, time, cmd }; list = Array.isArray(parsed) ? parsed : [parsed];
}).filter(p => p.pid && p.pid !== String(process.pid)); // 排除自己 } 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 }); res.json({ processes });
}); });
}); });
@@ -328,9 +438,9 @@ app.get('/api/node', (req, res) => {
app.post('/api/node/kill', (req, res) => { app.post('/api/node/kill', (req, res) => {
const { pid, force } = req.body; const { pid, force } = req.body;
if (!pid) return res.status(400).json({ error: 'pid required' }); if (!pid) return res.status(400).json({ error: 'pid required' });
const sig = force ? '-9' : '-15'; const args = force ? `/F /T /PID ${parseInt(pid)}` : `/PID ${parseInt(pid)}`;
exec(`kill ${sig} ${parseInt(pid)} 2>&1`, (err, stdout) => { exec(`taskkill ${args}`, { windowsHide: true }, (err, stdout, stderr) => {
if (err) return res.status(500).json({ error: stdout || err.message }); if (err) return res.status(500).json({ error: (stderr || stdout || err.message).trim() });
res.json({ success: true }); res.json({ success: true });
}); });
}); });
@@ -338,8 +448,8 @@ app.post('/api/node/kill', (req, res) => {
// ── GET /api/docker ────────────────────────────────────── // ── GET /api/docker ──────────────────────────────────────
app.get('/api/docker', (req, res) => { app.get('/api/docker', (req, res) => {
const format = '{{json .}}'; const format = '{{json .}}';
exec(`docker ps -a --format ${JSON.stringify(format)} 2>&1`, (err, stdout) => { exec(`docker ps -a --format ${JSON.stringify(format)}`, { windowsHide: true }, (err, stdout) => {
if (err) return res.json({ containers: [], error: stdout.trim() || err.message }); if (err) return res.json({ containers: [], error: (stdout || '').trim() || err.message });
try { try {
const containers = (stdout || '') const containers = (stdout || '')
@@ -374,15 +484,13 @@ app.post('/api/docker/:action', (req, res) => {
if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' }); if (!allowed.includes(action)) return res.status(400).json({ error: 'invalid action' });
if (!id) return res.status(400).json({ error: 'id required' }); if (!id) return res.status(400).json({ error: 'id required' });
exec(`docker ${action} ${JSON.stringify(id)} 2>&1`, (err, stdout) => { exec(`docker ${action} ${JSON.stringify(id)}`, { windowsHide: true }, (err, stdout) => {
if (err) return res.status(500).json({ error: stdout.trim() || err.message }); if (err) return res.status(500).json({ error: (stdout || '').trim() || err.message });
res.json({ success: true }); res.json({ success: true });
}); });
}); });
// ── Log API ─────────────────────────────────────────────── // ── Log API ───────────────────────────────────────────────
const LOG_DIR = process.env.LOG_DIR || '/Volumes/HDD/Claude/logs';
app.get('/api/logs', (req, res) => { app.get('/api/logs', (req, res) => {
try { try {
if (!fs.existsSync(LOG_DIR)) return res.json([]); if (!fs.existsSync(LOG_DIR)) return res.json([]);
@@ -420,5 +528,8 @@ app.use('/api', (req, res) => {
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`✅ Crontab UI 已啟動:http://localhost:${PORT}`); console.log(`✅ Crontab UI (Windows) 已啟動:http://localhost:${PORT}`);
console.log(` 排程檔:${CRONTAB_FILE}`);
console.log(` Log 目錄:${LOG_DIR}`);
startScheduler();
}); });