Files
crontab-ui/public/index.html
T

1202 lines
45 KiB
HTML
Raw Normal View History

2026-07-13 12:32:43 +08:00
<!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('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('docker')">🐳 Docker</button>
<button class="tab-btn" onclick="switchTab('logs')">📋 Log</button>
</div>
<!-- ── Crontab 分頁 ── -->
<div class="tab-panel active" 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" 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 = 'cron';
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">${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(' ')
: '<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(); });
loadCrontab();
</script>
</body>
</html>