Files
nas-monitor/index.html

252 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-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>