From 1aff3138cb40d3549eebb93e0f03d404131c8cd8 Mon Sep 17 00:00:00 2001 From: 3970260761 <3970260761@qq.com> Date: Sun, 8 Nov 2026 00:59:09 +0800 Subject: [PATCH] =?UTF-8?q?v1.3.14:=20CPU=E6=AF=8F=E6=A0=B8MHz=E6=98=BE?= =?UTF-8?q?=E7=A4=BA+=E5=9B=9B=E9=A1=B5=E6=B5=8B=E9=80=9F(CPU=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=80=9F=E5=BA=A6/=E5=86=85=E5=AD=98=E8=AF=BB?= =?UTF-8?q?=E5=86=99/=E7=A1=AC=E7=9B=98=E8=AF=BB=E5=86=99/=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E4=B8=8A=E4=B8=8B=E8=A1=8C)+=E6=9C=80=E5=A4=9A5?= =?UTF-8?q?=E6=9D=A1=E5=8E=86=E5=8F=B2=E8=87=AA=E5=8A=A8=E5=88=A0=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 175 ++++++++++++++++++++++++++++++++++++++++++++- docker-compose.yml | 2 +- promo_juejin.html | 136 +++++++++++++++++++++++++++++++++++ public/index.html | 28 ++++++-- 4 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 promo_juejin.html diff --git a/app.py b/app.py index d21d9c8..cbceb84 100644 --- a/app.py +++ b/app.py @@ -16,7 +16,7 @@ PORT = int(os.environ.get("PORT", "7031")) # ---------- CardAuth hub (免费形态: 安装统计/在线/公告/版本推送,失败开放) ---------- HUB_URL = os.environ.get("HUB_URL", "https://auth.88531.cn/api").rstrip("/") HUB_PROJECT_ID = os.environ.get("HUB_PROJECT_ID", "12") -HUB_VERSION = os.environ.get("SITE_VERSION", "1.3.13") +HUB_VERSION = os.environ.get("SITE_VERSION", "1.3.14") HUB = {"ok": False, "install_id": None, "latest": "", "update_available": False, "update_required": False, "download_url": "", "changelog": "", "announcements": [], "popup": [], "ts": 0} @@ -374,6 +374,7 @@ def api_cpu(): return jsonify({"cpu": cache["cpu"], "load": cache["load"], "active": active, "total": len(cache["procs"]), "info": get_cpu_info(), "percpu": get_percpu(), + "mhz": get_percpu_mhz(), "items": cache["procs"][:100]}) _cpu_static = {} @@ -463,6 +464,178 @@ def get_percpu(): except Exception: return [] +def get_percpu_mhz(): + """每核实时频率(MHz): 优先读 cpufreq scaling_cur_freq,读不到回退 cpuinfo""" + mhz = [] + try: + paths = sorted(glob.glob("/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_cur_freq")) + if not paths: + for base in ("/host/sys/devices/system/cpu", "/sys/devices/system/cpu"): + try: + names = sorted([d for d in os.listdir(base) if d.startswith("cpu") and d[3:].isdigit()]) + except Exception: + continue + for n in names: + paths.append(os.path.join(base, n, "cpufreq/scaling_cur_freq")) + if paths: + break + for p in paths: + try: + with open(p) as f: + v = float(f.read().strip()) + mhz.append(int(round(v / 1000.0))) + except Exception: + continue + except Exception: + pass + if not mhz: + try: + for cp in ("/host/proc/cpuinfo", "/proc/cpuinfo"): + try: + vals = [] + with open(cp) as f: + for line in f: + if line.startswith("cpu MHz"): + vals.append(int(round(float(line.split(":")[1].strip())))) + if vals: + mhz = vals + break + except Exception: + continue + except Exception: + pass + return mhz + +# ---------- 各页测速: CPU/内存/硬盘/网络(非破坏,失败返回error) ---------- +import urllib.request as _urlreq + +def _bench_cpu(): + import math + N = 200000 + t0 = time.perf_counter() + x = 0 + for i in range(N): + x += (i * i) % 10007 + dt = max(time.perf_counter() - t0, 1e-6) + return {"ms": int(round(dt * 1000)), "kops": int(round(N / dt / 1000)), + "score": int(round(N / dt / 1000)), "detail": "20万次整数运算耗时"} + +def _bench_mem(): + SZ = 32 * 1024 * 1024 + t0 = time.perf_counter() + b = bytearray(SZ) + b[:] = b"\x5a" * SZ + dt_w = max(time.perf_counter() - t0, 1e-6) + t0 = time.perf_counter() + s = sum(b[::4096]) + dt_r = max(time.perf_counter() - t0, 1e-6) + del b + return {"write_mbs": round(SZ / dt_w / 1048576, 1), + "read_mbs": round(SZ / dt_r / 1048576, 1), + "size_mb": 32, "detail": "32MB 内存写/读", "checksum": s % 997} + +def _bench_disk(): + SZ_MB = 32 + data = os.urandom(4 * 1024 * 1024) + path = "" + for cand in ("/host/vol1/.fnos_resmon_bench.tmp", + "/host/.fnos_resmon_bench.tmp", "/tmp/.fnos_resmon_bench.tmp"): + try: + d = os.path.dirname(cand) + if os.path.isdir(d) and os.access(d, os.W_OK): + path = cand + break + except Exception: + continue + if not path: + return {"ok": False, "error": "无可写目录"} + try: + t0 = time.perf_counter() + with open(path, "wb") as f: + for _ in range(SZ_MB // 4): + f.write(data) + f.flush() + os.fsync(f.fileno()) + dt_w = max(time.perf_counter() - t0, 1e-6) + t0 = time.perf_counter() + with open(path, "rb") as f: + while f.read(4 * 1024 * 1024): + pass + dt_r = max(time.perf_counter() - t0, 1e-6) + return {"write_mbs": round(SZ_MB / dt_w, 1), + "read_mbs": round(SZ_MB / dt_r, 1), + "size_mb": SZ_MB, "file": path.replace("/host", "") or "/", + "detail": "32MB 文件写(含落盘)/读"} + except Exception as e: + return {"ok": False, "error": str(e)} + finally: + try: + os.unlink(path) + except Exception: + pass + +def _bench_net(): + out = {} + try: + url = "https://speed.cloudflare.com/__down?bytes=5000000" + t0 = time.perf_counter() + req = _urlreq.Request(url, headers={"User-Agent": "fnos-resmon"}) + with _urlreq.urlopen(req, timeout=25) as r: + n = 0 + while True: + ch = r.read(256 * 1024) + if not ch: + break + n += len(ch) + dt = max(time.perf_counter() - t0, 1e-6) + out["down_mbps"] = round(n * 8 / dt / 1000000, 1) + out["down_mb"] = round(n / 1048576, 1) + out["down_s"] = round(dt, 1) + except Exception as e: + return {"ok": False, "error": "下载测速失败: %s" % e} + try: + payload = os.urandom(2 * 1024 * 1024) + t0 = time.perf_counter() + req = _urlreq.Request("https://speed.cloudflare.com/__up", + data=payload, method="POST", + headers={"Content-Type": "application/octet-stream"}) + with _urlreq.urlopen(req, timeout=25) as r: + r.read() + dt = max(time.perf_counter() - t0, 1e-6) + out["up_mbps"] = round(len(payload) * 8 / dt / 1000000, 1) + out["up_mb"] = round(len(payload) / 1048576, 1) + out["up_s"] = round(dt, 1) + except Exception as e: + out["up_mbps"] = 0 + out["up_error"] = "上传测速失败: %s" % e + out.update({"ok": True, "detail": "Cloudflare 测速点: 下行5MB/上行2MB"}) + return out + +@app.route("/api/bench/", methods=["POST"]) +def api_bench(kind): + try: + if kind == "cpu": + r = _bench_cpu() + p = _read_perf() + r.update({"ok": True, "mode": p.get("mode", ""), + "governor": p.get("governor", ""), "epp": p.get("epp", ""), + "mhz_avg": (sum(get_percpu_mhz()) // len(get_percpu_mhz()) if get_percpu_mhz() else 0)}) + elif kind == "mem": + r = _bench_mem() + r["ok"] = True + elif kind == "disk": + r = _bench_disk() + if "ok" not in r: + r["ok"] = True + elif kind == "net": + r = _bench_net() + else: + return jsonify({"ok": False, "error": "未知测速类型"}), 400 + r["at"] = time.strftime("%m-%d %H:%M:%S", time.localtime()) + return jsonify(r) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + # ---------- CPU 性能模式: 省电/平衡/高性能(写宿主 cpufreq,失败开放) ---------- PERF_FILE = os.environ.get("PERF_FILE", "/run/secrets/perf_mode.json") PERF_FILE_FALLBACKS = [PERF_FILE, "/app/perf_mode.json"] diff --git a/docker-compose.yml b/docker-compose.yml index ca34e39..8f01455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: environment: - PORT=7031 - PROCFS_PATH=/host/proc - - SITE_VERSION=1.3.13 + - SITE_VERSION=1.3.14 - HUB_URL=https://auth.88531.cn/api - HUB_PROJECT_ID=12 - HUB_KEY_FILE=/run/secrets/hub_key diff --git a/promo_juejin.html b/promo_juejin.html new file mode 100644 index 0000000..30d7aad --- /dev/null +++ b/promo_juejin.html @@ -0,0 +1,136 @@ +

前言:飞牛自带的监控,为什么不够用

+

飞牛 NAS 自带的资源监控只有一个百分比数字:CPU 多少、内存多少,没了。但实际运维中真正要回答的问题是:是哪个进程在吃 CPU?内存被谁占了?CPU 烧到多少度?风扇转不转?这台机器的配置到底顶不顶得住?哪块盘快坏了?哪个端口被谁占着?

+

于是我写了 fnos-resmon(飞牛资源管理器):一个单文件 Flask + psutil 的轻量监控面板,专为飞牛 NAS 设计,一条 docker 命令启动,浏览器打开 http://NAS-IP:7031 就能看,手机也能看。

+

开源地址:https://gitea.88531.cn/jinghui/fnos-resmon

+

一、它能做什么

+ +

二、实现原理:容器里怎么读到宿主真实数据

+

监控跑在 Docker 里,默认只能看到容器自己的 namespace。要读宿主真实数据,靠三招:

+
services:
+  fnos-resmon:
+    image: jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
+    network_mode: host
+    pid: host          # 与宿主共享 PID 命名空间
+    privileged: true
+    volumes:
+      - /proc:/host/proc:rw   # 宿主 proc
+      - /sys:/host/sys:ro     # 宿主 sysfs(温度/风扇/网卡从这读)
+

1. CPU / 内存 / 进程:psutil 重定向 PROCFS。启动时把 psutil.PROCFS_PATH 指向 /host/proc,之后所有 psutil.process_iter / cpu_percent / virtual_memory 读到的都是宿主数据:

+
PROCFS = os.environ.get("PROCFS_PATH", "/host/proc")
+if os.path.isdir(PROCFS):
+    psutil.PROCFS_PATH = PROCFS
+

2. 网络:nsenter 进宿主 netns。容器即使 bridged 模式,直读 /proc/net 也只能看到容器自身。网络类数据统一经 nsenter --target 1 --mount --net 在宿主(PID 1)netns 里执行 cat /proc/net/dev 和 ss -tunHn state all,失败才回退到 psutil 容器视角:

+
NSENTER = ["nsenter", "--target", "1", "--mount", "--net"]
+
+def _host_ss():
+    """宿主 tcp/udp 连接+监听,经 nsenter 在宿主 netns 执行 ss"""
+    out = _nsenter_run(["ss", "-tunHn", "state", "all"])
+

ss 行尾的 ("进程名",pid=1234,...) 会被正则抠出来,再用进程表按 PID 补齐进程名,前 80 条连接 + 前 80 条监听返回前端。

+

3. 后台采样线程 + 2 秒缓存。进程 CPU 统计需要两次采样算差值,所以 sample_procs() 先全员 cpu_percent(None) 打点,sleep 1 秒后再读数,按 CPU 倒序,前端每 2 秒拿到的是缓存,不会每次请求都卡 1 秒:

+
def sampler_loop():
+    while True:
+        try:
+            sample_procs()
+        except Exception:
+            pass
+        time.sleep(2)
+
+threading.Thread(target=sampler_loop, daemon=True).start()
+

每个进程采集的字段:cpu / mem / rss / uss / swap / 线程数 / 用户 / 状态 / IO 读写 / 连接数。容器内 /etc/passwd 没有宿主用户,纯数字 uid 会经 /host/etc/passwd 还原用户名(5 分钟缓存)。

+

三、两个最实用的接口:负载诊断与一键优化

+

GET /api/cpu/advise —— 配置顶不顶得住,一句话结论。公式很简单:单核负载 = 1分钟load ÷ 逻辑核数,分四档给结论,并点名最吃 CPU 的进程、给出扩容 / 杀进程 / 属正常的建议:

+
@app.route("/api/cpu/advise")
+def api_cpu_advise():
+    load = list(os.getloadavg())       # 1/5/15 分钟
+    per_core = load[0] / logical       # 单核负载
+    # 四档:<0.7 轻松 / <1.0 饱和 / <2.0 吃力 / 更高 过载
+

POST /api/sys/optimize —— 一键优化。依次做三件事:sync 数据落盘 → 写 /host/proc/sys/vm/drop_caches 回收 pagecache → 写 /host/proc/sys/vm/compact_memory 整理碎片。内存面板另有 POST /api/mem/drop 单独回收。零侵入:只调内核已有接口,不改系统文件。

+

GET /api/sensors —— 温度风扇。优先读 /host/sys/class/hwmon,回退 /sys/class/hwmon,temp*_input ÷ 1000 得摄氏度,fan*_input 即 RPM,label 优先用 temp*_label。超温阈值前端变色预警,风扇节点不存在(如纯 NAS 小主机无风扇上报)的机器返回空 + 友好提示,不阻塞主流程。

+

四、API 一览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方法路径作用
GET/api/cpuCPU 信息 + 每核占用 + 进程排行 + load
GET/api/mem内存占用 + RSS/USS/SWAP 进程明细 + 槽位硬件
GET/api/disk挂载用量 + SMART/温度/通电时间 + 阵列 + IO 排行
GET/api/net网卡速率 + TCP/监听明细 + 硬件(驱动/MTU/双工)
GET/api/sensors11 路温度 + 风扇 RPM
GET/api/cpu/advise负载诊断:四档结论 + 头号进程 + 建议
POST/api/sys/optimize一键优化:落盘 + 回收缓存 + 内存整理
POST/api/mem/drop一键内存回收
POST/api/kill结束异常进程(限宿主 PID)
GET/api/health健康检查
+

五、部署:一条命令

+
docker run -d --name fnos-resmon -p 7031:7031 --privileged \
+  -v /proc:/host/proc:rw -v /sys:/host/sys:ro \
+  jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
+

浏览器打开 http://NAS-IP:7031 即可。前端是单个静态页(改前端必须重建镜像),后端一个 app.py 约 1100 行,无数据库,开箱即用。

+

六、实测截图(v1.3.4 当前版本)

+

CPU 面板:63°C 红色预警、风扇 1200 转 RPM、负载诊断四档结论、一键优化按钮:

+

CPU面板

+

内存面板:RSS/USS/SWAP 三列细分 + 🧹 一键回收大按钮:

+

内存面板

+

磁盘面板:3 盘 SMART 全 PASSED + 温度通电时间:

+

磁盘面板

+

网络面板:qbittorrent 482 连接一眼揪出吃带宽进程:

+

网络面板

+

七、总结

+

飞牛自带监控解决"有没有",fnos-resmon 解决"是谁、多少度、顶不顶得住、能不能一键收拾"。核心就三招:psutil 重定向宿主 proc、nsenter 进宿主 netns 读网络、后台线程 2 秒缓存进程快照,剩下的都是把数据摆整齐。如果你也在飞牛上跑了很多 Docker 服务,装一个看看头号吃 CPU 的到底是谁 —— 八成有惊喜。

+

镜像:jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest | 开源:https://gitea.88531.cn/jinghui/fnos-resmon | 端口 7031,欢迎提 issue。

diff --git a/public/index.html b/public/index.html index 3aeb4ed..986595c 100644 --- a/public/index.html +++ b/public/index.html @@ -49,6 +49,9 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi .corecell .ct{font-size:11px;color:#999;margin-top:2px;font-variant-numeric:tabular-nums} .perfbtn{border:1px solid #ddd;background:#fff;border-radius:6px;padding:8px 18px;font-size:14px;cursor:pointer;margin-right:8px} .perfbtn.cur{background:#20a53a;border-color:#20a53a;color:#fff;font-weight:700} +.benchbtn{border:1px solid #20a53a;background:#20a53a;color:#fff;border-radius:6px;padding:8px 18px;font-size:14px;cursor:pointer} +.benchbtn:disabled{background:#ccc;border-color:#ccc;cursor:wait} +.benchhist{font-size:12px;color:#666;line-height:1.8;margin-top:4px} @@ -59,7 +62,7 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
网络
🔔 -v1.3.13 +v1.3.14 资享导航站 @@ -69,11 +72,15 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
+ + + +
@@ -90,7 +97,7 @@ function loadSort(){try{const v=JSON.parse(localStorage.getItem('fnos_sort_'+cur function saveSort(){try{localStorage.setItem('fnos_sort_'+cur,JSON.stringify(sortState))}catch(e){}} loadSort(); let curData=null; -document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));t.classList.add('active');cur=t.dataset.t;loadSort();document.getElementById('disks').innerHTML='';const ci=document.getElementById('cpuinfo');if(ci)ci.style.display='none';const cs=document.getElementById('cpusensor');if(cs)cs.style.display='none';const ca=document.getElementById('cpuadvise');if(ca)ca.style.display='none';const cp=document.getElementById('cpuperf');if(cp)cp.style.display='none';const mi2=document.getElementById('meminfo');if(mi2)mi2.style.display='none';const di=document.getElementById('diskinfo');if(di)di.style.display='none';const ni=document.getElementById('netinfo');if(ni)ni.style.display='none';load(true)}); +document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));t.classList.add('active');cur=t.dataset.t;loadSort();document.getElementById('disks').innerHTML='';const ci=document.getElementById('cpuinfo');if(ci)ci.style.display='none';const cs=document.getElementById('cpusensor');if(cs)cs.style.display='none';const ca=document.getElementById('cpuadvise');if(ca)ca.style.display='none';const cp=document.getElementById('cpuperf');if(cp)cp.style.display='none';const mi2=document.getElementById('meminfo');if(mi2)mi2.style.display='none';const di=document.getElementById('diskinfo');if(di)di.style.display='none';const ni=document.getElementById('netinfo');if(ni)ni.style.display='none';['cpubench','membench','diskbench','netbench'].forEach(id=>{const e=document.getElementById(id);if(e)e.style.display='none'});load(true)}); function fmtB(b){if(b==null)return '-';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<4){b/=1024;i++}return b.toFixed(b>=100?0:1)+u[i]} function fmtR(b){return fmtB(b)+'/s'} function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/
CPU占用
${j.cpu}%
活跃进程数
${j.active}
负载(1分钟,5分钟,15分钟)
${j.load.join(', ')}
`; -const I=j.info||{},P=j.percpu||[]; +const I=j.info||{},P=j.percpu||[],MHZ=j.mhz||[]; document.getElementById('cpuinfo').style.display=''; -document.getElementById('cpuinfo').innerHTML=`
${esc(I.model||'未知CPU')} ${esc(I.arch||'')}
物理 ${I.physical||'-'} 核 / 逻辑 ${I.logical||'-'} 核 · ${I.sockets?('共 '+I.sockets+' 路 × '+I.cores_per_socket+'核 × '+I.threads_per_core+'线程'):''} · 当前 ${I.freq_cur||'-'} MHz(${I.freq_min||'-'} ~ ${I.freq_max||'-'} MHz)
L1d ${esc(I.l1d||'-')} · L1i ${esc(I.l1i||'-')} · L2 ${esc(I.l2||'-')} · L3 ${esc(I.l3||'-')}
${P.map((v,i)=>`
#${i} ${v}%
`).join('')}
`; +document.getElementById('cpuinfo').innerHTML=`
${esc(I.model||'未知CPU')} ${esc(I.arch||'')}
物理 ${I.physical||'-'} 核 / 逻辑 ${I.logical||'-'} 核 · ${I.sockets?('共 '+I.sockets+' 路 × '+I.cores_per_socket+'核 × '+I.threads_per_core+'线程'):''} · 当前 ${I.freq_cur||'-'} MHz(${I.freq_min||'-'} ~ ${I.freq_max||'-'} MHz)
L1d ${esc(I.l1d||'-')} · L1i ${esc(I.l1i||'-')} · L2 ${esc(I.l2||'-')} · L3 ${esc(I.l3||'-')}
${P.map((v,i)=>`
#${i} ${v}%${MHZ[i]!=null?'
'+MHZ[i]+'MHz':''}
`).join('')}
`; document.getElementById('sub').textContent=`共 ${j.total} 个进程,显示占用最高的前 ${j.items.length} 个(进程CPU为单进程占用,单核满=100%,16核满=1600%,进程之和÷16≈整机占用)`; -cpuPerf();cpuAdvise();cpuSensor(); +cpuPerf();cpuAdvise();cpuSensor();showBench('cpu'); renderHeader(); renderBody(); }else if(cur==='mem'){ @@ -179,6 +186,7 @@ document.getElementById('meminfo').style.display=''; document.getElementById('meminfo').innerHTML=`
物理 ${fmtB(H2.installed||0)}(${DM.length} 条) · 系统可用 ${fmtB(H2.system_total||j.total)} · 预留 ${fmtB(H2.reserved||0)}(核显 ${fmtB(H2.vram||0)} + BIOS/PCI 保留)
` +(DM.length?`
槽位:
${DM.map(d=>row(esc((d.locator||'')+' / '+(d.bank||'')), esc(fmtB(d.size_b)+' '+d.type+' '+(d.cspeed||d.speed||'')+'MT/s '+(d.maker||'')+' '+(d.part||'')))).join('')}
`:`
未读到内存槽位表(DMI 不可用)
`) +`
占用拆解:
${row('空闲 MemFree',fmtB(D.MemFree))+row('可用 MemAvailable',fmtB(D.MemAvailable))+row('缓冲 Buffers',fmtB(D.Buffers))+row('缓存 Cached',fmtB(D.Cached))+row('Swap缓存',fmtB(D.SwapCached))+row('匿名页 AnonPages',fmtB(D.AnonPages))+row('映射 Mapped',fmtB(D.Mapped))+row('共享 Shmem',fmtB(D.Shmem))+row('Slab',fmtB(D.Slab))+row('可回收',fmtB(D.SReclaimable))+row('不可回收',fmtB(D.SUnreclaim))+row('页表',fmtB(D.PageTables))+row('内核栈',fmtB(D.KernelStack))+row('已提交',fmtB(D.Committed_AS))+row('提交上限',fmtB(D.CommitLimit))+row('脏页',fmtB(D.Dirty))}
`; +showBench('mem'); document.getElementById('sub').textContent=`按内存占用排序,前 ${j.items.length} 个进程`; renderHeader(); renderBody(); @@ -197,6 +205,7 @@ document.getElementById('diskinfo').innerHTML=`
共 ${DS.lengt +(DS.length?DS.map(d=>`
/dev/${esc(d.name||'')} ${esc(d.model||d.sys_model||'')} ${esc(d.type||'')} · ${fmtB(d.cap_b)} · ${esc(d.iface||'')} ${esc(d.health||'')}
${drow('序列号',esc(d.serial||'-'))+drow('温度',d.temp_c!=null?(d.temp_c+'°C 快照'):'-')+drow('通电',d.hours!=null?(Math.floor(d.hours/24)+' 天('+d.hours+' 小时)'):'-')+drow('通电次数',d.power_cycles!=null?d.power_cycles:'-')+drow('坏道重映射',d.realloc!=null?d.realloc:'-')+(d.wear!=null?drow('磨损',d.wear+'%'):'')+(d.helium!=null?drow('氦气',d.helium+'%'):'')+(d.rpm?drow('转速',d.rpm+' 转'):drow('转速',d.rotational?'机械':'固态'))+drow('尺寸',esc(d.form||'-'))+drow('读写累计',fmtB(d.io_read||0)+' / '+fmtB(d.io_write||0))}
`).join(''):`
未读到硬盘信息
`) +(AR.length?`
阵列拓扑(单成员 RAID1,无冗余):
${AR.map(a=>drow(esc(a.md+' → '+(a.mount||'')),esc((a.level||'')+' '+(a.members||'')+' '+(a.live_status||'')+' '+(a.live_devs||'')))).join('')+(AR.some(a=>a.live_sync)?`
${esc((AR.find(a=>a.live_sync)||{}).live_sync||'')}
`:''+(EX&&EX.mount?`
${drow('系统盘 '+esc(EX.mount||'/'),esc((EX.part||'')+'('+(EX.note||'')+')'))}
`:''))}
`:'') +((W.note)?`
${esc(W.note)}
`:''); +showBench('disk'); }else{ const up=j.nics.reduce((a,n)=>a+n.up,0),down=j.nics.reduce((a,n)=>a+n.down,0); S.innerHTML=`
上行速率
${fmtR(up)}
下行速率
${fmtR(down)}
累计接收
${fmtB(j.total_recv||0)}
累计发送
${fmtB(j.total_sent||0)}
连接数
${j.conns}
`; @@ -211,9 +220,16 @@ document.getElementById('netinfo').style.display=''; document.getElementById('netinfo').innerHTML=`
出口 ${esc(NW.egress||'-')} · 网关 ${esc(NW.gateway||'-')} · DNS ${esc(NW.dns||'-')} 共 ${NN.length} 块网卡(档案快照 ${esc(NW.snapshot||'')},地址/状态/速率为实时)
` +(NN.length?NN.map(n=>`
${esc(n.name||'')} ${esc(n.model||'')} ${esc(n.role||'')} ${esc(n.operstate||(n.isup===false?'down':'up'))}
${nrow('IPv4',esc((n.ipv4||[]).join(' / ')||'-'))+nrow('IPv6',esc((n.ipv6||[]).join(' / ')||'-'))+nrow('MAC',esc(n.address||n.mac||'-'))+nrow('协商速率',spd(n.speed||n.live_speed))+nrow('双工',esc(n.duplex||'-'))+nrow('MTU',esc(n.mtu||n.live_mtu||'-'))+nrow('驱动',esc(n.live_driver||n.driver||'-'))+(n.pci?nrow('PCI',esc(n.pci)):'')+(n.ssid?nrow('WiFi',esc(n.ssid)):'')+nrow('收发累计',fmtB(n.io_recv||0)+' / '+fmtB(n.io_sent||0))}
`).join(''):`
未读到网卡信息
`) +((NW.note)?`
${esc(NW.note)}
`:''); +showBench('net'); } }catch(e){if(manual)alert('加载失败: '+e)} } +const BENCH_TXT={cpu:['🏃 系统运行速度测试','20万次整数运算,分数越高越快;自动记录当前性能模式/governor/平均MHz,方便对比省电/平衡/高性能三档效果'],mem:['💾 内存读写测速','32MB内存写入/读取,测完即释放,不占内存'],disk:['💽 硬盘读写测速','32MB临时文件写(含落盘)/读,测完自动删除,不占空间'],net:['🌐 网络上下行测速','Cloudflare测速点:下行5MB/上行2MB,约需几秒到十几秒']}; +function benchHist(k){try{return JSON.parse(localStorage.getItem('fnos_bench_'+k)||'[]')}catch(e){return[]}} +function benchPush(k,t){try{const h=benchHist(k);h.unshift(t);localStorage.setItem('fnos_bench_'+k,JSON.stringify(h.slice(0,5)))}catch(e){}} +function renderBench(k,msg){const el=document.getElementById(k+'bench');if(!el)return;const T=BENCH_TXT[k],h=benchHist(k);el.innerHTML='
'+T[0]+'
'+T[1]+'
'+(msg||'')+'
'+(h.length?'
历史记录(最多5条,超出自动删旧):
'+h.map(x=>esc(x)).join('
')+'
':'
暂无测速记录,点「开始测速」跑一次
')} +function showBench(k){const el=document.getElementById(k+'bench');if(!el)return;el.style.display='';if(!el.innerHTML)renderBench(k)} +async function runBench(k){const b=document.getElementById('benchbtn_'+k),m=document.getElementById('benchmsg_'+k);if(b){b.disabled=true;b.textContent='测速中…'}if(m)m.textContent='正在测速,请稍候(网络测速约需十几秒)';try{const r=await fetch('/api/bench/'+k,{method:'POST'});const j=await r.json();let line='';if(j.ok){if(k==='cpu')line=j.at+' ['+j.mode+'|'+(j.governor||'-')+'] '+j.ms+'ms '+j.kops+'kops AVG'+j.mhz_avg+'MHz';else if(k==='mem')line=j.at+' W'+j.write_mbs+'MB/s R'+j.read_mbs+'MB/s';else if(k==='disk')line=j.at+' W'+j.write_mbs+'MB/s R'+j.read_mbs+'MB/s '+(j.file||'');else if(k==='net')line=j.at+' DOWN'+j.down_mbps+'Mbps'+(j.up_mbps?' UP'+j.up_mbps+'Mbps':' UPFAIL');benchPush(k,line);renderBench(k,'OK '+line)}else renderBench(k,'FAIL '+(j.error||'ERR'))}catch(e){renderBench(k,'FAIL '+e)}} load(true); let ivalSec=3,timer=null,paused=false; function startTimer(){if(timer)clearInterval(timer);if(!paused)timer=setInterval(()=>load(false),ivalSec*1000)} @@ -222,7 +238,7 @@ function togglePause(){paused=!paused;document.getElementById('pauseBtn').textCo startTimer(); async function licPoll(){try{const r=await fetch('/api/license-config',{cache:'no-store'});const j=await r.json();LIC=j; const dot=document.getElementById('licDot');if(dot)dot.style.display=(j.announcements&&j.announcements.length)?'':'none'; -const v=document.getElementById('licVer');if(v)v.textContent='v'+(j.version||'1.3.13')+(j.update_available?' ●':''); +const v=document.getElementById('licVer');if(v)v.textContent='v'+(j.version||'1.3.14')+(j.update_available?' ●':''); if(j.popup&&j.popup.length){try{const seen=JSON.parse(localStorage.getItem('fnos_ann_seen')||'[]');const fresh=j.popup.filter(a=>seen.indexOf(a.id)<0);if(fresh.length){const a=fresh[0];const el=document.getElementById('annPop');el.innerHTML=`
📢 ${esc(a.title||'公告')}✕
${a.content||''}
`;el.style.display='';}}catch(e){}} if(document.getElementById('annPanel').style.display!=='none')renderAnn();}catch(e){}} function renderAnn(){const el=document.getElementById('annPanel');const list=LIC.announcements||[];el.innerHTML=list.length?(`📢 公告(${list.length})`+list.map(a=>`
${esc(a.title||'')}
${a.content||''}
`).join('')):'暂无公告';}