Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ab88abbb |
+1
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
logs/
|
||||
.DS_Store
|
||||
crontab.txt
|
||||
*.stackdump
|
||||
|
||||
@@ -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 前端,無框架、無建置步驟。
|
||||
|
||||
> Windows 移植版請見 `master` 分支。
|
||||
|
||||
## 功能
|
||||
|
||||
| 頁籤 | 說明 |
|
||||
|---|---|
|
||||
| ⏰ Crontab | 讀寫**系統 crontab**(`crontab -l` / `crontab <file>`),支援新增、編輯、啟用/停用、刪除、任務備註(label) |
|
||||
| 🟢 PM2 | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 |
|
||||
| 🟡 Node.js | 列出系統上所有 node / bun 程序(`ps aux`),可結束程序(`kill -15` / `kill -9`) |
|
||||
| 🟢 PM2(首頁) | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 |
|
||||
| ⏰ Crontab | 以 cron 語法管理排程任務,支援新增、編輯、啟用/停用、刪除、任務備註(label) |
|
||||
| 🟡 Node.js | 列出系統上所有 node / bun 程序,可結束程序(taskkill) |
|
||||
| 🐳 Docker | 列出容器並可啟動/停止/重啟(需安裝 Docker) |
|
||||
| 📋 Log | 瀏覽 log 目錄下的 `.log` 檔案,支援 tail 與自動刷新 |
|
||||
|
||||
## 安裝與執行
|
||||
|
||||
需求:Node.js 18+、macOS(或其他有 crontab 的 Unix 系統)
|
||||
需求:Node.js 18+、Windows
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -30,10 +28,10 @@ node server.js
|
||||
### 用 PM2 背景常駐(建議)
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
npm install -g pm2 pm2-windows-startup
|
||||
pm2 start server.js --name crontab-ui
|
||||
pm2 save
|
||||
pm2 startup # 依照輸出的指令設定開機自動啟動(launchd)
|
||||
pm2-startup install # 登入 Windows 後自動啟動
|
||||
```
|
||||
|
||||
## 環境變數
|
||||
@@ -41,20 +39,42 @@ pm2 startup # 依照輸出的指令設定開機自動啟動(launchd)
|
||||
| 變數 | 預設值 | 說明 |
|
||||
|---|---|---|
|
||||
| `PORT` | `3789` | 網頁伺服器 port |
|
||||
| `LOG_DIR` | `/Volumes/HDD/Claude/logs` | Log 頁籤讀取的目錄 |
|
||||
| `CRONTAB_FILE` | `<專案目錄>\crontab.txt` | 排程任務儲存檔 |
|
||||
| `LOG_DIR` | `<專案目錄>\logs` | Log 頁籤讀取的目錄,排程執行紀錄 `cron.log` 也寫在這裡 |
|
||||
|
||||
## 運作方式
|
||||
## 排程機制(與 macOS 版的差異)
|
||||
|
||||
- 排程任務由**系統 cron** 執行,本工具只負責讀寫 crontab 內容;伺服器停止不影響排程執行
|
||||
- 任務上方緊鄰的 `#` 註解行會被視為該任務的備註(label),在 UI 中一併顯示與編輯
|
||||
- 停用任務 = 在該行前面加 `#`,啟用 = 移除
|
||||
- 監聽 port 以 `lsof -iTCP -sTCP:LISTEN` 偵測後對應到 PM2 程序
|
||||
Windows 沒有系統 cron,本版本改為:
|
||||
|
||||
- 任務儲存在本地檔案 `crontab.txt`(格式與 crontab 完全相同,含 `#` 停用與備註行)
|
||||
- 伺服器**內建排程器**,每分鐘比對一次並以 `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/
|
||||
├── server.js # Express API(crontab / pm2 / ps / docker / logs)
|
||||
└── public/
|
||||
└── index.html # 前端(單檔,無建置)
|
||||
├── server.js # Express API + 內建 cron 排程器
|
||||
├── public/
|
||||
│ └── index.html # 前端(單檔,無建置)
|
||||
├── crontab.txt # 排程任務(執行時自動建立,不進版控)
|
||||
└── logs/ # log 目錄(不進版控)
|
||||
```
|
||||
|
||||
Generated
+25
-24
@@ -31,9 +31,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -44,7 +44,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -204,9 +204,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -231,14 +231,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -257,7 +257,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -383,9 +383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -578,12 +578,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -694,14 +695,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
|
||||
+8
-7
@@ -340,15 +340,15 @@
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('cron')">⏰ Crontab</button>
|
||||
<button class="tab-btn" onclick="switchTab('pm2')">🟢 PM2</button>
|
||||
<button class="tab-btn active" 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('docker')">🐳 Docker</button>
|
||||
<button class="tab-btn" onclick="switchTab('logs')">📋 Log</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Crontab 分頁 ── -->
|
||||
<div class="tab-panel active" id="tab-cron">
|
||||
<div class="tab-panel" id="tab-cron">
|
||||
<main>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
@@ -378,7 +378,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── PM2 分頁 ── -->
|
||||
<div class="tab-panel" id="tab-pm2">
|
||||
<div class="tab-panel active" id="tab-pm2">
|
||||
<main>
|
||||
<div class="section-header" style="margin-top:4px">
|
||||
<div class="section-title">PM2 進程列表</div>
|
||||
@@ -566,7 +566,7 @@
|
||||
|
||||
<script>
|
||||
let showRaw = false;
|
||||
let currentTab = 'cron';
|
||||
let currentTab = 'pm2';
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const res = await fetch(url, options);
|
||||
@@ -694,10 +694,10 @@
|
||||
const mem = p.memory ? fmtBytes(p.memory) : '-';
|
||||
const status = p.status || 'unknown';
|
||||
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>${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>'
|
||||
}</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('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
|
||||
|
||||
switchTab('pm2');
|
||||
loadCrontab();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
const express = require('express');
|
||||
const { exec } = require('child_process');
|
||||
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')));
|
||||
|
||||
@@ -121,36 +125,136 @@ function describeSchedule(min, hour, day, month, weekday) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// ── 工具 ─────────────────────────────────────────────────
|
||||
function getCrontabLines(cb) {
|
||||
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => {
|
||||
cb((stdout || '').split('\n'));
|
||||
// ── 內建排程器 ────────────────────────────────────────────
|
||||
// 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),回傳允許值 Set;null 表示格式錯誤
|
||||
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 {
|
||||
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);
|
||||
fs.writeFileSync(CRONTAB_FILE, content);
|
||||
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 });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +266,9 @@ function labelToLines(label) {
|
||||
|
||||
// ── GET /api/crontab ──────────────────────────────────────
|
||||
app.get('/api/crontab', (req, res) => {
|
||||
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => {
|
||||
const raw = stdout || '';
|
||||
const raw = readCrontabRaw();
|
||||
res.json({ raw, entries: parseCrontab(raw) });
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/crontab/toggle ──────────────────────────────
|
||||
app.post('/api/crontab/toggle', (req, res) => {
|
||||
@@ -242,10 +344,12 @@ app.post('/api/crontab/add', (req, res) => {
|
||||
|
||||
// ── GET /api/pm2 ─────────────────────────────────────────
|
||||
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 });
|
||||
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 => ({
|
||||
id: p.pm_id,
|
||||
name: p.name,
|
||||
@@ -260,28 +364,26 @@ app.get('/api/pm2', (req, res) => {
|
||||
ports: [],
|
||||
}));
|
||||
|
||||
// 用 lsof 取得所有 LISTEN port,再依 PID 對應
|
||||
const pids = processes.filter(p => p.pid).map(p => p.pid).join(',');
|
||||
if (!pids) return res.json({ processes });
|
||||
const pids = new Set(processes.filter(p => p.pid).map(p => String(p.pid)));
|
||||
if (pids.size === 0) return res.json({ processes });
|
||||
|
||||
exec(`lsof -iTCP -sTCP:LISTEN -nP -p ${pids} 2>/dev/null`, (e2, lsofOut) => {
|
||||
// 解析 lsof 輸出:每行取 PID 和 NAME(*:PORT 或 addr:PORT)
|
||||
// 用 netstat 取得所有 LISTENING port,再依 PID 對應
|
||||
exec('netstat -ano', { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (e2, netOut) => {
|
||||
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\)/);
|
||||
(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 pid = parseInt(m[1]);
|
||||
const port = m[2];
|
||||
if (pid && port) {
|
||||
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[p.pid]) p.ports = portMap[p.pid];
|
||||
if (portMap[String(p.pid)]) p.ports = portMap[String(p.pid)];
|
||||
});
|
||||
res.json({ processes });
|
||||
});
|
||||
@@ -300,7 +402,7 @@ app.post('/api/pm2/:action', (req, res) => {
|
||||
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) => {
|
||||
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 });
|
||||
});
|
||||
@@ -308,18 +410,26 @@ app.post('/api/pm2/:action', (req, res) => {
|
||||
|
||||
// ── 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)); // 排除自己
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -328,9 +438,9 @@ app.get('/api/node', (req, res) => {
|
||||
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 });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -338,8 +448,8 @@ app.post('/api/node/kill', (req, res) => {
|
||||
// ── 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 });
|
||||
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 || '')
|
||||
@@ -374,15 +484,13 @@ app.post('/api/docker/:action', (req, res) => {
|
||||
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 });
|
||||
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 ───────────────────────────────────────────────
|
||||
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([]);
|
||||
@@ -420,5 +528,8 @@ app.use('/api', (req, res) => {
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user