Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc6a8758d |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
logs/
|
logs/
|
||||||
crontab.txt
|
.DS_Store
|
||||||
*.stackdump
|
*.stackdump
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
# Crontab UI (Windows)
|
# Crontab UI (macOS)
|
||||||
|
|
||||||
網頁版的排程與程序管理面板,移植自 macOS 版 crontab-ui,改為在 Windows 上原生執行。
|
網頁版的排程與程序管理面板,直接操作 macOS 的系統 `crontab`,並整合 PM2、Node.js 程序、Docker 與 log 檢視。
|
||||||
|
|
||||||
單一 Node.js 伺服器(Express)+ 單一 HTML 前端,無框架、無建置步驟。
|
單一 Node.js 伺服器(Express)+ 單一 HTML 前端,無框架、無建置步驟。
|
||||||
|
|
||||||
|
> Windows 移植版請見 `master` 分支。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
| 頁籤 | 說明 |
|
| 頁籤 | 說明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 🟢 PM2(首頁) | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 |
|
| ⏰ Crontab | 讀寫**系統 crontab**(`crontab -l` / `crontab <file>`),支援新增、編輯、啟用/停用、刪除、任務備註(label) |
|
||||||
| ⏰ Crontab | 以 cron 語法管理排程任務,支援新增、編輯、啟用/停用、刪除、任務備註(label) |
|
| 🟢 PM2 | 列出 PM2 程序(狀態、PID、CPU、記憶體、重啟次數、監聽 port),可啟動/停止/重啟/刪除 |
|
||||||
| 🟡 Node.js | 列出系統上所有 node / bun 程序,可結束程序(taskkill) |
|
| 🟡 Node.js | 列出系統上所有 node / bun 程序(`ps aux`),可結束程序(`kill -15` / `kill -9`) |
|
||||||
| 🐳 Docker | 列出容器並可啟動/停止/重啟(需安裝 Docker) |
|
| 🐳 Docker | 列出容器並可啟動/停止/重啟(需安裝 Docker) |
|
||||||
| 📋 Log | 瀏覽 log 目錄下的 `.log` 檔案,支援 tail 與自動刷新 |
|
| 📋 Log | 瀏覽 log 目錄下的 `.log` 檔案,支援 tail 與自動刷新 |
|
||||||
|
|
||||||
## 安裝與執行
|
## 安裝與執行
|
||||||
|
|
||||||
需求:Node.js 18+、Windows
|
需求:Node.js 18+、macOS(或其他有 crontab 的 Unix 系統)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -28,10 +30,10 @@ node server.js
|
|||||||
### 用 PM2 背景常駐(建議)
|
### 用 PM2 背景常駐(建議)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g pm2 pm2-windows-startup
|
npm install -g pm2
|
||||||
pm2 start server.js --name crontab-ui
|
pm2 start server.js --name crontab-ui
|
||||||
pm2 save
|
pm2 save
|
||||||
pm2-startup install # 登入 Windows 後自動啟動
|
pm2 startup # 依照輸出的指令設定開機自動啟動(launchd)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 環境變數
|
## 環境變數
|
||||||
@@ -39,42 +41,20 @@ pm2-startup install # 登入 Windows 後自動啟動
|
|||||||
| 變數 | 預設值 | 說明 |
|
| 變數 | 預設值 | 說明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `3789` | 網頁伺服器 port |
|
| `PORT` | `3789` | 網頁伺服器 port |
|
||||||
| `CRONTAB_FILE` | `<專案目錄>\crontab.txt` | 排程任務儲存檔 |
|
| `LOG_DIR` | `/Volumes/HDD/Claude/logs` | Log 頁籤讀取的目錄 |
|
||||||
| `LOG_DIR` | `<專案目錄>\logs` | Log 頁籤讀取的目錄,排程執行紀錄 `cron.log` 也寫在這裡 |
|
|
||||||
|
|
||||||
## 排程機制(與 macOS 版的差異)
|
## 運作方式
|
||||||
|
|
||||||
Windows 沒有系統 cron,本版本改為:
|
- 排程任務由**系統 cron** 執行,本工具只負責讀寫 crontab 內容;伺服器停止不影響排程執行
|
||||||
|
- 任務上方緊鄰的 `#` 註解行會被視為該任務的備註(label),在 UI 中一併顯示與編輯
|
||||||
- 任務儲存在本地檔案 `crontab.txt`(格式與 crontab 完全相同,含 `#` 停用與備註行)
|
- 停用任務 = 在該行前面加 `#`,啟用 = 移除
|
||||||
- 伺服器**內建排程器**,每分鐘比對一次並以 `cmd.exe` 執行到期的指令
|
- 監聽 port 以 `lsof -iTCP -sTCP:LISTEN` 偵測後對應到 PM2 程序
|
||||||
- 執行結果(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 API + 內建 cron 排程器
|
├── server.js # Express API(crontab / pm2 / ps / docker / logs)
|
||||||
├── public/
|
└── public/
|
||||||
│ └── index.html # 前端(單檔,無建置)
|
└── index.html # 前端(單檔,無建置)
|
||||||
├── crontab.txt # 排程任務(執行時自動建立,不進版控)
|
|
||||||
└── logs/ # log 目錄(不進版控)
|
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+24
-25
@@ -31,9 +31,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.6",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||||
"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.15.1",
|
"qs": "~6.14.0",
|
||||||
"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.2",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"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.2",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"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.5",
|
"body-parser": "~1.20.3",
|
||||||
"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.15.1",
|
"qs": "~6.14.0",
|
||||||
"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.4",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -578,13 +578,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.1",
|
"side-channel": "^1.1.0"
|
||||||
"side-channel": "^1.1.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -695,14 +694,14 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.4",
|
"object-inspect": "^1.13.3",
|
||||||
"side-channel-list": "^1.0.1",
|
"side-channel-list": "^1.0.0",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-8
@@ -340,15 +340,15 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" onclick="switchTab('pm2')">🟢 PM2</button>
|
<button class="tab-btn active" onclick="switchTab('cron')">⏰ Crontab</button>
|
||||||
<button class="tab-btn" onclick="switchTab('cron')">⏰ Crontab</button>
|
<button class="tab-btn" onclick="switchTab('pm2')">🟢 PM2</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" id="tab-cron">
|
<div class="tab-panel active" 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 active" id="tab-pm2">
|
<div class="tab-panel" 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 = 'pm2';
|
let currentTab = 'cron';
|
||||||
|
|
||||||
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;cursor:help" title="${esc(p.script)}">${esc(p.script.split(/[\\/]/).pop())}</span></td>
|
<td><strong>${esc(p.name)}</strong><br><span style="font-size:11px;color:#475569">${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://${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(' ')
|
? 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(' ')
|
||||||
: '<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,7 +1195,6 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { exec, execFile } = require('child_process');
|
const { exec } = 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')));
|
||||||
|
|
||||||
@@ -125,123 +121,11 @@ function describeSchedule(min, hour, day, month, weekday) {
|
|||||||
return parts.join(' ');
|
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),回傳允許值 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) {
|
function getCrontabLines(cb) {
|
||||||
cb(readCrontabRaw().split('\n'));
|
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => {
|
||||||
|
cb((stdout || '').split('\n'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLines(lines, res) {
|
function applyLines(lines, res) {
|
||||||
@@ -250,11 +134,23 @@ function applyLines(lines, res) {
|
|||||||
lines.forEach((l, i) => console.log(` [${i}] ${JSON.stringify(l)}`));
|
lines.forEach((l, i) => console.log(` [${i}] ${JSON.stringify(l)}`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(CRONTAB_FILE, content);
|
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 });
|
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);
|
||||||
res.status(500).json({ error: e.message });
|
if (fileContent) console.error('[applyLines] file content:\n' + fileContent);
|
||||||
|
res.status(500).json({ error: e.message, fileContent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,8 +162,10 @@ function labelToLines(label) {
|
|||||||
|
|
||||||
// ── GET /api/crontab ──────────────────────────────────────
|
// ── GET /api/crontab ──────────────────────────────────────
|
||||||
app.get('/api/crontab', (req, res) => {
|
app.get('/api/crontab', (req, res) => {
|
||||||
const raw = readCrontabRaw();
|
exec('crontab -l 2>/dev/null || echo ""', (err, stdout) => {
|
||||||
res.json({ raw, entries: parseCrontab(raw) });
|
const raw = stdout || '';
|
||||||
|
res.json({ raw, entries: parseCrontab(raw) });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST /api/crontab/toggle ──────────────────────────────
|
// ── POST /api/crontab/toggle ──────────────────────────────
|
||||||
@@ -344,12 +242,10 @@ 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', { windowsHide: true }, (err, stdout) => {
|
exec('pm2 jlist 2>/dev/null', (err, stdout) => {
|
||||||
if (err) return res.json({ processes: [], error: err.message });
|
if (err) return res.json({ processes: [], error: err.message });
|
||||||
try {
|
try {
|
||||||
// pm2 jlist 前面可能夾雜非 JSON 訊息,從第一個 [ 開始取
|
const list = JSON.parse(stdout || '[]');
|
||||||
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,
|
||||||
@@ -364,26 +260,28 @@ app.get('/api/pm2', (req, res) => {
|
|||||||
ports: [],
|
ports: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pids = new Set(processes.filter(p => p.pid).map(p => String(p.pid)));
|
// 用 lsof 取得所有 LISTEN port,再依 PID 對應
|
||||||
if (pids.size === 0) return res.json({ processes });
|
const pids = processes.filter(p => p.pid).map(p => p.pid).join(',');
|
||||||
|
if (!pids) return res.json({ processes });
|
||||||
|
|
||||||
// 用 netstat 取得所有 LISTENING port,再依 PID 對應
|
exec(`lsof -iTCP -sTCP:LISTEN -nP -p ${pids} 2>/dev/null`, (e2, lsofOut) => {
|
||||||
exec('netstat -ano', { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (e2, netOut) => {
|
// 解析 lsof 輸出:每行取 PID 和 NAME(*:PORT 或 addr:PORT)
|
||||||
const portMap = {}; // pid → [port, ...]
|
const portMap = {}; // pid → [port, ...]
|
||||||
(netOut || '').split('\n').forEach(line => {
|
(lsofOut || '').split('\n').forEach(line => {
|
||||||
// 格式: TCP 0.0.0.0:3789 0.0.0.0:0 LISTENING 12345
|
// 格式: COMMAND PID USER ... NAME (LISTEN)
|
||||||
const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
|
// NAME 可能是 *:3000 或 localhost:3000,後面跟著 (LISTEN)
|
||||||
|
const m = line.match(/\s+(\d+)\s+\S+\s+.*?:(\d+)\s+\(LISTEN\)/);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
const port = m[1];
|
const pid = parseInt(m[1]);
|
||||||
const pid = m[2];
|
const port = m[2];
|
||||||
if (pids.has(pid)) {
|
if (pid && port) {
|
||||||
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[String(p.pid)]) p.ports = portMap[String(p.pid)];
|
if (portMap[p.pid]) p.ports = portMap[p.pid];
|
||||||
});
|
});
|
||||||
res.json({ processes });
|
res.json({ processes });
|
||||||
});
|
});
|
||||||
@@ -402,7 +300,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)}`, { windowsHide: true }, (err, stdout) => {
|
exec(`pm2 ${action} ${JSON.stringify(name)} 2>&1`, (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 });
|
||||||
});
|
});
|
||||||
@@ -410,26 +308,18 @@ 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) => {
|
||||||
const script = `Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|bun)' } | ` +
|
exec("ps aux | grep -E '[n]ode|[b]un' | grep -v grep", (err, stdout) => {
|
||||||
`Select-Object ProcessId, CommandLine, ` +
|
const lines = (stdout || '').trim().split('\n').filter(Boolean);
|
||||||
`@{n='MemMB';e={[math]::Round($_.WorkingSetSize/1MB,1)}}, ` +
|
const processes = lines.map(line => {
|
||||||
`@{n='Started';e={if ($_.CreationDate) {$_.CreationDate.ToString('MM/dd HH:mm')} else {'-'}}} | ` +
|
const cols = line.trim().split(/\s+/);
|
||||||
`ConvertTo-Json -Compress`;
|
const pid = cols[1];
|
||||||
execFile('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
const cpu = cols[2];
|
||||||
if (err) return res.json({ processes: [], error: err.message });
|
const mem = cols[3];
|
||||||
let list = [];
|
const started = cols[8];
|
||||||
try {
|
const time = cols[9];
|
||||||
const parsed = JSON.parse(stdout.trim() || '[]');
|
const cmd = cols.slice(10).join(' ');
|
||||||
list = Array.isArray(parsed) ? parsed : [parsed];
|
return { pid, cpu, mem, started, time, cmd };
|
||||||
} catch {}
|
}).filter(p => p.pid && p.pid !== String(process.pid)); // 排除自己
|
||||||
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -438,9 +328,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 args = force ? `/F /T /PID ${parseInt(pid)}` : `/PID ${parseInt(pid)}`;
|
const sig = force ? '-9' : '-15';
|
||||||
exec(`taskkill ${args}`, { windowsHide: true }, (err, stdout, stderr) => {
|
exec(`kill ${sig} ${parseInt(pid)} 2>&1`, (err, stdout) => {
|
||||||
if (err) return res.status(500).json({ error: (stderr || stdout || err.message).trim() });
|
if (err) return res.status(500).json({ error: stdout || err.message });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -448,8 +338,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)}`, { windowsHide: true }, (err, stdout) => {
|
exec(`docker ps -a --format ${JSON.stringify(format)} 2>&1`, (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 || '')
|
||||||
@@ -484,13 +374,15 @@ 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)}`, { windowsHide: true }, (err, stdout) => {
|
exec(`docker ${action} ${JSON.stringify(id)} 2>&1`, (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([]);
|
||||||
@@ -528,8 +420,5 @@ app.use('/api', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`✅ Crontab UI (Windows) 已啟動:http://localhost:${PORT}`);
|
console.log(`✅ Crontab UI 已啟動:http://localhost:${PORT}`);
|
||||||
console.log(` 排程檔:${CRONTAB_FILE}`);
|
|
||||||
console.log(` Log 目錄:${LOG_DIR}`);
|
|
||||||
startScheduler();
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user