Files
crontab-ui/public/index.html
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

1203 lines
45 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>程序管理</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231a1d27'/%3E%3Ccircle cx='16' cy='16' r='5' fill='none' stroke='%235865f2' stroke-width='2.2'/%3E%3Cpath d='M16 4v3M16 25v3M4 16h3M25 16h3M7.5 7.5l2.1 2.1M22.4 22.4l2.1 2.1M7.5 24.5l2.1-2.1M22.4 9.6l2.1-2.1' stroke='%235865f2' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
}
header {
background: #1a1d27;
border-bottom: 1px solid #2d3148;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
header h1 span { font-size: 20px; margin-right: 8px; }
.header-actions { display: flex; gap: 10px; align-items: center; }
.btn {
padding: 7px 14px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover { filter: brightness(1.15); }
.btn:active { transform: scale(0.97); }
.btn-primary { background: #5865f2; color: #fff; }
.btn-danger { background: #ed4245; color: #fff; }
.btn-success { background: #3ba55d; color: #fff; }
.btn-warning { background: #f59e0b; color: #000; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #2d3148; }
.btn-ghost:hover { background: #1e2130; color: #e2e8f0; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
/* ── Tabs ── */
.tabs {
background: #1a1d27;
border-bottom: 1px solid #2d3148;
display: flex;
padding: 0 24px;
gap: 0;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: none;
color: #64748b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover { color: #e2e8f0; }
.tab-btn.active { color: #5865f2; border-bottom-color: #5865f2; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
main { max-width: 1000px; margin: 0 auto; padding: 24px 16px; }
.stats { display: flex; gap: 12px; margin-bottom: 20px; }
.stat-card {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 10px;
padding: 14px 20px;
flex: 1;
text-align: center;
}
.stat-card .num { font-size: 26px; font-weight: 700; color: #5865f2; }
.stat-card .label { font-size: 12px; color: #64748b; margin-top: 2px; }
.stat-card.active .num { color: #3ba55d; }
.stat-card.disabled .num { color: #94a3b8; }
.jobs-list { display: flex; flex-direction: column; gap: 10px; }
.job-card {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 12px;
padding: 16px 18px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 14px;
align-items: center;
transition: border-color 0.15s;
}
.job-card:hover { border-color: #4a5180; }
.job-card.disabled { opacity: 0.5; }
.job-card.comment {
background: #151820;
border-style: dashed;
grid-template-columns: 1fr auto;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: #3ba55d;
flex-shrink: 0;
}
.job-card.disabled .status-dot { background: #64748b; }
.job-info { min-width: 0; }
.job-label {
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 5px;
white-space: pre-line;
line-height: 1.5;
}
.job-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; }
.job-schedule {
font-size: 11px;
color: #5865f2;
font-family: 'SF Mono', 'Fira Code', monospace;
background: rgba(88, 101, 242, 0.12);
padding: 2px 8px;
border-radius: 4px;
}
.job-schedule-desc {
font-size: 12px;
color: #64748b;
}
.job-command {
font-size: 12px;
color: #94a3b8;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-text {
font-size: 13px;
color: #475569;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.job-actions { display: flex; gap: 8px; flex-shrink: 0; }
.empty-state { text-align: center; padding: 60px 20px; color: #475569; }
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 100;
opacity: 0; pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 14px;
padding: 24px;
width: 100%;
max-width: 560px;
transform: translateY(20px);
transition: transform 0.2s;
}
.modal-overlay.open .modal { transform: translateY(0); }
.modal h2 { font-size: 16px; font-weight: 600; margin-bottom: 18px; }
.form-group { margin-bottom: 14px; }
.form-group label {
display: block; font-size: 12px; color: #94a3b8;
margin-bottom: 6px; font-weight: 500;
}
.form-group input, .form-group textarea {
width: 100%;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 8px;
padding: 9px 12px;
color: #e2e8f0;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
resize: vertical;
}
.form-group input.mono { font-family: 'SF Mono', 'Fira Code', monospace; }
.form-group input:focus, .form-group textarea:focus { border-color: #5865f2; }
.schedule-helper {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-bottom: 10px;
}
.schedule-helper button {
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px;
padding: 6px; color: #94a3b8; font-size: 11px; cursor: pointer;
transition: all 0.15s; text-align: center;
}
.schedule-helper button:hover { border-color: #5865f2; color: #5865f2; }
.schedule-preview {
margin-top: 8px;
padding: 8px 12px;
border-radius: 7px;
font-size: 13px;
min-height: 34px;
display: flex;
align-items: center;
gap: 6px;
}
.schedule-preview.valid { background: rgba(59,165,93,0.1); color: #3ba55d; border: 1px solid rgba(59,165,93,0.25); }
.schedule-preview.invalid { background: rgba(237,66,69,0.1); color: #ed4245; border: 1px solid rgba(237,66,69,0.25); }
.schedule-preview.empty { background: #151820; color: #475569; border: 1px solid #2d3148; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.raw-view {
background: #0f1117; border: 1px solid #2d3148; border-radius: 10px;
padding: 16px; font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px; color: #94a3b8; white-space: pre; overflow-x: auto;
margin-top: 20px; display: none;
}
.raw-view.visible { display: block; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-title { font-size: 14px; font-weight: 600; color: #94a3b8; }
/* ── PM2 / Node 表格 ── */
.process-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.process-table th {
text-align: left;
padding: 10px 14px;
color: #64748b;
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #2d3148;
}
.process-table td {
padding: 12px 14px;
border-bottom: 1px solid #1e2130;
vertical-align: middle;
}
.process-table tr:hover td { background: #1e2130; }
.process-table tr:last-child td { border-bottom: none; }
.table-wrap {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 12px;
overflow: hidden;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.badge::before { content: '●'; font-size: 8px; }
.badge-online { background: rgba(59,165,93,0.15); color: #3ba55d; }
.badge-stopped { background: rgba(100,116,139,0.15); color: #64748b; }
.badge-error, .badge-errored { background: rgba(237,66,69,0.15); color: #ed4245; }
.badge-launching { background: rgba(88,101,242,0.15); color: #5865f2; }
.badge-running { background: rgba(59,165,93,0.15); color: #3ba55d; }
.badge-exited, .badge-dead { background: rgba(100,116,139,0.15); color: #64748b; }
.badge-created, .badge-restarting, .badge-paused { background: rgba(88,101,242,0.15); color: #5865f2; }
.cmd-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
color: #94a3b8;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast {
position: fixed; bottom: 24px; right: 24px;
background: #1e2130; border: 1px solid #2d3148;
border-radius: 10px; padding: 12px 18px;
font-size: 13px; color: #e2e8f0;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
transform: translateY(20px); opacity: 0;
transition: all 0.3s; z-index: 200;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { border-color: #3ba55d; }
.toast.error { border-color: #ed4245; }
</style>
</head>
<body>
<header>
<h1><span></span> 程序管理</h1>
<div class="header-actions" id="header-actions">
<button class="btn btn-ghost btn-sm" onclick="toggleRaw()">📄 原始檢視</button>
<button class="btn btn-primary btn-sm" onclick="openAddModal()"> 新增任務</button>
<button class="btn btn-ghost btn-sm" id="btn-refresh" onclick="refreshCurrent()">🔄 重新整理</button>
</div>
</header>
<div class="tabs">
<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" id="tab-cron">
<main>
<div class="stats">
<div class="stat-card">
<div class="num" id="stat-total">-</div>
<div class="label">總任務數</div>
</div>
<div class="stat-card active">
<div class="num" id="stat-active">-</div>
<div class="label">啟用中</div>
</div>
<div class="stat-card disabled">
<div class="num" id="stat-disabled">-</div>
<div class="label">已停用</div>
</div>
</div>
<div class="section-header">
<div class="section-title">所有排程任務</div>
</div>
<div class="jobs-list" id="jobs-list">
<div class="empty-state"><div class="icon"></div><p>載入中...</p></div>
</div>
<pre class="raw-view" id="raw-view"></pre>
</main>
</div>
<!-- ── PM2 分頁 ── -->
<div class="tab-panel active" id="tab-pm2">
<main>
<div class="section-header" style="margin-top:4px">
<div class="section-title">PM2 進程列表</div>
</div>
<div class="table-wrap">
<table class="process-table">
<thead>
<tr>
<th>名稱</th>
<th>狀態</th>
<th>Port</th>
<th>PID</th>
<th>CPU</th>
<th>記憶體</th>
<th>重啟</th>
<th>運行時間</th>
<th>操作</th>
</tr>
</thead>
<tbody id="pm2-tbody">
<tr><td colspan="9" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>
</tbody>
</table>
</div>
</main>
</div>
<!-- ── Node.js 分頁 ── -->
<div class="tab-panel" id="tab-node">
<main>
<div class="section-header" style="margin-top:4px">
<div class="section-title">Node.js 進程列表</div>
</div>
<div class="table-wrap">
<table class="process-table">
<thead>
<tr>
<th>PID</th>
<th>CPU</th>
<th>記憶體</th>
<th>啟動時間</th>
<th>指令</th>
<th>操作</th>
</tr>
</thead>
<tbody id="node-tbody">
<tr><td colspan="6" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>
</tbody>
</table>
</div>
</main>
</div>
<!-- ── Docker 分頁 ── -->
<div class="tab-panel" id="tab-docker">
<main>
<div class="section-header" style="margin-top:4px">
<div class="section-title">Docker 容器列表</div>
</div>
<div class="table-wrap">
<table class="process-table">
<thead>
<tr>
<th>名稱</th>
<th>狀態</th>
<th>映像</th>
<th>Port</th>
<th>容器 ID</th>
<th>建立 / 執行</th>
<th>操作</th>
</tr>
</thead>
<tbody id="docker-tbody">
<tr><td colspan="7" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>
</tbody>
</table>
</div>
</main>
</div>
<!-- ── Log 分頁 ── -->
<div class="tab-panel" id="tab-logs">
<main>
<div class="section-header" style="margin-top:4px">
<div class="section-title">Log 檔案</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="log-file-select" style="background:#1e2130;color:#e2e8f0;border:1px solid #2d3550;border-radius:6px;padding:4px 8px;font-size:13px" onchange="loadLogContent()">
<option value="">選擇 Log 檔案...</option>
</select>
<button class="btn-icon" onclick="loadLogContent()" title="重新整理">🔄</button>
</div>
</div>
<div id="log-content-wrap" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span id="log-meta" style="font-size:12px;color:#64748b"></span>
<label style="font-size:12px;color:#94a3b8;display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="log-auto-refresh" onchange="toggleAutoRefresh()" style="cursor:pointer"> 自動更新(5s
</label>
</div>
<pre id="log-content" style="background:#0d1117;border:1px solid #2d3550;border-radius:8px;padding:16px;font-size:12px;color:#e2e8f0;overflow:auto;max-height:520px;white-space:pre-wrap;word-break:break-all;line-height:1.6"></pre>
</div>
<div id="log-empty" style="text-align:center;color:#475569;padding:40px">選擇左上角的 Log 檔案查看內容</div>
</main>
</div>
<!-- Edit Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<h2>✏️ 編輯排程任務</h2>
<div class="form-group">
<label>任務描述(選填,支援換行)</label>
<textarea id="edit-label" rows="3" autocomplete="off" placeholder="例:每天備份資料庫&#10;- 備份 MySQL&#10;- 壓縮並上傳 S3"></textarea>
</div>
<div class="form-group">
<label>快速套用排程</label>
<div class="schedule-helper">
<button onclick="setField('edit-schedule','@reboot')">開機執行</button>
<button onclick="setField('edit-schedule','0 * * * *')">每小時</button>
<button onclick="setField('edit-schedule','0 0 * * *')">每天午夜</button>
<button onclick="setField('edit-schedule','0 9 * * 1-5')">平日早9</button>
<button onclick="setField('edit-schedule','0 0 * * 0')">每週日</button>
</div>
</div>
<div class="form-group">
<label>Cron 排程表達式</label>
<input type="text" id="edit-schedule" class="mono" placeholder="0 * * * *" oninput="updatePreview('edit')" />
<div class="schedule-preview empty" id="edit-preview">輸入排程表達式後顯示說明</div>
</div>
<div class="form-group">
<label>指令</label>
<input type="text" id="edit-command" class="mono" placeholder="/path/to/script.sh >> /tmp/log.txt 2>&1" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeEditModal()">取消</button>
<button class="btn btn-warning" onclick="saveEdit()">儲存</button>
</div>
</div>
</div>
<!-- Add Modal -->
<div class="modal-overlay" id="add-modal">
<div class="modal">
<h2> 新增 Cron 任務</h2>
<div class="form-group">
<label>任務描述(選填,支援換行)</label>
<textarea id="add-label" rows="3" autocomplete="off" placeholder="例:每天備份資料庫&#10;- 備份 MySQL&#10;- 壓縮並上傳 S3"></textarea>
</div>
<div class="form-group">
<label>快速套用排程</label>
<div class="schedule-helper">
<button onclick="setField('add-schedule','@reboot')">開機執行</button>
<button onclick="setField('add-schedule','0 * * * *')">每小時</button>
<button onclick="setField('add-schedule','0 0 * * *')">每天午夜</button>
<button onclick="setField('add-schedule','0 9 * * 1-5')">平日早9</button>
<button onclick="setField('add-schedule','0 0 * * 0')">每週日</button>
</div>
</div>
<div class="form-group">
<label>Cron 排程表達式</label>
<input type="text" id="add-schedule" class="mono" placeholder="0 * * * *" oninput="updatePreview('add')" />
<div class="schedule-preview empty" id="add-preview">輸入排程表達式後顯示說明</div>
</div>
<div class="form-group">
<label>指令</label>
<input type="text" id="add-command" class="mono" placeholder="/path/to/script.sh >> /tmp/log.txt 2>&1" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeAddModal()">取消</button>
<button class="btn btn-primary" onclick="addJob()">新增</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let showRaw = false;
let currentTab = 'pm2';
async function fetchJson(url, options) {
const res = await fetch(url, options);
const text = await res.text();
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const preview = text.trim().slice(0, 160) || `HTTP ${res.status}`;
throw new Error(`API 沒有回 JSON${preview}`);
}
try {
return JSON.parse(text);
} catch {
const preview = text.trim().slice(0, 160) || `HTTP ${res.status}`;
throw new Error(`JSON 解析失敗:${preview}`);
}
}
// ── Tab 切換 ───────────────────────────────────────────
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelector(`.tab-btn[onclick="switchTab('${tab}')"]`).classList.add('active');
document.getElementById(`tab-${tab}`).classList.add('active');
// 顯示/隱藏 header actions
const cronActions = document.getElementById('header-actions');
cronActions.style.display = tab === 'cron' ? '' : 'none';
if (tab === 'pm2') loadPM2();
if (tab === 'node') loadNode();
if (tab === 'docker') loadDocker();
if (tab === 'logs') loadLogFiles();
}
function refreshCurrent() {
if (currentTab === 'cron') loadCrontab();
if (currentTab === 'pm2') loadPM2();
if (currentTab === 'node') loadNode();
if (currentTab === 'docker') loadDocker();
if (currentTab === 'logs') loadLogContent();
}
// ── Log ───────────────────────────────────────────────
let logAutoRefreshTimer = null;
async function loadLogFiles() {
try {
const files = await fetchJson('/api/logs');
const sel = document.getElementById('log-file-select');
const current = sel.value;
sel.innerHTML = '<option value="">選擇 Log 檔案...</option>' +
files.map(f => `<option value="${f.name}">${f.name} (${formatBytes(f.size)})</option>`).join('');
if (current && files.find(f => f.name === current)) {
sel.value = current;
} else if (files.length > 0 && !current) {
sel.value = files[0].name;
loadLogContent();
}
} catch (e) { console.error(e); }
}
async function loadLogContent() {
const sel = document.getElementById('log-file-select');
const filename = sel.value;
const wrap = document.getElementById('log-content-wrap');
const empty = document.getElementById('log-empty');
if (!filename) { wrap.style.display = 'none'; empty.style.display = ''; return; }
try {
const data = await fetchJson(`/api/logs/${encodeURIComponent(filename)}`);
document.getElementById('log-meta').textContent = `${filename} · 共 ${data.lines} 行(顯示最後 200 行)`;
const pre = document.getElementById('log-content');
const blocks = (data.content || '').split(/\n{2,}/);
pre.textContent = blocks.reverse().join('\n\n') || '(空白)';
pre.scrollTop = 0;
wrap.style.display = '';
empty.style.display = 'none';
} catch (e) { console.error(e); }
}
function toggleAutoRefresh() {
if (logAutoRefreshTimer) {
clearInterval(logAutoRefreshTimer);
logAutoRefreshTimer = null;
}
if (document.getElementById('log-auto-refresh').checked) {
logAutoRefreshTimer = setInterval(loadLogContent, 5000);
}
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
// ── PM2 ───────────────────────────────────────────────
async function loadPM2() {
document.getElementById('pm2-tbody').innerHTML =
'<tr><td colspan="9" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>';
try {
const { processes, error } = await fetchJson('/api/pm2');
if (error && !processes.length) {
document.getElementById('pm2-tbody').innerHTML =
`<tr><td colspan="9" style="text-align:center;color:#ed4245;padding:30px">❌ ${esc(error)}</td></tr>`;
return;
}
renderPM2(processes);
} catch (e) {
document.getElementById('pm2-tbody').innerHTML =
`<tr><td colspan="9" style="text-align:center;color:#ed4245;padding:30px">❌ ${esc(e.message)}</td></tr>`;
}
}
function renderPM2(processes) {
if (!processes.length) {
document.getElementById('pm2-tbody').innerHTML =
'<tr><td colspan="9" style="text-align:center;color:#475569;padding:30px">沒有 PM2 進程</td></tr>';
return;
}
document.getElementById('pm2-tbody').innerHTML = processes.map(p => {
const uptime = p.uptime ? fmtUptime(Date.now() - p.uptime) : '-';
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;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://${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>
<td>${p.cpu}%</td>
<td>${mem}</td>
<td>${p.restarts}</td>
<td style="font-size:12px;color:#64748b">${uptime}</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${p.status === 'online'
? `<button class="btn btn-ghost btn-sm" onclick="pm2Action('stop','${esc(p.name)}')">⏹ 停止</button>`
: `<button class="btn btn-success btn-sm" onclick="pm2Action('start','${esc(p.name)}')">▶️ 啟動</button>`}
<button class="btn btn-warning btn-sm" onclick="pm2Action('restart','${esc(p.name)}')">🔄 重啟</button>
<button class="btn btn-danger btn-sm" onclick="pm2Action('delete','${esc(p.name)}')">🗑️ 刪除</button>
</div>
</td>
</tr>`;
}).join('');
}
async function pm2Action(action, name) {
const labels = { stop:'停止', start:'啟動', restart:'重啟', delete:'刪除' };
if (action === 'delete' && !confirm(`確定要刪除 PM2 進程「${name}」嗎?`)) return;
try {
const d = await fetchJson(`/api/pm2/${action}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (d.success) { showToast(`✅ 已${labels[action]}${name}`, 'success'); loadPM2(); }
else showToast('❌ ' + d.error, 'error');
} catch (e) { showToast('❌ ' + e.message, 'error'); }
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
const h = Math.floor(s/3600);
if (h < 24) return `${h}h ${Math.floor((s%3600)/60)}m`;
return `${Math.floor(h/24)}d ${h%24}h`;
}
function fmtBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(1) + ' GB';
}
// ── Node.js ───────────────────────────────────────────
async function loadNode() {
document.getElementById('node-tbody').innerHTML =
'<tr><td colspan="6" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>';
try {
const { processes } = await fetchJson('/api/node');
renderNode(processes);
} catch (e) {
document.getElementById('node-tbody').innerHTML =
`<tr><td colspan="6" style="text-align:center;color:#ed4245;padding:30px">❌ ${esc(e.message)}</td></tr>`;
}
}
function renderNode(processes) {
if (!processes.length) {
document.getElementById('node-tbody').innerHTML =
'<tr><td colspan="6" style="text-align:center;color:#475569;padding:30px">沒有 Node.js 進程</td></tr>';
return;
}
document.getElementById('node-tbody').innerHTML = processes.map(p => `
<tr>
<td style="font-family:monospace;color:#94a3b8">${esc(p.pid)}</td>
<td>${esc(p.cpu)}%</td>
<td>${esc(p.mem)}%</td>
<td style="font-size:12px;color:#64748b">${esc(p.started)} / ${esc(p.time)}</td>
<td class="cmd-cell" title="${esc(p.cmd)}">${esc(p.cmd)}</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" onclick="killNode('${esc(p.pid)}', false)">⏹ 終止</button>
<button class="btn btn-danger btn-sm" onclick="killNode('${esc(p.pid)}', true)">💀 強制</button>
</div>
</td>
</tr>`).join('');
}
async function killNode(pid, force) {
const label = force ? '強制終止' : '終止';
if (!confirm(`確定要${label} PID ${pid} 嗎?`)) return;
try {
const d = await fetchJson('/api/node/kill', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pid, force }),
});
if (d.success) { showToast(`✅ 已${label} PID ${pid}`, 'success'); loadNode(); }
else showToast('❌ ' + d.error, 'error');
} catch (e) { showToast('❌ ' + e.message, 'error'); }
}
// ── Docker ────────────────────────────────────────────
async function loadDocker() {
document.getElementById('docker-tbody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:#475569;padding:30px">載入中...</td></tr>';
try {
const { containers, error } = await fetchJson('/api/docker');
if (error && !containers.length) {
document.getElementById('docker-tbody').innerHTML =
`<tr><td colspan="7" style="text-align:center;color:#ed4245;padding:30px">❌ ${esc(error)}</td></tr>`;
return;
}
renderDocker(containers || []);
} catch (e) {
document.getElementById('docker-tbody').innerHTML =
`<tr><td colspan="7" style="text-align:center;color:#ed4245;padding:30px">❌ ${esc(e.message)}</td></tr>`;
}
}
function renderDocker(containers) {
if (!containers.length) {
document.getElementById('docker-tbody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:#475569;padding:30px">沒有 Docker 容器</td></tr>';
return;
}
document.getElementById('docker-tbody').innerHTML = containers.map(c => {
const state = (c.state || 'unknown').toLowerCase();
const badgeClass = dockerBadgeClass(state);
const canStart = ['created', 'exited', 'dead'].includes(state);
const canStop = ['running', 'restarting', 'paused'].includes(state);
return `<tr>
<td><strong>${esc(c.name)}</strong><br><span style="font-size:11px;color:#475569">${esc(c.command || '-')}</span></td>
<td><span class="badge ${badgeClass}">${esc(c.status || state)}</span></td>
<td class="cmd-cell" title="${esc(c.image)}">${esc(c.image)}</td>
<td>${c.ports ? `<span style="font-family:monospace;color:#94a3b8">${esc(c.ports)}</span>` : '<span style="color:#475569;font-size:12px">-</span>'}</td>
<td style="font-family:monospace;color:#94a3b8">${esc(c.id)}</td>
<td style="font-size:12px;color:#64748b">${esc(c.runningFor || c.createdAt || '-')}</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${canStart
? `<button class="btn btn-success btn-sm docker-action-btn" data-action="start" data-id="${escAttr(c.id)}" data-name="${escAttr(c.name)}">▶️ 啟動</button>`
: `<button class="btn btn-ghost btn-sm docker-action-btn" data-action="stop" data-id="${escAttr(c.id)}" data-name="${escAttr(c.name)}">⏹ 停止</button>`}
${canStop || canStart
? `<button class="btn btn-warning btn-sm docker-action-btn" data-action="restart" data-id="${escAttr(c.id)}" data-name="${escAttr(c.name)}">🔄 重啟</button>`
: ''}
</div>
</td>
</tr>`;
}).join('');
}
function dockerBadgeClass(state) {
const mapped = {
running: 'badge-running',
exited: 'badge-exited',
dead: 'badge-dead',
created: 'badge-created',
paused: 'badge-paused',
restarting: 'badge-restarting',
};
return mapped[state] || 'badge-stopped';
}
async function dockerAction(action, id, name) {
const labels = { start: '啟動', stop: '停止', restart: '重啟' };
try {
const d = await fetchJson(`/api/docker/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (d.success) {
showToast(`✅ 已${labels[action]}${name}`, 'success');
loadDocker();
} else {
showToast('❌ ' + d.error, 'error');
}
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.docker-action-btn');
if (!btn) return;
dockerAction(btn.dataset.action, btn.dataset.id, btn.dataset.name);
});
// ── 載入 ───────────────────────────────────────────────
async function loadCrontab() {
try {
const data = await fetchJson('/api/crontab');
renderJobs(data.entries);
document.getElementById('raw-view').textContent = data.raw || '(空的 crontab';
} catch (e) {
showToast('❌ 載入失敗:' + e.message, 'error');
}
}
function renderJobs(entries) {
const list = document.getElementById('jobs-list');
const crons = entries.filter(e => e.type === 'cron');
document.getElementById('stat-total').textContent = crons.length;
document.getElementById('stat-active').textContent = crons.filter(e => !e.disabled).length;
document.getElementById('stat-disabled').textContent = crons.filter(e => e.disabled).length;
if (crons.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="icon">📭</div><p>目前沒有排程任務</p></div>`;
return;
}
list.innerHTML = entries.map(entry => {
if (entry.type === 'comment') {
return `
<div class="job-card comment">
<span class="comment-text">${esc(entry.text)}</span>
<div class="job-actions">
<button class="btn btn-danger btn-sm" onclick="deleteJob(${entry.index}, null)">🗑️ 刪除</button>
</div>
</div>`;
}
const labelHtml = entry.label
? `<div class="job-label">📌 ${esc(entry.label)}</div>`
: '';
return `
<div class="job-card ${entry.disabled ? 'disabled' : ''}">
<div class="status-dot" title="${entry.disabled ? '已停用' : '執行中'}"></div>
<div class="job-info">
${labelHtml}
<div class="job-meta">
<span class="job-schedule">${esc(entry.schedule)}</span>
<span class="job-schedule-desc">${esc(entry.scheduleDesc)}</span>
</div>
<div class="job-command" title="${esc(entry.command)}">${esc(entry.command)}</div>
</div>
<div class="job-actions">
<button class="btn btn-sm ${entry.disabled ? 'btn-success' : 'btn-ghost'}" onclick="toggleJob(${entry.index})">
${entry.disabled ? '▶️ 啟用' : '⏸ 停用'}
</button>
<button class="btn btn-warning btn-sm"
data-index="${entry.index}"
data-schedule="${escAttr(entry.schedule)}"
data-command="${escAttr(entry.command)}"
data-label="${escAttr(entry.label || '')}"
data-label-indices="${escAttr(JSON.stringify(entry.labelIndices || []))}"
onclick="openEditModalFromBtn(this)">✏️ 編輯</button>
<button class="btn btn-danger btn-sm" onclick="deleteJob(${entry.index}, ${JSON.stringify(entry.labelIndices || [])})">🗑️ 刪除</button>
</div>
</div>`;
}).join('');
}
// ── 操作 ───────────────────────────────────────────────
async function toggleJob(index) {
const d = await fetchJson('/api/crontab/toggle', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index }),
});
if (d.success) { showToast('✅ 已切換狀態', 'success'); loadCrontab(); }
else showToast('❌ ' + d.error, 'error');
}
async function deleteJob(index, labelIndices) {
if (!confirm('確定要刪除這個任務嗎?')) return;
const d = await fetchJson(`/api/crontab/${index}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ labelIndices: labelIndices || [] }),
});
if (d.success) { showToast('🗑️ 已刪除', 'success'); loadCrontab(); }
else showToast('❌ ' + d.error, 'error');
}
// ── Edit Modal ─────────────────────────────────────────
let editMeta = {};
function openEditModalFromBtn(btn) {
editMeta = {
index: parseInt(btn.dataset.index),
labelIndices: JSON.parse(btn.dataset.labelIndices || '[]'),
};
document.getElementById('edit-label').value = btn.dataset.label;
document.getElementById('edit-schedule').value = btn.dataset.schedule;
document.getElementById('edit-command').value = btn.dataset.command;
document.getElementById('edit-modal').classList.add('open');
document.getElementById('edit-label').focus();
updatePreview('edit');
}
function closeEditModal() {
document.getElementById('edit-modal').classList.remove('open');
}
async function saveEdit() {
const label = document.getElementById('edit-label').value;
const schedule = document.getElementById('edit-schedule').value.trim();
const command = document.getElementById('edit-command').value.trim();
if (!schedule || !command) return showToast('⚠️ 請填寫排程和指令', 'error');
const schedErr = validateSchedule(schedule);
if (schedErr) return showToast('⚠️ 排程格式錯誤:' + schedErr, 'error');
const d = await fetchJson(`/api/crontab/${editMeta.index}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedule, command, label, labelIndices: editMeta.labelIndices }),
});
if (d.success) { showToast('✅ 已儲存', 'success'); closeEditModal(); loadCrontab(); }
else showToast('❌ ' + d.error, 'error');
}
// ── Add Modal ──────────────────────────────────────────
function openAddModal() {
document.getElementById('add-label').value = '';
document.getElementById('add-schedule').value = '';
document.getElementById('add-command').value = '';
document.getElementById('add-modal').classList.add('open');
document.getElementById('add-label').focus();
updatePreview('add');
}
function closeAddModal() {
document.getElementById('add-modal').classList.remove('open');
}
async function addJob() {
const label = document.getElementById('add-label').value;
const schedule = document.getElementById('add-schedule').value.trim();
const command = document.getElementById('add-command').value.trim();
if (!schedule || !command) return showToast('⚠️ 請填寫排程和指令', 'error');
const schedErr = validateSchedule(schedule);
if (schedErr) return showToast('⚠️ 排程格式錯誤:' + schedErr, 'error');
const d = await fetchJson('/api/crontab/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedule, command, label }),
});
if (d.success) { showToast('✅ 任務已新增', 'success'); closeAddModal(); loadCrontab(); }
else showToast('❌ ' + d.error, 'error');
}
// ── 排程格式驗證 ───────────────────────────────────────
function validateSchedule(s) {
if (!s) return '請輸入排程';
s = s.trim();
if (s.startsWith('@')) {
const valid = ['@reboot','@yearly','@annually','@monthly','@weekly','@daily','@midnight','@hourly'];
if (!valid.includes(s.split(/\s+/)[0])) return `無效的快速排程:${s.split(/\s+/)[0]}`;
return null;
}
const parts = s.split(/\s+/);
if (parts.length !== 5) return `應有 5 個欄位(分 時 日 月 週),目前有 ${parts.length} 個`;
const [min, hour, day, month, weekday] = parts;
const checks = [
[min, 0, 59, '分鐘(0-59'],
[hour, 0, 23, '小時(0-23'],
[day, 1, 31, '日(1-31'],
[month, 1, 12, '月(1-12'],
[weekday,0, 7, '星期(0-7'],
];
for (const [val, lo, hi, name] of checks) {
if (val === '*' || val === '?') continue;
if (/^[\d\/\-,]+$/.test(val)) {
// 拆開 , 和 - 和 / 來驗數字
const nums = val.split(/[,\-\/]/).filter(n => /^\d+$/.test(n)).map(Number);
for (const n of nums) {
if (n < lo || n > hi) return `${name} 的值 ${n} 超出範圍`;
}
}
}
return null;
}
// ── 排程預覽 ───────────────────────────────────────────
function updatePreview(prefix) {
const val = document.getElementById(`${prefix}-schedule`).value.trim();
const el = document.getElementById(`${prefix}-preview`);
if (!val) {
el.className = 'schedule-preview empty';
el.textContent = '輸入排程表達式後顯示說明';
return;
}
const err = validateSchedule(val);
if (err) {
el.className = 'schedule-preview invalid';
el.textContent = '⚠️ ' + err;
return;
}
el.className = 'schedule-preview valid';
el.textContent = '✅ ' + describeExpr(val);
}
function describeExpr(expr) {
expr = expr.trim();
if (expr.startsWith('@')) {
return {
'@reboot': '開機時執行一次',
'@yearly': '每年 1 月 1 日 00:00',
'@annually': '每年 1 月 1 日 00:00',
'@monthly': '每月 1 日 00:00',
'@weekly': '每週日 00:00',
'@daily': '每天 00:00',
'@midnight': '每天 00:00',
'@hourly': '每小時 0 分',
}[expr.split(/\s/)[0]] || expr;
}
const [min, hour, day, month, weekday] = expr.split(/\s+/);
const wd = ['日','一','二','三','四','五','六'];
const fmtField = (v, unit, names) => {
if (v === '*') return null;
if (v.includes('/')) {
const [, step] = v.split('/');
return `每 ${step} ${unit}`;
}
if (v.includes(',')) {
const vals = v.split(',').map(n => names ? (names[+n] || n) : n);
return vals.join('、') + ' ' + unit;
}
if (v.includes('-')) {
const [a, b] = v.split('-');
if (names) return `${names[+a] || a}${names[+b] || b}`;
return `${a}-${b} ${unit}`;
}
return names ? (names[+v] || v) : `${v} ${unit}`;
};
const parts = [];
// 月
const mDesc = fmtField(month, '月', null);
if (mDesc) parts.push(mDesc);
// 週 / 日
if (weekday !== '*' && weekday !== '?') {
const wDesc = fmtField(weekday, '', wd);
parts.push(`每週${wDesc || ''}`);
} else {
const dDesc = fmtField(day, '日', null);
if (dDesc) parts.push(`每月 ${dDesc}`);
else parts.push('每天');
}
// 時:分
if (hour === '*' && min === '*') {
parts.push('每分鐘');
} else if (hour === '*') {
const minDesc = fmtField(min, '分', null);
parts.push(`每小時 ${minDesc || '0 分'}`);
} else if (min === '*') {
const hDesc = fmtField(hour, '時', null);
parts.push(`${hDesc || ''} 每分鐘`);
} else {
const h = hour.includes('/') || hour.includes(',') || hour.includes('-')
? fmtField(hour, '時', null) : hour.padStart(2, '0');
const m = min.includes('/') || min.includes(',') || min.includes('-')
? fmtField(min, '分', null) : min.padStart(2, '0');
parts.push(`${h}:${m}`);
}
return parts.join(' ');
}
// ── Helpers ────────────────────────────────────────────
function setField(id, val) {
document.getElementById(id).value = val;
// 觸發預覽更新
const prefix = id.replace('-schedule', '');
if (id.endsWith('-schedule')) updatePreview(prefix);
}
function toggleRaw() {
showRaw = !showRaw;
document.getElementById('raw-view').classList.toggle('visible', showRaw);
}
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
let toastTimer;
function showToast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `toast show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
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>
</html>