init: NAS 进程监控面板 (backend + frontend + 开机自启脚本)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
app.log
|
||||
app.pid
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
39
S99nas-monitor.sh
Normal file
39
S99nas-monitor.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# NAS 进程监控 开机自启脚本(以 root 运行,以便读取所有进程的网络连接信息)
|
||||
APP=/volume1/web/nas-monitor/app.py
|
||||
LOG=/volume1/web/nas-monitor/app.log
|
||||
PIDFILE=/volume1/web/nas-monitor/app.pid
|
||||
PY=$(command -v python3 || echo /usr/bin/python3)
|
||||
|
||||
start() {
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "nas-monitor 已在运行 (PID $(cat "$PIDFILE"))"; return 0
|
||||
fi
|
||||
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
|
||||
n=0
|
||||
while [ ! -f "$APP" ] && [ $n -lt 90 ]; do
|
||||
sleep 1; n=$((n+1))
|
||||
done
|
||||
if [ ! -f "$APP" ]; then
|
||||
echo "等待 $APP 超时(数据卷可能未挂载),启动放弃"; return 1
|
||||
fi
|
||||
setsid nohup "$PY" "$APP" > "$LOG" 2>&1 < /dev/null &
|
||||
echo $! > "$PIDFILE"
|
||||
sleep 2
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "nas-monitor 已启动 (PID $(cat "$PIDFILE"))"
|
||||
else
|
||||
echo "启动失败,请查看 $LOG"; return 1
|
||||
fi
|
||||
}
|
||||
stop() {
|
||||
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
|
||||
pkill -f "nas-monitor/app.py" 2>/dev/null
|
||||
echo "nas-monitor 已停止"
|
||||
}
|
||||
case "$1" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; sleep 1; start ;;
|
||||
*) echo "用法: $0 {start|stop|restart}"; exit 1 ;;
|
||||
esac
|
||||
382
app.py
Normal file
382
app.py
Normal file
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NAS 进程实时监控后端(纯标准库,无外部依赖;可选 nethogs 提供逐进程带宽)。
|
||||
|
||||
提供:
|
||||
GET / -> 前端页面
|
||||
GET /api/processes -> 进程列表 JSON(含每进程网络信息 + 系统总流量 + 逐进程实时带宽)
|
||||
POST /api/kill -> 停止进程 {"pid": int, "signal": 15|9}
|
||||
|
||||
所有请求需带 token(?token=xxx 或 header X-Token)。
|
||||
默认 token 取自环境变量 NAS_MONITOR_TOKEN,未设置则用 "iLoveJava5"。
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
TOKEN = os.environ.get("NAS_MONITOR_TOKEN", "iLoveJava5")
|
||||
HOST = os.environ.get("NAS_MONITOR_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("NAS_MONITOR_PORT", "8901"))
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SELF_PID = os.getpid()
|
||||
|
||||
# 受保护、不允许通过本接口终止的 PID(避免把自己或系统核心干掉)
|
||||
PROTECTED_PIDS = {1, SELF_PID}
|
||||
|
||||
|
||||
# ---------------- 网络总量采样(后台线程每秒采样 /proc/net/dev)----------------
|
||||
_net_lock = threading.Lock()
|
||||
_net_snap = {"rx_bytes": 0, "tx_bytes": 0, "rx_rate": 0.0, "tx_rate": 0.0, "ts": 0.0}
|
||||
|
||||
|
||||
def _read_net_bytes():
|
||||
rx = tx = 0
|
||||
try:
|
||||
with open("/proc/net/dev") as f:
|
||||
for line in f:
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, rest = line.split(":", 1)
|
||||
if name.strip() == "lo":
|
||||
continue
|
||||
parts = rest.split()
|
||||
if len(parts) >= 16:
|
||||
rx += int(parts[0])
|
||||
tx += int(parts[8])
|
||||
except Exception:
|
||||
pass
|
||||
return rx, tx
|
||||
|
||||
|
||||
def _net_sampler():
|
||||
global _net_snap
|
||||
prev_rx, prev_tx = _read_net_bytes()
|
||||
prev_t = time.time()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
rx, tx = _read_net_bytes()
|
||||
now = time.time()
|
||||
dt = now - prev_t
|
||||
rx_rate = tx_rate = 0.0
|
||||
if dt > 0:
|
||||
rx_rate = max(0.0, (rx - prev_rx)) / dt
|
||||
tx_rate = max(0.0, (tx - prev_tx)) / dt
|
||||
with _net_lock:
|
||||
_net_snap = {"rx_bytes": rx, "tx_bytes": tx,
|
||||
"rx_rate": rx_rate, "tx_rate": tx_rate, "ts": now}
|
||||
prev_rx, prev_tx, prev_t = rx, tx, now
|
||||
|
||||
|
||||
threading.Thread(target=_net_sampler, daemon=True).start()
|
||||
|
||||
|
||||
def get_net_total():
|
||||
with _net_lock:
|
||||
return dict(_net_snap)
|
||||
|
||||
|
||||
# ---------------- 各进程实时带宽(nethogs 常驻抓包,可选)----------------
|
||||
# nethogs 由 Entware 提供(/opt/sbin/nethogs)。后端以 root 运行可直接抓包。
|
||||
# 输出 tracemode(-t):每次刷新以 "Refreshing:" 开头,随后每行
|
||||
# /path/to/prog/<PID>/0\t<sent KB/s>\t<recv KB/s>
|
||||
NETHOGS_BIN = "/opt/sbin/nethogs"
|
||||
_nh_lock = threading.Lock()
|
||||
_nh_snap = {} # pid -> (sent_kbps, recv_kbps)
|
||||
_nh_proc = None
|
||||
|
||||
|
||||
def _parse_nethogs_line(line, temp):
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
return
|
||||
if line.startswith("Refreshing:"):
|
||||
with _nh_lock:
|
||||
_nh_snap.update(temp)
|
||||
temp.clear()
|
||||
return
|
||||
if "\t" not in line:
|
||||
return
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
return
|
||||
try:
|
||||
sent = float(parts[-2])
|
||||
recv = float(parts[-1])
|
||||
except ValueError:
|
||||
return
|
||||
path = parts[0].strip("/")
|
||||
if path.startswith("unknown"):
|
||||
return
|
||||
segs = path.split("/")
|
||||
if len(segs) < 2:
|
||||
return
|
||||
try:
|
||||
pid = int(segs[-2])
|
||||
except ValueError:
|
||||
return
|
||||
temp[pid] = (sent, recv)
|
||||
|
||||
|
||||
def _nethogs_loop():
|
||||
"""常驻:运行 nethogs 抓包,把逐进程速率写进 _nh_snap。崩溃自动重启。"""
|
||||
global _nh_proc
|
||||
while True:
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
lp = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = ("/opt/lib" + (":" + lp if lp else ""))
|
||||
proc = subprocess.Popen(
|
||||
[NETHOGS_BIN, "-t", "-v", "0", "-d", "2", "-a"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env=env, text=True, bufsize=1,
|
||||
)
|
||||
_nh_proc = proc
|
||||
temp = {}
|
||||
for line in proc.stdout:
|
||||
_parse_nethogs_line(line, temp)
|
||||
proc.wait()
|
||||
except Exception:
|
||||
try:
|
||||
if _nh_proc:
|
||||
_nh_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def get_nethogs_snap():
|
||||
with _nh_lock:
|
||||
return dict(_nh_snap)
|
||||
|
||||
|
||||
threading.Thread(target=_nethogs_loop, daemon=True).start()
|
||||
|
||||
|
||||
# ---------------- 各进程网络连接信息(inode 关联 socket)----------------
|
||||
def get_net_per_pid():
|
||||
"""返回 {pid: {"conns":int,"listen":[ports],"established":int}}。"""
|
||||
entries = {}
|
||||
specs = [("tcp", "/proc/net/tcp"), ("tcp6", "/proc/net/tcp6"),
|
||||
("udp", "/proc/net/udp"), ("udp6", "/proc/net/udp6")]
|
||||
for proto, path in specs:
|
||||
try:
|
||||
with open(path) as f:
|
||||
next(f, None)
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if len(p) < 10:
|
||||
continue
|
||||
inode = p[9]
|
||||
st = p[3]
|
||||
local = p[1]
|
||||
lport = 0
|
||||
if ":" in local:
|
||||
try:
|
||||
lport = int(local.split(":")[1], 16)
|
||||
except ValueError:
|
||||
pass
|
||||
listen = (proto.startswith("tcp") and st == "0A")
|
||||
established = (proto.startswith("tcp") and st == "01")
|
||||
entries[inode] = {"lport": lport, "listen": listen,
|
||||
"established": established}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {}
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
fd_dir = "/proc/%s/fd" % pid
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
continue
|
||||
for fd in fds:
|
||||
try:
|
||||
link = os.readlink("%s/%s" % (fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if link.startswith("socket:["):
|
||||
inode = link[len("socket:["):-1]
|
||||
e = entries.get(inode)
|
||||
if not e:
|
||||
continue
|
||||
d = result.setdefault(
|
||||
int(pid), {"conns": 0, "listen": set(), "established": 0})
|
||||
d["conns"] += 1
|
||||
if e["listen"]:
|
||||
d["listen"].add(e["lport"])
|
||||
if e["established"]:
|
||||
d["established"] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out = {}
|
||||
for pid, d in result.items():
|
||||
out[pid] = {"conns": d["conns"], "listen": sorted(d["listen"]),
|
||||
"established": d["established"]}
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- token 校验 ----------------
|
||||
def check_token(req):
|
||||
qs = req.path.split("?", 1)
|
||||
token = ""
|
||||
if len(qs) == 2:
|
||||
for kv in qs[1].split("&"):
|
||||
if kv.startswith("token="):
|
||||
token = kv[6:]
|
||||
if not token:
|
||||
token = req.headers.get("X-Token", "")
|
||||
return token == TOKEN
|
||||
|
||||
|
||||
# ---------------- 进程列表 ----------------
|
||||
def get_processes():
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["ps", "-eo", "pid,user:20,pcpu,pmem,rss,etime,comm,args", "--no-headers"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8", "replace")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
net_map = get_net_per_pid()
|
||||
nh_snap = get_nethogs_snap()
|
||||
procs = []
|
||||
for line in out.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
pid, user, pcpu, pmem, rss, etime, comm = parts[:7]
|
||||
args = " ".join(parts[7:]) if len(parts) > 7 else comm
|
||||
try:
|
||||
pid_i = int(pid)
|
||||
except ValueError:
|
||||
continue
|
||||
net = net_map.get(pid_i, {"conns": 0, "listen": [], "established": 0})
|
||||
nh = nh_snap.get(pid_i)
|
||||
net["rate_sent_kbps"] = nh[0] if nh else 0.0
|
||||
net["rate_recv_kbps"] = nh[1] if nh else 0.0
|
||||
procs.append({
|
||||
"pid": pid_i,
|
||||
"user": user,
|
||||
"cpu": float(pcpu) if pcpu else 0.0,
|
||||
"mem": float(pmem) if pmem else 0.0,
|
||||
"rss_kb": int(rss) if rss.isdigit() else 0,
|
||||
"etime": etime,
|
||||
"comm": comm,
|
||||
"args": args,
|
||||
"protected": pid_i in PROTECTED_PIDS,
|
||||
"net": net,
|
||||
})
|
||||
# 按 CPU 降序
|
||||
procs.sort(key=lambda p: p["cpu"], reverse=True)
|
||||
return {
|
||||
"processes": procs,
|
||||
"count": len(procs),
|
||||
"net_total": get_net_total(),
|
||||
}
|
||||
|
||||
|
||||
def do_kill(pid, sig):
|
||||
if not isinstance(pid, int) or pid <= 0:
|
||||
return {"ok": False, "error": "invalid pid"}
|
||||
if pid in PROTECTED_PIDS:
|
||||
return {"ok": False, "error": "该进程受保护,禁止终止(含监控自身)"}
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
return {"ok": True, "pid": pid, "signal": int(sig)}
|
||||
except ProcessLookupError:
|
||||
return {"ok": False, "error": "进程不存在(可能已退出)"}
|
||||
except PermissionError:
|
||||
return {"ok": False, "error": "权限不足,无法终止该进程(可能需要 root)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass # 静默访问日志
|
||||
|
||||
def _send(self, code, obj=None, ctype="application/json; charset=utf-8"):
|
||||
if obj is None:
|
||||
body = b""
|
||||
elif ctype.startswith("application/json"):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
else:
|
||||
body = obj if isinstance(obj, bytes) else obj.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _token_fail(self):
|
||||
self._send(401, {"error": "token 无效"})
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path in ("/", "/index.html"):
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "index.html"), "rb") as f:
|
||||
self._send(200, f.read(), "text/html; charset=utf-8")
|
||||
except FileNotFoundError:
|
||||
self._send(404, {"error": "index.html 缺失"})
|
||||
return
|
||||
if path == "/api/processes":
|
||||
if not check_token(self):
|
||||
return self._token_fail()
|
||||
self._send(200, get_processes())
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._send(200, {"ok": True})
|
||||
return
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path == "/api/kill":
|
||||
if not check_token(self):
|
||||
return self._token_fail()
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
data = json.loads(raw.decode("utf-8", "replace") or "{}")
|
||||
except Exception:
|
||||
data = {}
|
||||
pid = data.get("pid")
|
||||
sig = data.get("signal", 15)
|
||||
try:
|
||||
sig = int(sig)
|
||||
except Exception:
|
||||
sig = 15
|
||||
if sig not in (signal.SIGTERM, signal.SIGKILL, 15, 9):
|
||||
sig = signal.SIGTERM
|
||||
if not isinstance(pid, int):
|
||||
try:
|
||||
pid = int(pid)
|
||||
except Exception:
|
||||
return self._send(400, {"ok": False, "error": "pid 必须是整数"})
|
||||
self._send(200, do_kill(pid, sig))
|
||||
return
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
srv = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
print(f"NAS Monitor listening on http://{HOST}:{PORT} (token={TOKEN})")
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
srv.shutdown()
|
||||
251
index.html
Normal file
251
index.html
Normal 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,'"')})">停止</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,'"')}">${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>
|
||||
Reference in New Issue
Block a user