init: NAS 进程监控面板 (backend + frontend + 开机自启脚本)

This commit is contained in:
2026-08-15 08:27:54 +08:00
commit 3e7cdcef62
4 changed files with 677 additions and 0 deletions

251
index.html Normal file
View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAS 进程监控</title>
<base href="/nas-monitor/">
<style>
:root{
--bg:#0d1117; --panel:#161b22; --border:#30363d; --text:#c9d1d9;
--muted:#8b949e; --accent:#58a6ff; --danger:#f85149; --ok:#3fb950; --warn:#d29922;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
header{padding:14px 18px;background:var(--panel);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
header h1{font-size:17px;margin:0;font-weight:600}
.stat{color:var(--muted);font-size:13px}
.stat b{color:var(--text)}
.controls{margin-left:auto;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
input,select,button{background:#0d1117;color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:13px}
input:focus,select:focus{outline:none}
button{cursor:pointer}
button:hover{border-color:var(--accent)}
.btn-stop{background:#3d1518;color:var(--danger);border-color:#5c1f23}
.btn-stop:hover{background:#4d181c}
.wrap{padding:14px 18px}
.token-bar{padding:10px 18px;background:#1c1408;border-bottom:1px solid #4d3a10;color:var(--warn);display:flex;gap:10px;align-items:center}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap}
th{position:sticky;top:0;background:var(--panel);color:var(--muted);font-weight:600;cursor:pointer;user-select:none}
td.cmd{max-width:420px;overflow:hidden;text-overflow:ellipsis}
tr:hover td{background:#11161d}
.pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;border:1px solid var(--border);color:var(--muted)}
.cpu-hi{color:var(--warn)}
.muted{color:var(--muted)}
.net{color:var(--muted);font-size:12px}
.empty{padding:40px;text-align:center;color:var(--muted)}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:50}
.modal.show{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:22px;max-width:420px;width:90%}
.modal h3{margin:0 0 10px}
.modal .row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:8px;opacity:0;transition:.25s;z-index:60}
.toast.show{opacity:1}
.toast.err{border-color:var(--danger);color:var(--danger)}
.toast.ok{border-color:var(--ok);color:var(--ok)}
</style>
</head>
<body>
<header>
<h1>NAS 进程监控</h1>
<span class="stat">进程数 <b id="pcount">-</b></span>
<span class="stat">负载 <b id="load">-</b></span>
<span class="stat">网络 <b id="net">↓- ↑-</b></span>
<span class="stat">更新于 <b id="updated">-</b></span>
<div class="controls">
<input id="search" placeholder="搜索命令/用户/PID" size="18">
<label class="stat"><input type="checkbox" id="autorefresh" checked> 自动刷新</label>
<select id="interval">
<option value="2000">2s</option>
<option value="3000" selected>3s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
</select>
<button id="refreshBtn">刷新</button>
</div>
</header>
<div class="token-bar" id="tokenBar" style="display:none">
<span>需要访问令牌token</span>
<input id="tokenInput" placeholder="默认与 NAS 登录密码相同" size="20">
<button id="tokenSave">保存</button>
</div>
<div class="wrap">
<table>
<thead>
<tr>
<th data-k="pid">PID</th>
<th data-k="user">用户</th>
<th data-k="cpu">CPU%</th>
<th data-k="mem">MEM%</th>
<th data-k="rss_kb">RSS</th>
<th data-k="etime">运行时间</th>
<th data-k="comm">命令</th>
<th data-k="rate_recv_kbps">网络</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tbody"><tr><td colspan="9" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<div class="modal" id="modal">
<div class="box">
<h3>终止进程</h3>
<div id="modalBody"></div>
<div class="row">
<button id="mCancel">取消</button>
<button class="btn-stop" id="mKill">终止 (SIGTERM)</button>
<button class="btn-stop" id="mKill9">强杀 (SIGKILL)</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const LS_TOKEN = "nas_monitor_token";
const DEFAULT_TOKEN = "iLoveJava5";
let token = localStorage.getItem(LS_TOKEN) || DEFAULT_TOKEN;
let sortKey = "cpu", sortDir = -1;
let timer = null;
let pending = null;
function api(path, opts){
opts = opts || {};
const headers = Object.assign({}, opts.headers || {});
if(token) headers["X-Token"] = token;
let url = path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(token);
return fetch(url, Object.assign({}, opts, {headers})).then(r => {
if(r.status === 401){
token = "";
const ti = document.getElementById("tokenInput");
if(ti) ti.value = DEFAULT_TOKEN;
showTokenBar();
throw new Error("token 无效");
}
return r.json();
});
}
function showTokenBar(){ document.getElementById("tokenBar").style.display = "flex"; }
function toast(msg, type){
const t = document.getElementById("toast");
t.textContent = msg; t.className = "toast show " + (type||"");
setTimeout(()=>t.className="toast", 2200);
}
function fmtKB(kb){ if(kb>=1048576) return (kb/1048576).toFixed(1)+"G"; if(kb>=1024) return (kb/1024).toFixed(0)+"M"; return kb+"K"; }
function fmtRate(b){ b=b||0; if(b>=1073741824) return (b/1073741824).toFixed(2)+"GB/s"; if(b>=1048576) return (b/1048576).toFixed(1)+"MB/s"; if(b>=1024) return (b/1024).toFixed(1)+"KB/s"; return b.toFixed(0)+"B/s"; }
function netCell(net){
net = net || {conns:0, listen:[], established:0, rate_sent_kbps:0, rate_recv_kbps:0};
let parts = [];
const r = net.rate_recv_kbps||0, s = net.rate_sent_kbps||0;
if(r>0.01 || s>0.01){
let sp = [];
if(r>0.01) sp.push("↓"+fmtKBs(r));
if(s>0.01) sp.push(sp.length? " ↑"+fmtKBs(s) : "↑"+fmtKBs(s));
parts.push(sp.join(" "));
}
if(net.listen && net.listen.length) parts.push("监听 "+net.listen.map(x=>":"+x).join(" "));
if(net.established) parts.push(net.established+"连接");
return parts.length ? parts.join(" · ") : '<span class="muted">-</span>';
}
function fmtKBs(k){ k=k||0; if(k>=1024) return (k/1024).toFixed(1)+"MB/s"; if(k>=1) return k.toFixed(1)+"KB/s"; return k.toFixed(2)+"KB/s"; }
function load(){
api("api/processes").then(d=>{
if(d.error){ toast(d.error, "err"); return; }
document.getElementById("pcount").textContent = d.count;
document.getElementById("updated").textContent = new Date().toLocaleTimeString();
const nt = d.net_total || {};
document.getElementById("net").textContent =
"↓"+fmtRate(nt.rx_rate)+" ↑"+fmtRate(nt.tx_rate);
render(d.processes||[]);
}).catch(e=>{
document.getElementById("tbody").innerHTML =
'<tr><td colspan="8" class="empty">加载失败:' + (e.message||e) +
'。<br>若提示需要令牌,请在页面顶部输入框填写(默认即 NAS 登录密码),点"保存"。</td></tr>';
});
}
function render(procs){
const q = (document.getElementById("search").value||"").toLowerCase();
let list = procs.filter(p =>
!q || p.comm.toLowerCase().includes(q) || p.args.toLowerCase().includes(q) ||
p.user.toLowerCase().includes(q) || String(p.pid).includes(q));
list.sort((a,b)=>{
let va=a[sortKey], vb=b[sortKey];
if(typeof va==="string"){ return sortDir*va.localeCompare(vb); }
return sortDir*(va-vb);
});
const tb = document.getElementById("tbody");
if(!list.length){ tb.innerHTML = '<tr><td colspan="9" class="empty">无匹配进程</td></tr>'; return; }
tb.innerHTML = list.map(p=>{
const cpuCls = p.cpu>=5 ? "cpu-hi" : "";
const stop = p.protected ? '<span class="pill">受保护</span>'
: `<button class="btn-stop" onclick="askKill(${p.pid}, ${JSON.stringify(p.comm).replace(/"/g,'&quot;')})">停止</button>`;
return `<tr>
<td>${p.pid}</td>
<td>${p.user}</td>
<td class="${cpuCls}">${p.cpu.toFixed(1)}</td>
<td>${p.mem.toFixed(1)}</td>
<td>${fmtKB(p.rss_kb)}</td>
<td>${p.etime}</td>
<td class="cmd" title="${p.args.replace(/"/g,'&quot;')}">${p.args}</td>
<td class="net">${netCell(p.net)}</td>
<td>${stop}</td>
</tr>`;
}).join("");
}
function askKill(pid, comm){
pending = pid;
document.getElementById("modalBody").innerHTML =
`确认终止进程 <b>PID ${pid}</b><br><span class="pill">${comm}</span><br><br>
先尝试 SIGTERM优雅退出不行再用 SIGKILL强制`;
document.getElementById("modal").classList.add("show");
}
function doKill(sig){
if(!pending) return;
const pid = pending;
document.getElementById("modal").classList.remove("show");
api("api/kill", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({pid, signal:sig})})
.then(d=>{
if(d.ok){ toast(`已发送信号 ${sig} 到 PID ${pid}`, "ok"); }
else { toast("失败:" + d.error, "err"); }
pending = null;
setTimeout(load, 800);
}).catch(e=>toast("请求出错:"+e.message,"err"));
}
// 事件
document.getElementById("refreshBtn").onclick = load;
document.getElementById("search").oninput = ()=>load();
document.querySelectorAll("th[data-k]").forEach(th=>{
th.onclick = ()=>{ const k=th.dataset.k; if(sortKey===k) sortDir*=-1; else {sortKey=k; sortDir=-1;} load(); };
});
document.getElementById("tokenSave").onclick = ()=>{
token = document.getElementById("tokenInput").value.trim();
localStorage.setItem(LS_TOKEN, token);
document.getElementById("tokenBar").style.display="none";
load();
};
document.getElementById("mCancel").onclick = ()=>{ document.getElementById("modal").classList.remove("show"); pending=null; };
document.getElementById("mKill").onclick = ()=>doKill(15);
document.getElementById("mKill9").onclick = ()=>doKill(9);
document.getElementById("autorefresh").onchange = (e)=>{ e.target.checked ? startTimer() : stopTimer(); };
document.getElementById("interval").onchange = ()=>{ stopTimer(); startTimer(); };
function startTimer(){ stopTimer(); const iv=+document.getElementById("interval").value; timer=setInterval(load, iv); }
function stopTimer(){ if(timer) clearInterval(timer); timer=null; }
// 启动
if(!token) showTokenBar();
load();
startTimer();
</script>
</body>
</html>