1203 lines
45 KiB
HTML
1203 lines
45 KiB
HTML
<!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="例:每天備份資料庫 - 備份 MySQL - 壓縮並上傳 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="例:每天備份資料庫 - 備份 MySQL - 壓縮並上傳 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, '&').replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escAttr(str) {
|
|||
|
|
return String(str)
|
|||
|
|
.replace(/&/g, '&').replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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>
|