Compare commits

..
17 Commits
Author SHA1 Message Date
jinghui eab6475199 feat: 右上角加刷新间隔设置与停止/恢复刷新 2026-11-07 14:31:02 +08:00
jinghui b9757e2ccb feat: 内存回收改为大按钮样式 + 恢复Docker源码 2026-11-07 14:24:37 +08:00
jinghui de153140f4 docs: CPU截图换高清半屏版,删旧长图 2026-11-07 14:18:17 +08:00
jinghui 41194e2935 docs: 高清半屏截图,每页一图 2026-11-07 14:15:28 +08:00
jinghui 082a94293a chore: remove FPK packaging, docker-only 2026-11-07 14:03:40 +08:00
jinghui bf5a1c7301 更新 README.md 2026-11-06 17:28:30 +08:00
jinghui 6620377934 更新 README.md 2026-11-06 17:25:07 +08:00
jinghui 81df67fb20 更新 README.md 2026-11-06 17:21:11 +08:00
jinghui 96442db19c 上传文件至「docs」 2026-11-06 17:19:26 +08:00
jinghui 0936a86d0e 更新 README.md 2026-11-06 17:18:48 +08:00
jinghui ed23092cd6 feat: rename memory recycle button 2026-11-06 17:16:27 +08:00
jinghui d5e78908fb docs: full-page screenshots 2026-11-06 17:10:57 +08:00
jinghui e0627a9158 docs: add per-page screenshots 2026-11-06 17:08:54 +08:00
jinghui 986dc63179 更新 README.md 2026-11-06 17:02:22 +08:00
jinghui fc764b9faa 更新 README.md 2026-11-06 16:52:04 +08:00
jinghui 44eccf9a75 更新 README.md 2026-11-06 16:43:52 +08:00
jinghui 1abd96f081 v1.2.6 version in top-right 2026-11-06 16:37:59 +08:00
52 changed files with 1152 additions and 4917 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask psutil -i https://mirrors.aliyun.com/pypi/simple/
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 util-linux && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir flask psutil -i https://mirrors.aliyun.com/pypi/simple/
COPY app.py .
COPY hw_mem.json .
COPY hw_disk.json .
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-192
View File
@@ -1,192 +0,0 @@
1|# 💻 fnmon — fnOS 全功能系统监控面板
2|
3|> 一款专为飞牛 fnOS 打造的**实时系统监控面板**,一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面!
4|
5|---
6|
7|## 🎯 简介
8|
9|**fnmon**是一个轻量级的 Web 监控工具,以 Docker 容器方式运行在飞牛系统上。无需安装额外驱动、无需修改系统文件,一条命令跑起来就能用。
10|
11|它提供了传统 NAS 系统(如群晖 DSM、威联通 QTS)才有的可视化监控体验,但专为飞牛 OS 深度定制。
12|
13|---
14|
15|## ✨ 功能亮点
16|
17|### ⚡ CPU 监控
18|- 实时频率显示(每个核心独立展示)
19|- Governor / EPP 运行模式
20|- 核心温度(支持 SATA/HDD/SDD/NVMe)
21|- Turbo Boost / 超频检测
22|- 60 秒频率历史曲线图
23|- **CPU 评测跑分数据**(PassMark、Cinebench、Geekbench)
24|- **同级 CPU 横向对比柱状图**(N100/J4125/i5-8400T/i3-12100...)
25|
26|### 🧠 内存监控
27|- 总容量 / 已用 / 可用 / 缓存
28|- SWAP 使用情况
29|- 内存历史趋势图
30|
31|### 🌐 网络监控
32|- 实时网速(上传/下载)
33|- 总流量统计
34|- **Docker 容器流量排行**(哪个容器最占带宽一目了然)
35|- 按日/月/年统计
36|
37|### 💾 磁盘监控
38|- 所有磁盘设备列表
39|- **物理硬盘健康状态**(SMART 信息)
40| - 🌡 硬盘温度
41| - ❤️ NVMe 寿命百分比
42| - ✅ SATA 健康状态(PASSED/FAILED)
43| - 🔄 通电时间
44| - 💾 总写入量
45|
46|### 📱 应用用量排行
47|- 综合 CPU + 内存 + 网络 三维度评分
48|- 按 **日/周/月/年/总计** 排行
49|- 一键切换时间维度
50|
51|### 🔝 进程 CPU 占用排行(TOP 20)
52|- 实时刷新,按 CPU 占用排序
53|- 显示 PID、用户、启动时间
54|- CPU% 颜色标识(红 > 50% / 橙 > 20% / 黄 > 5%)
55|
56|---
57|
58|## 📷 界面预览
59|
60|```
61|+----------------------------------------------------------+
62|| ⚡ CPU i3-9100T@ 3.10GHz 💠 4C |
63|| Governor EPP 温度 频率范围 负载 风扇 模式 Boost |
64|| ███████ ███ ███ ███████ ███ ███ ███ ████████ |
65|+----------------------------------------------------------+
66|| [CPU0 799MHz ████] [CPU1 800MHz ████] |
67|| [CPU2 799MHz ████] [CPU3 800MHz ████] |
68|+----------------------------------------------------------+
69|| 📈 频率历史 (60s) |
70|| ╱╲ |
71|| ╱ ╲___ 频率曲线图 |
72|+----------------------------------------------------------+
73|| 🆚 同级CPU对比 (PassMark多核) |
74|| i3-9100T ← 你 ██████████ 5500 |
75|| i5-8400T ████████████████ 8500 |
76|| N100 ████████ 4200 |
77|+----------------------------------------------------------+
78|| 📊 性能评测 |
79|| PassMark: 5500 Cinebench: 3200 Geekbench: 3500 |
80|+----------------------------------------------------------+
81|| 🔝 进程排行 | PID | 用户 | CPU% | 内存 | 启动 | 进程名 |
82|+----------------------------------------------------------+
83|```
84|
85|(实际界面为深色主题,数据实时刷新,更美观)
86|
87|---
88|
89|## 🚀 快速安装
90|
91|### 方式一:Docker 一键运行(推荐 👍)
92|
93|```bash
94|docker run -d \
95| --name fnmon \
96| -p 5000:5000 \
97| --privileged \
98| -v /proc:/proc:ro \
99| -v /sys:/sys:ro \
100| jinghui1984/fnmon:latest
101|```
102|
103|启动后浏览器访问:**http://飞牛IP:5000**
104|
105|### 方式二:Docker Compose
106|
107|创建 `docker-compose.yml`:
108|
109|```yaml
110|version: '3'
111|services:
112| fnmon:
113| image: jinghui1984/fnmon:latest
114| container_name: fnmon
115| restart: always
116| ports:
117| - "5000:5000"
118| privileged: true
119| volumes:
120| - /proc:/proc:ro
121| - /sys:/sys:ro
122|```
123|
124|运行:
125|```bash
126|docker compose up -d
127|```
128|
129|### 方式三:SSH 离线安装
130|
131|下载 fpk + 脚本:
132|
133|| 文件 | 说明 |
134||------|------|
135|| `fnmon-v1.0.15.fpk` | fnOS 应用包(44KB) |
136|| `install.sh` | 一键安装脚本 |
137|
138|```bash
139|sudo bash install.sh
140|```
141|
142|---
143|
144|## ⚙️ 技术架构
145|
146|```
147|┌─────────────┐ ┌─────────────┐ ┌─────────────┐
148|│ Flask 后端 │────▶│ SQLite 存储 │────▶│ 前端 Dashboard │
149|│ (Python) │ │ (用量追踪) │ │ (HTML+JS) │
150|└──────┬──────┘ └─────────────┘ └─────────────┘
151| │
152| ├──▶ psutil / smartctl / /proc (系统数据采集)
153| ├──▶ Docker API (容器流量)
154| └──▶ SSE 推送 (实时刷新)
155|```
156|
157|---
158|
159|## 📦 下载链接
160|
161|| 资源 | 链接 / 位置 |
162||------|------------|
163|| Docker Hub | `jinghui1984/fnmon:latest` |
164|| Docker Compose | 见上方 `docker-compose.yml` |
165|| 离线 fpk | 评论区下载 |
166|
167|---
168|
169|## 🔄 更新计划
170|
171|- [x] CPU 频率 + 核心温度监控
172|- [x] 内存 / SWAP 趋势图
173|- [x] 网络流量 + 容器排行
174|- [x] 硬盘健康 (SMART / NVMe 寿命)
175|- [x] 进程 CPU 排行
176|- [x] 应用用量排行(日/周/月/年)
177|- [x] CPU 评测 + 对比
178|- [ ] 暗色/亮色主题切换
179|- [ ] 自定义端口配置
180|- [ ] 微信/邮件告警(CPU温度过高)
181|- [ ] 多语言支持
182|
183|---
184|
185|## 💬 交流反馈
186|
187|- 使用中遇到问题请在评论区留言
188|- 想加新功能请在这里提出建议
189|- GitHub:https://github.com/jinghui1984/fnmon
190|
191|**觉得好用的话记得回帖支持一下!** 🎉
192|
+9 -28
View File
@@ -1,12 +1,8 @@
# 飞牛资源管理器 (fnos-resmon)
# 飞牛NAS必装:进程级资源监控,比自带强10倍,一眼看穿谁在偷吃你的CPU/内存/磁盘/网络全透视,带一键内存回收的监控面板
CPU/内存/磁盘/网络 + 进程排行 + 一键回收 + SMART/连接明细,Docker一键部署
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
![截图](docs/screenshot.png)
[![Gitea](https://img.shields.io/badge/Gitea-fnos--resmon-blue)](https://gitea.88531.cn/jinghui/fnos-resmon)
[![镜像](https://img.shields.io/badge/Docker-TCR-green)](https://gitea.88531.cn/jinghui/fnos-resmon)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
---
@@ -39,7 +35,13 @@
## 📸 功能截图
![主界面](docs/screenshot.png)
![CPU](docs/cpu.png)
![内存](docs/mem.png)
![磁盘](docs/disk.png)
![网络](docs/net.png)
---
@@ -86,11 +88,6 @@ docker run -d --name fnos-resmon -p 7031:7031 --privileged \
启动后浏览器访问 `http://你的NASIP:7031` 即可。
### 方式二:飞牛应用中心安装 FPK
1. 下载 [Releases](https://gitea.88531.cn/jinghui/fnos-resmon/releases) 中的 FPK
2. 飞牛应用中心 → 本地安装 → 选择 FPK
3. 安装完成后打开「飞牛资源管理器」
---
@@ -111,22 +108,6 @@ docker run -d --name fnos-resmon -p 7031:7031 --privileged \
|------|------|
| Docker 镜像 | `jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest` |
| Gitea 仓库 | https://gitea.88531.cn/jinghui/fnos-resmon |
| FPK 包 | [Releases](https://gitea.88531.cn/jinghui/fnos-resmon/releases) |
---
## 🔄 自动更新
执行仓库根目录的 `install.sh` 即可自动更新(git pull + docker compose up -d --build):
```bash
curl -fsSL https://gitea.88531.cn/jinghui/fnos-resmon/raw/branch/main/install.sh | bash
```
或手动:
```bash
cd /vol1/1000/HD1/docker/fnos-resmon && git pull && docker compose up -d --build
```
欢迎 Issue 和 PR!
+931 -726
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
-827
View File
@@ -1,827 +0,0 @@
from flask import Flask, jsonify, request, send_from_directory
import psutil, os, time, signal, threading, subprocess, platform, glob, json, struct
try:
psutil.PROCFS_PATH = os.environ.get("PROCFS_PATH", "/proc")
except Exception:
pass
PROCFS = os.environ.get("PROCFS_PATH", "/host/proc")
if os.path.isdir(PROCFS):
psutil.PROCFS_PATH = PROCFS
app = Flask(__name__, static_folder="public", static_url_path="")
PORT = int(os.environ.get("PORT", "7031"))
# ---------- background sampler ----------
cache = {"procs": [], "cpu": 0.0, "load": [0, 0, 0], "ts": 0}
net_prev = {}
net_rate = {}
def fmt_status(s):
m = {
psutil.STATUS_RUNNING: ("活动", 1),
psutil.STATUS_SLEEPING: ("睡眠", 0),
psutil.STATUS_DISK_SLEEP: ("睡眠", 0),
psutil.STATUS_STOPPED: ("停止", 0),
psutil.STATUS_ZOMBIE: ("僵尸", 0),
}
return m.get(s, ("睡眠", 0))
_uid_cache = {"map": {}, "ts": 0}
def uid_name(uid_str):
# 容器内 /etc/passwd 没有宿主用户,uid 会显示成数字,用宿主 passwd 还原
if not uid_str or not uid_str.isdigit():
return uid_str
now = time.time()
if now - _uid_cache["ts"] > 300:
m = {}
try:
with open("/host/etc/passwd") as f:
for line in f:
p = line.split(":")
if len(p) > 3:
try:
m[p[2]] = p[0]
except Exception:
pass
except Exception:
pass
_uid_cache["map"] = m
_uid_cache["ts"] = now
return _uid_cache["map"].get(uid_str, uid_str)
def sample_procs():
# init cpu percent calculation
for p in psutil.process_iter():
try:
p.cpu_percent(None)
except Exception:
pass
time.sleep(1.0)
procs = []
try:
cpu_total = psutil.cpu_percent(interval=None)
except Exception:
cpu_total = 0
try:
load = list(os.getloadavg())
except Exception:
load = [0, 0, 0]
for p in psutil.process_iter(["pid", "name", "username", "status", "num_threads", "cmdline"]):
try:
with p.oneshot():
cpu = p.cpu_percent(None)
mem = p.memory_percent()
try:
rss = p.memory_info().rss
except Exception:
rss = 0
uss = 0
swap = 0
try:
mfi = p.memory_full_info()
uss = getattr(mfi, "uss", 0) or 0
swap = getattr(mfi, "swap", 0) or 0
except Exception:
pass
try:
io = p.io_counters()
rd, wr = io.read_bytes, io.write_bytes
except Exception:
rd, wr = 0, 0
try:
conns = len(p.net_connections())
except Exception:
conns = 0
info = p.info
name = info.get("name") or ""
cmd = " ".join(info.get("cmdline") or [])[:120]
desc = cmd if cmd else name
st, act = fmt_status(info.get("status"))
if (not act) and (cpu or 0) > 1.0:
st, act = "活动", 1
procs.append({
"name": name[:40], "pid": info["pid"],
"cpu": round(cpu or 0, 2), "mem": round(mem or 0, 2),
"rss": rss, "uss": uss, "swap": swap, "threads": info.get("num_threads") or 0,
"user": uid_name(str(info.get("username") or ""))[:20],
"status": st, "active": act,
"desc": desc[:80], "io_r": rd, "io_w": wr, "conns": conns,
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except Exception:
continue
procs.sort(key=lambda x: x["cpu"], reverse=True)
cache["procs"] = procs
cache["cpu"] = round(cpu_total, 1)
cache["load"] = [round(x, 2) for x in load]
cache["ts"] = time.time()
def sampler_loop():
while True:
try:
sample_procs()
except Exception:
pass
time.sleep(2)
threading.Thread(target=sampler_loop, daemon=True).start()
# warm up immediately in background; serve stale until ready
@app.route("/")
def index():
return send_from_directory("public", "index.html")
@app.route("/api/cpu")
def api_cpu():
active = sum(1 for p in cache["procs"] if p["active"])
return jsonify({"cpu": cache["cpu"], "load": cache["load"],
"active": active, "total": len(cache["procs"]),
"info": get_cpu_info(), "percpu": get_percpu(),
"items": cache["procs"][:100]})
_cpu_static = {}
def _parse_host_cpuinfo():
info = {}
try:
with open("/host/proc/cpuinfo") as f:
txt = f.read()
except Exception:
try:
with open("/proc/cpuinfo") as f:
txt = f.read()
except Exception:
return info
# 取第一个 processor 段的 model name / cache size / cpu MHz
for line in txt.splitlines():
if ":" not in line:
continue
k, v = line.split(":", 1)
k, v = k.strip(), v.strip()
if k == "model name" and "model" not in info:
info["model"] = v
elif k == "cache size" and "cache" not in info:
info["cache"] = v
return info
def _parse_lscpu():
out = {}
try:
txt = subprocess.run(["lscpu"], capture_output=True, text=True,
timeout=5).stdout
except Exception:
return out
for line in txt.splitlines():
if ":" not in line and ":" not in line:
continue
line = line.replace(":", ":")
k, v = line.split(":", 1)
k, v = k.strip(), v.strip()
out[k] = v
return out
def get_cpu_info():
global _cpu_static
if not _cpu_static:
ci = _parse_host_cpuinfo()
lc = _parse_lscpu()
def pick(*keys):
for k in keys:
if k in lc and lc[k]:
return lc[k]
return ""
# L1d/L1i/L2/L3 键名中英文兼容
l1d = pick("L1d 缓存", "L1d cache")
l1i = pick("L1i 缓存", "L1i cache")
l2 = pick("L2 缓存", "L2 cache")
l3 = pick("L3 缓存", "L3 cache")
_cpu_static = {
"model": ci.get("model") or pick("型号名称", "Model name"),
"arch": platform.machine(),
"sockets": pick("座", "Socket(s)"),
"cores_per_socket": pick("每个座的核数", "Core(s) per socket"),
"threads_per_core": pick("每个核的线程数", "Thread(s) per core"),
"l1d": l1d, "l1i": l1i, "l2": l2, "l3": l3,
"cache": ci.get("cache", ""),
}
try:
f = psutil.cpu_freq()
cur = round(f.current, 0) if f else 0
fmin = round(f.min, 0) if f and f.min else 0
fmax = round(f.max, 0) if f and f.max else 0
except Exception:
cur = fmin = fmax = 0
d = dict(_cpu_static)
d.update({
"logical": psutil.cpu_count() or 0,
"physical": psutil.cpu_count(logical=False) or 0,
"freq_cur": cur, "freq_min": fmin, "freq_max": fmax,
})
return d
def get_percpu():
try:
vals = psutil.cpu_percent(interval=None, percpu=True)
return [round(v, 1) for v in (vals or [])]
except Exception:
return []
@app.route("/api/mem")
def api_mem():
vm = psutil.virtual_memory()
sm = psutil.swap_memory()
items = sorted(cache["procs"], key=lambda x: x["mem"], reverse=True)[:100]
mi = _read_meminfo_host()
dm = get_mem_hw()
return jsonify({
"total": vm.total, "used": vm.used, "available": vm.available,
"percent": vm.percent, "cached": getattr(vm, "cached", 0),
"swap_total": sm.total, "swap_used": sm.used, "swap_percent": sm.percent,
"detail": mi, "hw": dm,
"items": items})
_mem_hw_static = {}
def _read_meminfo_host():
mi = {}
for p in ("/host/proc/meminfo", "/proc/meminfo"):
try:
with open(p) as f:
for line in f:
if ":" not in line:
continue
k, v = line.split(":", 1)
parts = v.strip().split()
if not parts:
continue
try:
num = int(parts[0])
except ValueError:
continue
# 统一转 bytes
if len(parts) > 1 and parts[1] == "kB":
num *= 1024
mi[k.strip()] = num
if mi:
break
except Exception:
continue
return mi
def _walk_dmi(path):
"""yield (type, raw_bytes, strings)"""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return
off, n = 0, 0
L = len(data)
while off + 4 <= L and n < 200:
typ, ln = data[off], data[off + 1]
if ln < 4 or off + ln > L:
break
if typ == 127: # end of table
break
s = off + ln
strs = []
ok = True
while s < L:
try:
e = data.index(b"\x00", s)
except ValueError:
ok = False
break
if e == s:
s += 1
break
strs.append(data[s:e].decode("utf-8", "replace").strip())
s = e + 1
if not ok:
break
yield typ, data[off:off + ln], strs
off = s
n += 1
_MEM_TYPE = {0x02: "Unknown", 0x03: "DRAM", 0x12: "DDR", 0x13: "DDR2",
0x14: "DDR2 FB-DIMM", 0x18: "DDR3", 0x19: "FBD2", 0x1A: "DDR4",
0x1B: "LPDDR", 0x1C: "LPDDR2", 0x1D: "LPDDR3", 0x1E: "LPDDR4",
0x1F: "Logical NV", 0x20: "HBM", 0x21: "HBM2", 0x22: "DDR5",
0x23: "LPDDR5", 0x24: "HBM3"}
def _parse_dmi_mem():
dimms, max_cap_kb = [], 0
for dmi_path in ("/host/sys/firmware/dmi/tables/DMI",
"/sys/firmware/dmi/tables/DMI"):
found = False
for typ, raw, strs in _walk_dmi(dmi_path):
found = True
def S(i):
return strs[i - 1] if 1 <= i <= len(strs) else ""
if typ == 16 and len(raw) >= 11:
import struct
(cap,) = struct.unpack_from("<I", raw, 7)
if cap not in (0, 0x80000000):
max_cap_kb = cap # SMBIOS 单位 kB
elif typ == 17 and len(raw) >= 28:
import struct
(size,) = struct.unpack_from("<H", raw, 12)
if size == 0 or size == 0xFFFF:
continue # 空槽
if size & 0x8000:
mb = size & 0x7FFF # bit15=1 时单位 KB→转MB(向上)
size_mb = max(mb // 1024, 1) if mb >= 1024 else 1
size_b = (size & 0x7FFF) * 1024
else:
size_mb = size & 0x7FFF
size_b = size_mb * 1024 * 1024
if size_mb == 0x7FFF:
continue
(speed,) = struct.unpack_from("<H", raw, 20)
cspeed = 0
if len(raw) >= 30:
(cspeed,) = struct.unpack_from("<H", raw, 28)
mtype = raw[18] if len(raw) > 18 else 0
dimms.append({
"locator": S(raw[16]) if len(raw) > 16 else "",
"bank": S(raw[17]) if len(raw) > 17 else "",
"size_b": size_b, "size_mb": size_mb,
"type": _MEM_TYPE.get(mtype, "DDR4" if mtype == 0 else hex(mtype)),
"speed": speed or 0, "cspeed": cspeed or 0,
"maker": S(raw[22]) if len(raw) > 22 else "",
"serial": S(raw[23]) if len(raw) > 23 else "",
"part": S(raw[25]) if len(raw) > 25 else "",
})
if found:
break
return dimms, max_cap_kb
def _get_vram():
total = 0
for base in ("/host/sys/class/drm", "/sys/class/drm"):
try:
for p in glob.glob(base + "/card*/device/mem_info_vram_total"):
try:
with open(p) as f:
total += int(f.read().strip())
except Exception:
pass
except Exception:
pass
if total:
break
return total
def get_mem_hw():
global _mem_hw_static
if not _mem_hw_static:
dimms, max_cap_kb = [], 0
# 优先读构建时从宿主 dmidecode 导出的静态槽位表
for jp in ("hw_mem.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_mem.json")):
try:
with open(jp) as f:
hj = json.load(f)
dimms = hj.get("dimms", []) or []
max_cap_kb = int((hj.get("max_cap_b", 0) or 0) // 1024)
if dimms:
break
except Exception:
continue
if not dimms:
dimms, max_cap_kb = _parse_dmi_mem()
installed = sum(d["size_b"] for d in dimms)
_mem_hw_static = {"dimms": dimms, "installed": installed,
"max_cap_kb": max_cap_kb}
d = {"dimms": _mem_hw_static["dimms"],
"installed": _mem_hw_static["installed"],
"max_cap_kb": _mem_hw_static["max_cap_kb"]}
try:
mem_total = psutil.virtual_memory().total
except Exception:
mem_total = 0
d["vram"] = _get_vram()
d["system_total"] = mem_total
d["reserved"] = (d["installed"] - mem_total) if d["installed"] and mem_total else 0
return d
HOST_MNTS = [("/", "/host"), ("/vol1", "/host/vol1"),
("/vol2", "/host/vol2"), ("/vol3", "/host/vol3")]
def _df_host():
"""容器内 df 直读宿主路径(经 bind 进来的宿主目录),返回 {mnt: {...}}"""
try:
out = subprocess.run(
["df", "-B1", "--output=source,target,fstype,size,used,avail",
"/host", "/host/vol1", "/host/vol2", "/host/vol3"],
capture_output=True, text=True, timeout=5).stdout.splitlines()
res = {}
for line in out[1:]:
p = line.split()
if len(p) < 6:
continue
dev, tgt, fst, size, used, avail = p[0], p[1], p[2], int(p[3]), int(p[4]), int(p[5])
mnt = tgt.replace("/host", "", 1) or "/"
res[mnt] = {"device": dev, "mount": mnt, "fstype": fst,
"total": size, "used": used, "free": avail,
"percent": round(used / size * 100, 1) if size else 0}
return res
except Exception:
return {}
@app.route("/api/mem/drop", methods=["POST"])
def api_mem_drop():
import subprocess
try:
subprocess.run(["sync"], check=False, timeout=10)
p = os.environ.get("PROCFS_PATH", "/proc")
with open(os.path.join(p, "sys", "vm", "drop_caches"), "w") as f:
f.write("3")
vm = psutil.virtual_memory()
return jsonify({"ok": True, "available": vm.available, "percent": vm.percent})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/disk")
def api_disk():
parts = []
dfm = _df_host()
for mp, hp in HOST_MNTS:
if mp in dfm:
parts.append(dfm[mp])
continue
try:
st = os.statvfs(hp)
total = st.f_blocks * st.f_frsize
free = st.f_bavail * st.f_frsize
used = total - free
parts.append({"device": "?", "mount": mp, "fstype": "?",
"total": total, "used": used, "free": free,
"percent": round(used / total * 100, 1) if total else 0})
except Exception:
continue
try:
dio = psutil.disk_io_counters()
dio_d = {"read": dio.read_bytes, "write": dio.write_bytes} if dio else {}
except Exception:
dio_d = {}
# top io processes
items = sorted(cache["procs"], key=lambda x: x["io_r"] + x["io_w"], reverse=True)[:60]
return jsonify({"parts": parts, "io": dio_d, "hw": get_disk_hw(),
"items": items})
_disk_hw_static = {}
def _read(p, default=""):
try:
with open(p) as f:
return f.read().strip()
except Exception:
return default
def _sys_block_base():
for base in ("/host/sys/block", "/sys/block"):
try:
if os.path.isdir(base):
return base
except Exception:
continue
return "/host/sys/block"
def _live_sysfs(name):
"""实时 sysfs: 容量/转速/型号(覆盖静态快照中的漂移项)"""
base = _sys_block_base()
d = os.path.join(base, name)
out = {}
try:
sectors = _read(os.path.join(d, "size"))
if sectors.isdigit():
out["cap_b"] = int(sectors) * 512
except Exception:
pass
rot = _read(os.path.join(d, "queue/rotational"))
if rot in ("0", "1"):
out["rotational"] = (rot == "1")
model = _read(os.path.join(d, "device/model"))
if model:
out["sys_model"] = model
vendor = _read(os.path.join(d, "device/vendor"))
if vendor:
out["sys_vendor"] = vendor
return out
def _live_mdstat():
"""实时 mdstat: 每组阵列状态/同步进度,键为 md 名"""
st = {}
for p in ("/host/proc/mdstat", "/proc/mdstat"):
try:
with open(p) as f:
txt = f.read()
break
except Exception:
txt = ""
else:
return st
import re
cur = None
for line in txt.splitlines():
m = re.match(r"^(md\d+)\s*:\s*(\w+)\s+(\w+)(.*)$", line)
if m:
cur = m.group(1)
st[cur] = {"status": m.group(2), "level": m.group(3),
"devs": m.group(4).strip(), "sync": ""}
continue
if cur and ("recovery" in line or "resync" in line or "blocks" in line):
s = line.strip()
if "recovery" in line or "resync" in line:
st[cur]["sync"] = s
elif "blocks" in line and not st[cur].get("blocks"):
st[cur]["blocks"] = s
return st
def _live_perdisk_io():
try:
d = psutil.disk_io_counters(perdisk=True) or {}
except Exception:
return {}
out = {}
for k, v in d.items():
try:
out[k] = {"read": v.read_bytes, "write": v.write_bytes}
except Exception:
continue
return out
def get_disk_hw():
global _disk_hw_static
if not _disk_hw_static:
hj = {}
for jp in ("hw_disk.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_disk.json")):
try:
with open(jp) as f:
hj = json.load(f)
if hj.get("disks"):
break
except Exception:
continue
_disk_hw_static = hj or {"disks": [], "arrays": []}
disks = [dict(x) for x in _disk_hw_static.get("disks", [])]
arrays = [dict(x) for x in _disk_hw_static.get("arrays", [])]
# 合并实时数据
pio = _live_perdisk_io()
for dk in disks:
nm = dk.get("name", "")
dk.update(_live_sysfs(nm))
if nm in pio:
dk["io_read"] = pio[nm]["read"]
dk["io_write"] = pio[nm]["write"]
# 转速文字
if dk.get("rotational") is True and not dk.get("rpm"):
dk["rpm"] = 5400
md_live = _live_mdstat()
for a in arrays:
md = a.get("md", "")
lv = md_live.get(md, {})
if lv:
a["live_status"] = lv.get("status", "")
a["live_devs"] = lv.get("devs", "")
a["live_sync"] = lv.get("sync", "")
if md in pio:
a["io_read"] = pio[md]["read"]
a["io_write"] = pio[md]["write"]
return {"disks": disks, "arrays": arrays,
"extra": _disk_hw_static.get("extra", {}),
"snapshot": _disk_hw_static.get("snapshot", ""),
"note": _disk_hw_static.get("note", "")}
@app.route("/api/net")
def api_net():
global net_prev, net_rate
now = time.time()
try:
counters = psutil.net_io_counters(pernic=True)
except Exception:
counters = {}
rates = []
for nic, c in counters.items():
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
prev = net_prev.get(nic)
if prev:
dt = max(now - prev["t"], 0.5)
up = (c.bytes_sent - prev["sent"]) / dt
down = (c.bytes_recv - prev["recv"]) / dt
else:
up = down = 0
net_prev[nic] = {"sent": c.bytes_sent, "recv": c.bytes_recv, "t": now}
rates.append({"nic": nic, "up": max(up, 0), "down": max(down, 0),
"sent": c.bytes_sent, "recv": c.bytes_recv})
rates.sort(key=lambda x: x["up"] + x["down"], reverse=True)
try:
conns = len(psutil.net_connections())
except Exception:
conns = 0
total_recv = 0
total_sent = 0
for nic, c in counters.items():
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
total_recv += getattr(c, "bytes_recv", 0) or 0
total_sent += getattr(c, "bytes_sent", 0) or 0
items = sorted(cache["procs"], key=lambda x: x["conns"], reverse=True)[:60]
tcp_list = []
listen_list = []
try:
name_by_pid = {p["pid"]: p["name"] for p in cache.get("procs", [])}
for c in psutil.net_connections(kind="tcp"):
laddr = f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else ""
raddr = f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else ""
family = "ipv6" if ":" in (c.laddr.ip if c.laddr else "") and "." not in (c.laddr.ip if c.laddr else "") else "ipv4"
# better family detection
import socket
family = "ipv6" if (c.family == socket.AF_INET6) else "ipv4"
if c.status == "LISTEN":
listen_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": c.laddr.ip if c.laddr else "", "port": c.laddr.port if c.laddr else 0, "family": family, "pid": c.pid, "proto": "tcp"})
else:
tcp_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": laddr, "raddr": raddr, "family": family, "pid": c.pid, "status": c.status})
except Exception:
pass
tcp_list = tcp_list[:80]
listen_list = listen_list[:80]
return jsonify({"nics": rates, "conns": conns, "items": items,
"hw": get_net_hw(),
"total_recv": total_recv, "total_sent": total_sent,
"tcp": tcp_list, "listen": listen_list})
_net_hw_static = {}
def _net_sysfs(name):
"""实时 sysfs: 状态/MTU/速率/MAC,键名与 psutil 速率互补"""
out = {}
for base in ("/host/sys/class/net", "/sys/class/net"):
d = os.path.join(base, name)
try:
if not os.path.isdir(d):
continue
for k in ("address", "operstate", "mtu", "speed", "duplex",
"carrier"):
v = _read(os.path.join(d, k))
if v:
out[k] = v
# 驱动名
try:
drv = os.path.basename(os.readlink(os.path.join(d, "device/driver")))
if drv:
out["live_driver"] = drv
except Exception:
pass
if out:
break
except Exception:
continue
return out
def _net_addrs():
try:
ad = psutil.net_if_addrs() or {}
except Exception:
return {}
out = {}
for nic, lst in ad.items():
v4, v6, mac = [], [], ""
for a in lst:
try:
fam = a.family.name if hasattr(a.family, "name") else str(a.family)
except Exception:
fam = ""
addr = a.address or ""
if "PACKET" in fam:
if addr:
mac = mac or addr
elif "AF_INET6" in fam:
if addr and not addr.startswith("fe80"):
v6.append(addr.split("%")[0])
elif addr:
v6.append(addr.split("%")[0] + "(链路)")
elif "AF_INET" in fam:
if addr and len(addr.split(".")) == 4:
v4.append(addr)
out[nic] = {"v4": v4, "v6": v6, "mac": mac}
return out
def get_net_hw():
global _net_hw_static
if not _net_hw_static:
hj = {}
for jp in ("hw_net.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_net.json")):
try:
with open(jp) as f:
hj = json.load(f)
if hj.get("nics"):
break
except Exception:
continue
_net_hw_static = hj or {"nics": []}
statics = {x.get("name"): dict(x) for x in _net_hw_static.get("nics", [])}
try:
st = psutil.net_if_stats() or {}
except Exception:
st = {}
addrs = _net_addrs()
# 展示的网卡: 静态档案 + 实际有流量的非虚拟口
names = list(statics.keys())
try:
counters = psutil.net_io_counters(pernic=True) or {}
except Exception:
counters = {}
for nic in counters:
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
if nic not in statics:
names.append(nic)
out = []
for nic in names:
base = dict(statics.get(nic, {"name": nic}))
s = st.get(nic)
if s is not None:
try:
base["isup"] = bool(s.isup)
base["live_speed"] = s.speed or 0
base["live_mtu"] = s.mtu or 0
except Exception:
pass
base.update(_net_sysfs(nic))
a = addrs.get(nic, {})
if a:
base["ipv4"] = a.get("v4", [])
base["ipv6"] = a.get("v6", [])
if a.get("mac") and not base.get("mac"):
base["mac"] = a["mac"]
c = counters.get(nic)
if c is not None:
try:
base["io_sent"] = c.bytes_sent
base["io_recv"] = c.bytes_recv
except Exception:
pass
out.append(base)
# 默认网关/DNS 实时读
gw, dns = _net_hw_static.get("gateway", ""), _net_hw_static.get("dns", "")
base_if = ""
try:
with open("/host/proc/net/route" if os.path.exists("/host/proc/net/route") else "/proc/net/route") as f:
for line in f.read().splitlines()[1:]:
f_ = line.split()
if len(f_) > 2 and f_[1] == "00000000" and f_[7] == "00000000":
import socket, struct
gw = socket.inet_ntoa(struct.pack("<L", int(f_[2], 16)))
base_if = f_[0]
break
except Exception:
pass
try:
rp = "/host/etc/resolv.conf" if os.path.exists("/host/etc/resolv.conf") else "/etc/resolv.conf"
with open(rp) as f:
for line in f:
if line.startswith("nameserver"):
dns = line.split()[1]
break
except Exception:
pass
return {"nics": out, "gateway": gw, "dns": dns,
"egress": base_if,
"snapshot": _net_hw_static.get("snapshot", ""),
"note": _net_hw_static.get("note", "")}
@app.route("/api/kill", methods=["POST"])
def api_kill():
d = request.get_json(force=True, silent=True) or {}
try:
pid = int(d.get("pid", 0))
except Exception:
return jsonify({"ok": False, "error": "pid无效"}), 400
if pid <= 1:
return jsonify({"ok": False, "error": "不能结束该进程"}), 400
try:
os.kill(pid, signal.SIGTERM)
return jsonify({"ok": True})
except ProcessLookupError:
return jsonify({"ok": False, "error": "进程已不存在"}), 404
except PermissionError:
return jsonify({"ok": False, "error": "无权限"}), 403
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/health")
def health():
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)
-16
View File
@@ -1,16 +0,0 @@
services:
fnos-resmon:
image: 192.168.68.19:3200/jinghui/fnos-resmon:latest
container_name: fnos-resmon
network_mode: host
pid: host
restart: unless-stopped
environment:
- PORT=7031
- PROCFS_PATH=/host/proc
privileged: true
volumes:
- /proc:/host/proc:rw
- /sys:/host/sys:ro
- /etc/passwd:/host/etc/passwd:ro
- /:/host:ro
BIN
View File
Binary file not shown.
-13
View File
@@ -1,13 +0,0 @@
{
".url": {
"fnos-resmon.Application": {
"title": "飞牛资源管理器",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "http",
"port": "7031",
"url": "/",
"allUsers": false
}
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

-322
View File
@@ -1,322 +0,0 @@
#!/usr/bin/env python3
"""
App Usage Tracker — 进程 + Docker 容器资源用量排行
按日/周/月/年/总计 聚合 CPU、内存、网络流量
"""
import json
import os
import re
import sqlite3
import subprocess
import time
from datetime import date, datetime, timedelta
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app_usage.db')
_LAST_SNAPSHOT_TS = 0
_SNAPSHOT_INTERVAL = 60 # collect snapshots every 60s
def _get_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS snapshots (
ts INTEGER, app TEXT, cpu_pct REAL, mem_mb REAL,
net_rx INTEGER, net_tx INTEGER
)''')
c.execute('CREATE INDEX IF NOT EXISTS idx_snap_ts ON snapshots(ts)')
c.execute('CREATE INDEX IF NOT EXISTS idx_snap_app ON snapshots(app)')
c.execute('''CREATE TABLE IF NOT EXISTS daily_rollup (
day TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
net_rx INTEGER, net_tx INTEGER,
PRIMARY KEY (day, app)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS weekly_rollup (
week TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
net_rx INTEGER, net_tx INTEGER,
PRIMARY KEY (week, app)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS monthly_rollup (
month TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
net_rx INTEGER, net_tx INTEGER,
PRIMARY KEY (month, app)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS yearly_rollup (
year TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
net_rx INTEGER, net_tx INTEGER,
PRIMARY KEY (year, app)
)''')
conn.commit()
return conn
def _get_process_list():
"""Get current processes: {comm: {cpu_pct, mem_mb}}"""
procs = {}
try:
result = os.popen("ps -eo comm,pcpu,rss --no-headers 2>/dev/null").read()
for line in result.strip().split('\n'):
parts = line.strip().split(None, 2)
if len(parts) >= 3:
name = parts[0]
# Normalize names: strip docker prefixes, version suffixes
if name.startswith('docker-'):
name = name[7:] # strip 'docker-'
name = re.sub(r'[-_]\d+$', '', name) # strip trailing numbers
name = re.sub(r'[-_](v?\d[\d.]*|arm.*|amd64)$', '', name)
try:
cpu = float(parts[1])
except ValueError:
cpu = 0
try:
mem_kb = int(parts[2])
except ValueError:
mem_kb = 0
if name not in procs:
procs[name] = {'cpu': 0, 'mem': 0}
procs[name]['cpu'] += cpu
procs[name]['mem'] += mem_kb
except Exception:
pass
# Convert mem from KB to MB
for v in procs.values():
v['mem'] = round(v['mem'] / 1024, 1)
return procs
def _get_docker_traffic():
"""Get per-container network: {name: {rx, tx}}"""
traffic = {}
try:
result = subprocess.run(
['docker', 'stats', '--no-stream', '--format', '{{.Name}}\t{{.NetIO}}'],
capture_output=True, text=True, timeout=15
)
for line in result.stdout.strip().split('\n'):
parts = line.strip().split('\t')
if len(parts) < 2:
continue
name = parts[0]
netio = parts[1].strip()
rx_str, tx_str = '', ''
if ' / ' in netio:
rx_str, tx_str = netio.split(' / ', 1)
def parse(s):
s = s.strip().replace(',', '')
m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s)
if not m:
return 0
v = float(m.group(1))
u = m.group(2).lower()
mul = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6,
'mib': 1048576, 'gb': 1e9, 'gib': 1073741824}
return int(v * mul.get(u, 1))
traffic[name] = {'rx': parse(rx_str), 'tx': parse(tx_str)}
except Exception:
pass
return traffic
def collect_snapshot():
"""Collect and store one snapshot of app usage."""
global _LAST_SNAPSHOT_TS
now = int(time.time())
if now - _LAST_SNAPSHOT_TS < _SNAPSHOT_INTERVAL:
return
_LAST_SNAPSHOT_TS = now
procs = _get_process_list()
dockers = _get_docker_traffic()
conn = _get_db()
c = conn.cursor()
# Process CPU + mem
for app, data in procs.items():
net_rx = dockers.get(app, {}).get('rx', 0)
net_tx = dockers.get(app, {}).get('tx', 0)
c.execute(
'INSERT INTO snapshots VALUES (?,?,?,?,?,?)',
(now, app, data['cpu'], data['mem'], net_rx, net_tx)
)
# Docker-only apps (no matching process)
for app, data in dockers.items():
if app not in procs:
c.execute(
'INSERT INTO snapshots VALUES (?,?,?,?,?,?)',
(now, app, 0, 0, data['rx'], data['tx'])
)
conn.commit()
# Cleanup old snapshots (keep last 7 days of raw data)
c.execute('DELETE FROM snapshots WHERE ts < ?', (now - 7 * 86400,))
conn.commit()
conn.close()
# Roll up daily/weekly/monthly/yearly
_rollup_all()
def _rollup_all():
"""Roll up snapshots into daily/weekly/monthly/yearly aggregates."""
conn = _get_db()
c = conn.cursor()
now = datetime.now()
today = now.strftime('%Y-%m-%d')
this_week = now.strftime('%G-W%V') # ISO week
this_month = now.strftime('%Y-%m')
this_year = now.strftime('%Y')
periods = [
('daily_rollup', 'day', today),
('weekly_rollup', 'week', this_week),
('monthly_rollup', 'month', this_month),
('yearly_rollup', 'year', this_year),
]
for table, col, period_val in periods:
# Aggregate from snapshots (last 24h for daily, etc.)
cutoff = int(time.time()) - 86400 # last 24h for all rollups
c.execute(f'''
INSERT OR REPLACE INTO {table} ({col}, app, cpu_avg, mem_avg, net_rx, net_tx)
SELECT ?, app,
ROUND(AVG(cpu_pct), 1), ROUND(AVG(mem_mb), 1),
MAX(net_rx), MAX(net_tx)
FROM snapshots
WHERE ts >= ?
GROUP BY app
ORDER BY AVG(cpu_pct) DESC
''', (period_val, cutoff))
conn.commit()
conn.close()
def get_app_ranking(period='total', metric='cpu', limit=15):
"""
Get app usage ranking.
period: 'day', 'week', 'month', 'year', 'total'
metric: 'cpu', 'mem', 'net'
"""
now = datetime.now()
today = now.strftime('%Y-%m-%d')
this_week = now.strftime('%G-W%V')
this_month = now.strftime('%Y-%m')
this_year = now.strftime('%Y')
conn = _get_db()
c = conn.cursor()
if period == 'day':
table = 'daily_rollup'
col = 'day'
val = today
elif period == 'week':
table = 'weekly_rollup'
col = 'week'
val = this_week
elif period == 'month':
table = 'monthly_rollup'
col = 'month'
val = this_month
elif period == 'year':
table = 'yearly_rollup'
col = 'year'
val = this_year
else: # total - from snapshots aggregate all time
table = 'snapshots'
col = None
val = None
if metric == 'cpu':
order_col = 'cpu_avg'
elif metric == 'mem':
order_col = 'mem_avg'
else: # net
order_col = '(net_rx + net_tx)'
if period == 'total':
c.execute(f'''
SELECT app,
ROUND(AVG(cpu_pct), 1) as cpu_avg,
ROUND(AVG(mem_mb), 1) as mem_avg,
MAX(net_rx) as net_rx,
MAX(net_tx) as net_tx
FROM snapshots
GROUP BY app
ORDER BY {order_col} DESC
LIMIT ?
''', (limit,))
else:
c.execute(f'''
SELECT app, cpu_avg, mem_avg, net_rx, net_tx
FROM {table}
WHERE {col} = ?
ORDER BY {order_col} DESC
LIMIT ?
''', (val, limit))
rows = c.fetchall()
conn.close()
result = []
for row in rows:
app, cpu, mem, rx, tx = row
result.append({
'app': app,
'cpu': cpu or 0,
'mem': mem or 0,
'net_rx': rx or 0,
'net_tx': tx or 0,
'net_total': (rx or 0) + (tx or 0),
})
return result
def get_combined_ranking(period='total', limit=15):
"""Combined ranking: weighted score across CPU + mem + net."""
apps = {}
periods = ['day', 'week', 'month', 'year', 'total']
target = period if period != 'total' else 'total'
for metric in ['cpu', 'mem', 'net']:
ranking = get_app_ranking(target, metric, limit=50)
total_score = max((r.get('cpu', 0) if metric == 'cpu' else
r.get('mem', 0) if metric == 'mem' else
r.get('net_total', 0)) for r in ranking) if ranking else 1
if total_score == 0:
total_score = 1
for r in ranking:
app = r['app']
if app not in apps:
apps[app] = {'app': app, 'cpu': 0, 'mem': 0, 'net_rx': 0, 'net_tx': 0, 'net_total': 0, 'score': 0}
apps[app]['cpu'] = max(apps[app]['cpu'], r.get('cpu', 0))
apps[app]['mem'] = max(apps[app]['mem'], r.get('mem', 0))
apps[app]['net_rx'] = max(apps[app]['net_rx'], r.get('net_rx', 0))
apps[app]['net_tx'] = max(apps[app]['net_tx'], r.get('net_tx', 0))
apps[app]['net_total'] = max(apps[app]['net_total'], r.get('net_total', 0))
# Weighted score
val = (r.get('cpu', 0) if metric == 'cpu' else
r.get('mem', 0) if metric == 'mem' else
r.get('net_total', 0))
apps[app]['score'] += val / total_score
# Sort by score
result = sorted(apps.values(), key=lambda x: x['score'], reverse=True)[:limit]
return result
if __name__ == '__main__':
collect_snapshot()
print("=== CPU Ranking (total) ===")
for r in get_app_ranking('total', 'cpu', 10):
print(f" {r['app']:30s} CPU:{r['cpu']:6.1f}% MEM:{r['mem']:8.1f}MB")
print("\n=== Combined Ranking ===")
for r in get_combined_ranking('total', 10):
print(f" {r['app']:30s} score:{r['score']:5.2f} CPU:{r['cpu']:5.1f}% MEM:{r['mem']:7.1f}MB net:{r['net_total']}")
+8
View File
@@ -0,0 +1,8 @@
飞牛资源管理器:一眼看穿NAS谁在偷吃资源
你的飞牛NAS卡顿、内存飙升,系统自带却只能看汇总数字,根本找不到元凶。飞牛资源管理器,一个Docker命令就能装好,进程级监控一目了然。
CPU、内存、磁盘、网络四大面板实时刷新,每个进程的CPU和内存占用清清楚楚。
内存面板还能看RSS、USS、SWAP明细,一键回收未使用的内存,释放空间立竿见影。
磁盘面板直接显示SMART健康、温度、通电时间,硬盘故障提前预警。
网络面板列出所有TCP连接和侦听端口,哪个进程在联网一目了然。
表头点击排序,手机也能看,Docker运行不修改系统文件。开源免费,欢迎Star。
收藏这个视频,下次NAS卡顿时就知道该查哪里。
BIN
View File
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called after the user change environment variables in application setting page.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called before the user change environment variables in application setting page.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called after the user installs the application.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called before the user installs the application.
exit 0
-42
View File
@@ -1,42 +0,0 @@
#!/bin/bash
FILE_PATH="${TRIM_APPDEST}/docker/docker-compose.yaml"
is_docker_running () {
DOCKER_NAME=""
if [ -f "$FILE_PATH" ]; then
DOCKER_NAME=$(cat $FILE_PATH | grep "container_name" | awk -F ':' '{print $2}' | xargs)
echo "DOCKER_NAME is set to: $DOCKER_NAME"
fi
if [ -n "$DOCKER_NAME" ]; then
docker inspect $DOCKER_NAME | grep -q "\"Status\": \"running\"," || exit 1
return
fi
}
case $1 in
start)
# run start command. exit 0 if success, exit 1 if failed
# do nothing, docker application will be started by appcenter
exit 0
;;
stop)
# run stop command. exit 0 if success, exit 1 if failed
# do nothing, docker application will be stopped by appcenter
exit 0
;;
status)
# check application status command. exit 0 if running, exit 3 if not running
# check first container by default, you cound modify it by yourself
if is_docker_running; then
exit 0
else
exit 3
fi
;;
*)
exit 1
;;
esac
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called after the user uninstalls the application.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called after the user uninstalls the application.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called after the user upgrades the application.
exit 0
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
### This script is called before the user upgrades the application.
exit 0
BIN
View File
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
{
"network": ["host"],
"pid": ["host"],
"volumes": ["/proc", "/sys", "/etc/passwd", "/"]
}
-1
View File
@@ -1 +0,0 @@
{}
-14
View File
@@ -1,14 +0,0 @@
1|location ^~/apps/fnmon/ {
2| proxy_pass http://127.0.0.1:5000/;
3| proxy_http_version 1.1;
4| proxy_set_header Host $host;
5| proxy_set_header X-Real-IP $remote_addr;
6| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
7| proxy_read_timeout 86400;
8| proxy_buffering off;
9|
10| # SSE support
11| proxy_set_header Connection '';
12| chunked_transfer_encoding on;
13|}
14|
+5 -5
View File
@@ -1,14 +1,14 @@
version: '3.8'
services:
fnos-resmon:
image: jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
container_name: fnos-resmon
privileged: true
network_mode: host
pid: host
restart: unless-stopped
environment:
- PORT=7031
- PROCFS_PATH=/host/proc
privileged: true
volumes:
- /proc:/host/proc:rw
- /sys:/host/sys:ro
environment:
- PROCFS_PATH=/host/proc
restart: unless-stopped
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

BIN
View File
Binary file not shown.
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""Generate a self-contained fpk for distribution — includes app code + static assets."""
import os, re, struct, zlib, tarfile, io, json, hashlib, time as tmod
OUTDIR = '/vol2/1000/HD2/fpk'
APP_NAME = 'fnmon'
APP_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(OUTDIR, exist_ok=True)
# === Auto-version ===
latest_ver = '0.0.0'
# Also account for legacy cpu-monitor-v* fpks
for f in os.listdir(OUTDIR):
m = re.match(r'(?:fnmon|cpu-monitor)-v(\d+\.\d+\.\d+)\.fpk', f)
if m:
v = tuple(int(x) for x in m.group(1).split('.'))
if v > tuple(int(x) for x in latest_ver.split('.')):
latest_ver = m.group(1)
v_parts = [int(x) for x in latest_ver.split('.')]
v_parts[2] += 1
NEW_VER = '.'.join(str(x) for x in v_parts)
OUTPATH = f'{OUTDIR}/fnmon-v{NEW_VER}.fpk'
now_ts = int(tmod.time())
def add_file(tar, path, content=None, filepath=None, mode=0o644):
"""Add a file to the tar archive."""
info = tarfile.TarInfo(name=f'{APP_NAME}/{path}')
if filepath:
with open(filepath, 'rb') as f:
data = f.read()
elif isinstance(content, bytes):
data = content
else:
data = content.encode('utf-8')
info.size = len(data)
info.mode = mode
info.mtime = now_ts
info.uid = info.gid = 0
info.uname = 'root'
info.gname = 'root'
tar.addfile(info, io.BytesIO(data))
# === Build manifest ===
manifest_content = f'''appname = {APP_NAME}
version = {NEW_VER}
display_name = fnmon - 系统监视器
desc = 实时系统监控面板 - CPU/内存/网络/磁盘/进程 全能监控
platform = all
source = thirdparty
maintainer = Hermes
maintainer_url = https://github.com/nousresearch/hermes-agent
distributor = Community
os_min_version = 0.8.1
desktop_uidir = ui
desktop_applaunchname = {APP_NAME}.Application
service_port = 5000
checkport = true
ctl_stop = true
'''
manifest_checksum = hashlib.md5(manifest_content.encode('utf-8')).hexdigest()
manifest_full = manifest_content + f'checksum = {manifest_checksum}\n'
# === Build tar ===
with tarfile.open(OUTPATH, 'w:gz') as tar:
add_file(tar, 'manifest', manifest_full)
# Config
add_file(tar, 'config/privilege', json.dumps({
'defaults': {'run-as': 'root'},
}, indent=2))
add_file(tar, 'config/resource', '{\n "systemd-unit": {}\n}\n')
# Lifecycle scripts
scripts = {
'install_init': '#!/bin/bash\nexit 0\n',
'install_callback': f'''#!/bin/bash
# Create and enable systemd service
cat > /etc/systemd/system/{APP_NAME}.service << 'SERVEOF'
[Unit]
Description=fnmon - 系统监视器
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/apps/{APP_NAME}
ExecStart=/usr/bin/python3 /var/apps/{APP_NAME}/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVEOF
systemctl daemon-reload
systemctl enable {APP_NAME}.service
systemctl start {APP_NAME}.service
exit 0
''',
'uninstall_init': f'#!/bin/bash\nsystemctl stop {APP_NAME}.service 2>/dev/null\nexit 0\n',
'uninstall_callback': f'''#!/bin/bash
systemctl disable {APP_NAME}.service 2>/dev/null
rm -f /etc/systemd/system/{APP_NAME}.service
systemctl daemon-reload
exit 0
''',
'upgrade_init': '#!/bin/bash\nexit 0\n',
'upgrade_callback': f'#!/bin/bash\nsystemctl restart {APP_NAME}.service 2>/dev/null\nexit 0\n',
'config_init': '#!/bin/bash\nexit 0\n',
'config_callback': '#!/bin/bash\nexit 0\n',
}
for name, content in scripts.items():
add_file(tar, f'cmd/{name}', content, mode=0o755)
# Main lifecycle
add_file(tar, 'cmd/main', f'''#!/bin/bash
case $1 in
start)
systemctl start {APP_NAME}.service 2>/dev/null
sleep 2
curl -s -o /dev/null http://localhost:5000 && exit 0
exit 1
;;
stop) exit 0 ;;
status)
curl -s -o /dev/null http://localhost:5000 && exit 0 || exit 3
;;
*) exit 1 ;;
esac
''', mode=0o755)
# === Bundle app code ===
add_file(tar, 'app.py', filepath=os.path.join(APP_DIR, 'app.py'), mode=0o755)
add_file(tar, 'traffic_tracker.py', filepath=os.path.join(APP_DIR, 'traffic_tracker.py'), mode=0o644)
add_file(tar, 'app_usage_tracker.py', filepath=os.path.join(APP_DIR, 'app_usage_tracker.py'), mode=0o644)
add_file(tar, 'static/index.html', filepath=os.path.join(APP_DIR, 'static/index.html'), mode=0o644)
# UI config
add_file(tar, 'ui/index.json', json.dumps({
APP_NAME: {
'application': {
'type': 'url',
'protocol': 'http',
'port': 5000,
'url': 'http://${host}:5000',
'title': 'fnmon - 系统监视器',
'description': '实时系统监控面板'
}
}
}, ensure_ascii=False, indent=2))
# Icons
for fname, fpk_name in [(f'{APP_NAME}-icon-128.png', 'ICON.PNG'),
(f'{APP_NAME}-icon-256.png', 'ICON_256.PNG')]:
ipath = os.path.join(OUTDIR, fname)
if os.path.exists(ipath):
add_file(tar, fpk_name, filepath=ipath)
else:
# Fallback: simple blue icon
add_file(tar, fpk_name, _gen_png(128 if '128' in fname else 256, (80, 120, 255)))
# === Done ===
size = os.path.getsize(OUTPATH)
print(f'✅ fpk created: {OUTPATH} ({size} bytes)')
print(f' version: v{NEW_VER} (previous: v{latest_ver})')
print(f' checksum: {manifest_checksum}')
print(f' bundled: app.py + frontend + trackers')
with tarfile.open(OUTPATH) as t:
for m in t.getmembers():
print(f' {m.name}: {m.size}B')
def _gen_png(size, color):
"""Quick PNG generator (fallback if no icon)."""
r, g, b = color
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
hdr = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0))
raw = b''
for y in range(size):
raw += b'\x00'
for x in range(size):
d = ((x-size/2)**2 + (y-size/2)**2) ** 0.5
rad = size/2 - 4
if d < rad:
t = d / rad
raw += struct.pack('BBB', int(r-(r-50)*t), int(g-(g-100)*t), int(b-(b-200)*t))
else:
raw += b'\x00\x00\x00'
return hdr + ihdr + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Generate a system-monitoring-themed icon for the fnmon fpk."""
from PIL import Image, ImageDraw, ImageFont
import math
SIZE = 256
OUT = '/vol2/1000/HD2/fpk/fnmon-icon.png'
draw_icon = ImageDraw.Draw(Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0)))
img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Colors
BG_DARK = (16, 22, 36)
ACCENT = (56, 189, 248)
GREEN = (74, 222, 128)
YELLOW = (251, 191, 36)
RED = (248, 113, 113)
WHITE = (230, 240, 255)
CARD = (30, 41, 59)
cx, cy = SIZE//2, SIZE//2
r = SIZE//2 - 16
# Rounded square background
draw.rounded_rectangle([4, 4, SIZE-5, SIZE-5], radius=28, fill=BG_DARK)
# -- Gauge arc --
arc_r = r - 10
for deg in range(60, 241):
rad = math.radians(deg)
x1 = cx + int((arc_r-2) * math.cos(rad))
y1 = cy + int((arc_r-2) * math.sin(rad))
x2 = cx + int(arc_r * math.cos(rad))
y2 = cy + int(arc_r * math.sin(rad))
# green 60-120, yellow 120-180, red 180-240
if deg < 120:
c = GREEN
elif deg < 180:
c = YELLOW
else:
c = RED
draw.line([(x1, y1), (x2, y2)], fill=c, width=4)
# Gauge needle at ~80°
needle_deg = 100
rad = math.radians(needle_deg)
nx = cx + int(arc_r * 0.7 * math.cos(rad))
ny = cy + int(arc_r * 0.7 * math.sin(rad))
draw.line([(cx, cy), (nx, ny)], fill=WHITE, width=3)
draw.ellipse([cx-5, cy-5, cx+5, cy+5], fill=ACCENT)
# -- Bar chart bars at bottom left --
bar_x, bar_y = cx - arc_r + 5, cy + 8
bar_w, bar_h = 8, 30
for i, (h, c) in enumerate([(24, GREEN), (16, YELLOW), (24, ACCENT)]):
bx = bar_x + i * (bar_w + 4)
draw.rectangle([bx, bar_y + bar_h - h, bx + bar_w, bar_y + bar_h], fill=c, width=0)
# -- Small CPU chip top-left --
chip_x, chip_y = cx - arc_r + 5, cy - arc_r + 5
draw.rounded_rectangle([chip_x, chip_y, chip_x+24, chip_y+24], radius=3, fill=ACCENT)
draw.rectangle([chip_x+4, chip_y+4, chip_x+20, chip_y+20], fill=(20, 40, 60))
# Dot
draw.rectangle([chip_x+10, chip_y+9, chip_x+14, chip_y+15], fill=(30, 80, 120))
# -- Signal dots (network) top right --
sig_x, sig_y = cx + arc_r - 20, cy - arc_r + 5
for i in range(3):
sh = 6 + i * 5
draw.rectangle([sig_x + i * 5, sig_y + 16 - sh, sig_x + i * 5 + 3, sig_y + 16], fill=ACCENT if i < 2 else (100, 150, 200))
# Resize for 128
img_128 = img.resize((128, 128), Image.LANCZOS if hasattr(Image, 'LANCZOS') else Image.ANTIALIAS)
img.save(OUT)
img_128.save('/vol2/1000/HD2/fpk/fnmon-icon-128.png')
print(f'✅ 256x256 saved: {OUT}')
print(f'✅ 128x128 saved: /vol2/1000/HD2/fpk/fnmon-icon-128.png')
# Also save 256 copy
img.save('/vol2/1000/HD2/fpk/fnmon-icon-256.png')
print(f'✅ 256x256 saved: /vol2/1000/HD2/fpk/fnmon-icon-256.png')
+1
View File
@@ -0,0 +1 @@
{"arrays":[{"devices":["sdc3"],"level":"raid1","lvm":"trim_43d5e563_4991_48af_89f2_514933c40b28-0","md":"md0","members":"1/1","mount":"/vol1","note":"单成员 RAID1,无冗余"},{"devices":["sda1"],"level":"raid1","lvm":"trim_3f9de466_c96d_406a_a13b_9a3d4bab6b42","md":"md1","members":"1/1","mount":"/vol3","note":"单成员 RAID1,无冗余"},{"devices":["sdb1"],"level":"raid1","lvm":"trim_a03ba591_eb54_4c55_a112_00d2903bd71c-0","md":"md3","members":"1/1","mount":"/vol2","note":"单成员 RAID1,无冗余"}],"disks":[{"cap_b":1024209543168,"form":"2.5英寸","health":"PASSED","hours":18878,"iface":"SATA 6Gb/s","model":"QUANXING S101 1TB","name":"sda","power_cycles":452,"realloc":0,"serial":"AD20221216A0101609","temp_c":40,"type":"SSD","wear":0},{"cap_b":12000138625024,"form":"3.5英寸","health":"PASSED","helium":100,"hours":4396,"iface":"SATA 6Gb/s","model":"WDC WD120EDGZ-11B1PA1","model_family":"Western Digital Ultrastar (He10/12)","name":"sdb","power_cycles":237,"realloc":0,"rpm":7200,"serial":"9MJB45YT","temp_c":37,"type":"HDD 氦气盘"},{"cap_b":512110190592,"form":"2.5英寸","health":"PASSED","hours":16661,"iface":"SATA 6Gb/s","model":"QUANXING S101 512G","name":"sdc","power_cycles":735,"realloc":0,"serial":"AC20221216A0100212","temp_c":40,"type":"SSD","wear":0}],"extra":{"mount":"/","note":"系统盘直挂分区,无 RAID/LVM","part":"sdc2"},"note":"SMART 为构建时快照,温度/通电时间随时间漂移;容量/型号/序列号为静态","snapshot":"2026-11-06"}
+42
View File
@@ -0,0 +1,42 @@
{
"dimms": [
{
"locator": "DIMM 0",
"bank": "P0 CHANNEL A",
"size_b": 8589934592,
"size_mb": 8192,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "A-DATA Technology",
"serial": "3E8A0000",
"part": ""
},
{
"locator": "DIMM 0",
"bank": "P0 CHANNEL B",
"size_b": 17179869184,
"size_mb": 16384,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "Unknown",
"serial": "22522642",
"part": "QXD016GD4U266600"
},
{
"locator": "DIMM 1",
"bank": "P0 CHANNEL B",
"size_b": 17179869184,
"size_mb": 16384,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "Unknown",
"serial": "62805591",
"part": "EAGET-16GB-3200"
}
],
"installed": 42949672960,
"max_cap_b": 137438953472
}
+1
View File
@@ -0,0 +1 @@
{"dns":"192.168.68.1","gateway":"192.168.68.1","nics":[{"driver":"r8169","model":"Realtek RTL8125 2.5GbE","name":"enp42s0","pci":"10EC:8125","role":"有线主网口(已接入 OVS,IP 挂在 enp42s0-ovs 上)"},{"driver":"mt7921e","model":"MEDIATEK MT7921K Wi-Fi 6E","name":"wlp41s0","pci":"14C3:0608","role":"无线备用(DHCP,metric 600)","ssid":"css_5G"},{"model":"EasyTier VPN 虚拟网卡","name":"tun0","role":"组网隧道"},{"model":"OVS 内部端口(与 enp42s0 同 MAC)","name":"enp42s0-ovs","role":"宿主实际出口 IP 承载口"},{"model":"Open vSwitch 数据通道","name":"ovs-system","role":"OVS datapath(DOWN 属正常)"}],"note":"型号/驱动/网关/DNS 为构建时快照;地址/状态/速率/流量为实时读取","snapshot":"2026-11-06"}
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${PORT:-7031}"
CONTAINER_NAME="${CONTAINER_NAME:-fnos-resmon}"
INSTALL_DIR="${INSTALL_DIR:-/vol1/1000/HD1/docker/fnos-resmon}"
REPO_URL="https://gitea.88531.cn/jinghui/fnos-resmon.git"
if ! command -v docker >/dev/null 2>&1; then
echo "❌ 未检测到 Docker,请先安装 Docker"; exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "❌ 未检测到 docker compose"; exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "❌ 未检测到 git"; exit 1
fi
# 自动探测 docker 根目录
if [ -d /vol1/1000/HD1/docker ]; then
COMPOSE_ROOT=/vol1/1000/HD1/docker
elif [ -d "$HOME/docker" ]; then
COMPOSE_ROOT="$HOME/docker"
else
COMPOSE_ROOT="$(dirname "$INSTALL_DIR")"
fi
if [ -d "$INSTALL_DIR/.git" ]; then
echo "🔄 检测到已有安装,执行更新..."
cd "$INSTALL_DIR"
git fetch origin main
git reset --hard origin/main
else
echo "📥 克隆项目..."
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
cat > "$INSTALL_DIR/.env" <<EOF
PORT=$PORT
CONTAINER_NAME=$CONTAINER_NAME
COMPOSE_ROOT=$COMPOSE_ROOT
EOF
echo "🐳 启动/更新容器..."
docker compose -f "$INSTALL_DIR/docker-compose.yml" --env-file "$INSTALL_DIR/.env" up -d --build
echo "⏳ 等待服务就绪..."
for i in $(seq 1 30); do
if curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT" | grep -q 200; then
echo "✅ 部署完成!访问 http://<NAS IP>:$PORT"
exit 0
fi
sleep 2
done
echo "⚠️ 服务可能未就绪,请检查 docker logs"
exit 1
-7
View File
@@ -1,7 +0,0 @@
appname: fnos-resmon
version: 1.2.5
display_name: 飞牛资源管理器
desc: 飞牛 NAS 资源监视器(CPU/内存/磁盘/网络四页签,读取宿主真实数据)
arch: x86_64
source: docker
maintainer: jinghui
+81
View File
@@ -0,0 +1,81 @@
<h1>飞牛NAS必装:进程级资源监控,比自带强10倍</h1>
<blockquote>
<p>飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。</p>
</blockquote>
<p>你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。</p>
<p><strong>飞牛资源管理器(fnos-resmon)</strong> 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,一条 docker 命令启动。</p>
<h2>一句话说清优势</h2>
<table>
<thead>
<tr>
<th>能力</th>
<th>飞牛自带</th>
<th>fnos-resmon</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU/内存占用率</td>
<td>✅ 有</td>
<td>✅ 有</td>
</tr>
<tr>
<td>进程级 CPU/内存排行</td>
<td>❌ 无</td>
<td>✅ 进程名/PID/用户/状态/线程数</td>
</tr>
<tr>
<td>内存 RSS/USS/SWAP 明细</td>
<td>❌ 无</td>
<td>✅ 三列细分</td>
</tr>
<tr>
<td>一键内存回收</td>
<td>❌ 无</td>
<td>✅ 🧹 一键回收未使用的内存</td>
</tr>
<tr>
<td>一键结束异常进程</td>
<td>❌ 无</td>
<td>✅ 一键 kill</td>
</tr>
<tr>
<td>磁盘 SMART/温度/通电时间</td>
<td>❌ 需命令行</td>
<td>✅ Web 直观展示</td>
</tr>
<tr>
<td>TCP 连接明细/侦听端口</td>
<td>❌ 无</td>
<td>✅ 进程+地址+状态</td>
</tr>
<tr>
<td>网卡驱动/MTU/双工</td>
<td>❌ 无</td>
<td>✅ 有</td>
</tr>
</tbody>
</table>
<h2>CPU 面板:谁在偷吃一眼看清</h2>
<p>CPU 型号、架构、物理/逻辑核数一目了然,进程级 CPU 排行实时刷新,点表头即排序。</p>
<p><img alt="CPU面板" src="http://img.88531.cn/i/2026/11/07/6aee0007e9bf2.png" /></p>
<h2>内存面板:一键回收释放空间</h2>
<p>每个进程 RSS/USS/SWAP 三列细分,🧹 一键回收未使用的内存,异常进程一键结束。</p>
<p><img alt="内存面板" src="http://img.88531.cn/i/2026/11/07/6aee000887b6a.png" /></p>
<h2>磁盘面板:硬盘健康提前预警</h2>
<p>SMART 健康、温度、通电时间 Web 直观展示,硬盘故障提前发现。</p>
<p><img alt="磁盘面板" src="http://img.88531.cn/i/2026/11/07/6aee000936412.png" /></p>
<h2>网络面板:谁在联网一目了然</h2>
<p>所有 TCP 连接和侦听端口明细,进程+地址+状态,网卡驱动/MTU/双工全展示。</p>
<p><img alt="网络面板" src="http://img.88531.cn/i/2026/11/07/6aee0009c4d5e.png" /></p>
<h2>一条命令安装</h2>
<pre><code class="language-bash">docker run -d --name fnos-resmon --privileged --network host --pid host \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
-e PORT=7031 -e PROCFS_PATH=/host/proc \
192.168.68.19:3200/jinghui/fnos-resmon:latest
</code></pre>
<p>浏览器打开 <code>http://NAS-IP:7031</code> 即可使用,手机也能看。</p>
<ul>
<li>开源地址:https://gitea.88531.cn/jinghui/fnos-resmon</li>
<li>3 秒自动刷新,零侵入,Docker 运行不修改系统文件</li>
</ul>
+58
View File
@@ -0,0 +1,58 @@
# 飞牛NAS必装:进程级资源监控,比自带强10倍
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。
**飞牛资源管理器(fnos-resmon)** 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,一条 docker 命令启动。
## 一句话说清优势
| 能力 | 飞牛自带 | fnos-resmon |
|------|---------|-------------|
| CPU/内存占用率 | ✅ 有 | ✅ 有 |
| 进程级 CPU/内存排行 | ❌ 无 | ✅ 进程名/PID/用户/状态/线程数 |
| 内存 RSS/USS/SWAP 明细 | ❌ 无 | ✅ 三列细分 |
| 一键内存回收 | ❌ 无 | ✅ 🧹 一键回收未使用的内存 |
| 一键结束异常进程 | ❌ 无 | ✅ 一键 kill |
| 磁盘 SMART/温度/通电时间 | ❌ 需命令行 | ✅ Web 直观展示 |
| TCP 连接明细/侦听端口 | ❌ 无 | ✅ 进程+地址+状态 |
| 网卡驱动/MTU/双工 | ❌ 无 | ✅ 有 |
## CPU 面板:谁在偷吃一眼看清
CPU 型号、架构、物理/逻辑核数一目了然,进程级 CPU 排行实时刷新,点表头即排序。
![CPU面板](http://img.88531.cn/i/2026/11/07/6aee0007e9bf2.png)
## 内存面板:一键回收释放空间
每个进程 RSS/USS/SWAP 三列细分,🧹 一键回收未使用的内存,异常进程一键结束。
![内存面板](http://img.88531.cn/i/2026/11/07/6aee000887b6a.png)
## 磁盘面板:硬盘健康提前预警
SMART 健康、温度、通电时间 Web 直观展示,硬盘故障提前发现。
![磁盘面板](http://img.88531.cn/i/2026/11/07/6aee000936412.png)
## 网络面板:谁在联网一目了然
所有 TCP 连接和侦听端口明细,进程+地址+状态,网卡驱动/MTU/双工全展示。
![网络面板](http://img.88531.cn/i/2026/11/07/6aee0009c4d5e.png)
## 一条命令安装
```bash
docker run -d --name fnos-resmon --privileged --network host --pid host \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
-e PORT=7031 -e PROCFS_PATH=/host/proc \
192.168.68.19:3200/jinghui/fnos-resmon:latest
```
浏览器打开 `http://NAS-IP:7031` 即可使用,手机也能看。
- 开源地址:https://gitea.88531.cn/jinghui/fnos-resmon
- 3 秒自动刷新,零侵入,Docker 运行不修改系统文件
+14 -3
View File
@@ -28,6 +28,9 @@ col.c-name{width:150px}col.c-num{width:90px}col.c-pid{width:80px}col.c-user{widt
.st-sleep{color:#ff9900}.st-run{color:#20a53a}
.kill{color:#20a53a;cursor:pointer;background:none;border:none;font-size:13px}
.kill:hover{text-decoration:underline}
.bigbtn{background:linear-gradient(180deg,#1f9d55,#157347);border:none;color:#fff;font-size:18px;font-weight:700;border-radius:10px;padding:14px 28px;cursor:pointer;box-shadow:0 4px 14px rgba(31,157,85,.4);display:inline-block;margin:6px 0}
.bigbtn:hover{filter:brightness(1.1)}
.bigbtn:active{transform:scale(.97)}
.num{font-variant-numeric:tabular-nums}
.bar{height:8px;background:#f0f0f0;border-radius:4px;overflow:hidden;margin-top:6px}
.bar>i{display:block;height:100%;background:#20a53a;border-radius:4px}
@@ -53,6 +56,9 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
<div class="tab" data-t="disk">磁盘</div>
<div class="tab" data-t="net">网络</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:12px;padding-right:16px;font-size:12px">
<span style="color:#999">v1.3.2</span>
<label style="color:#999">刷新 <select id="ival" onchange="setIval(this.value)" style="border:1px solid #ddd;border-radius:3px;padding:2px 4px;color:#333"><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="60">60s</option></select></label>
<button id="pauseBtn" onclick="togglePause()" style="background:#ff9900;color:#fff;border:none;border-radius:3px;padding:3px 10px;cursor:pointer">⏸ 停止刷新</button>
<a href="https://nav.88531.cn" target="_blank" style="color:#20a53a;text-decoration:none">资享导航站</a>
<a href="https://gitea.88531.cn/jinghui/fnos-resmon" target="_blank" style="color:#20a53a;text-decoration:none">开源地址</a>
</div>
@@ -76,7 +82,7 @@ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelect
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,'&amp;').replace(/</g,'&lt;')}
async function dropMem(){if(!confirm('回收可回收内存(drop caches)?'))return;const r=await fetch('/api/mem/drop',{method:'POST'});const j=await r.json();alert(j.ok?('已回收,可用 '+fmtB(j.available)):('失败: '+j.error));load(true)}
async function dropMem(){if(!confirm('一键回收未使用的内存(drop caches)?'))return;const r=await fetch('/api/mem/drop',{method:'POST'});const j=await r.json();alert(j.ok?('已回收,可用 '+fmtB(j.available)):('失败: '+j.error));load(true)}
async function kill(pid,name){if(!confirm('结束进程 '+name+' (PID '+pid+') ?'))return;const r=await fetch('/api/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid})});const j=await r.json();alert(j.ok?'已发送结束信号':('失败: '+j.error));load(true)}
function cmpVal(p,key){
if(key==='name')return String(p.name||'');
@@ -140,7 +146,7 @@ document.getElementById('sub').textContent=`共 ${j.total} 个进程,显示占
renderHeader();
B.innerHTML=j.items.map(p=>`<tr><td><a class="proc">${esc(p.name)}</a></td><td class="num">${p.cpu}</td><td class="num">${p.threads}</td><td class="num">${p.pid}</td><td>${esc(p.desc)}</td><td>${esc(p.user)}</td><td class="${p.active?'st-run':'st-sleep'}">${p.status}</td><td><button class="kill" onclick="kill(${p.pid},'${esc(p.name)}')">结束</button></td></tr>`).join('');
}else if(cur==='mem'){
S.innerHTML=`<div class="stat"><div class="k">内存占用</div><div class="v green">${j.percent}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(j.used)} / ${fmtB(j.total)}</div></div><div class="stat"><div class="k">可用 / Swap</div><div class="v num">${fmtB(j.available)} / ${fmtB(j.swap_used)}/${fmtB(j.swap_total)} (Swap ${j.swap_percent||0}%)</div></div><div class="stat"><div class="k">操作</div><div class="v"><button class="kill" onclick="dropMem()">回收可回收内存</button></div></div>`;
S.innerHTML=`<div class="stat"><div class="k">内存占用</div><div class="v green">${j.percent}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(j.used)} / ${fmtB(j.total)}</div></div><div class="stat"><div class="k">可用 / Swap</div><div class="v num">${fmtB(j.available)} / ${fmtB(j.swap_used)}/${fmtB(j.swap_total)} (Swap ${j.swap_percent||0}%)</div></div><div class="stat"><div class="k">操作</div><div class="v"><button class="bigbtn" onclick="dropMem()">🧹 一键回收未使用的内存</button></div></div>`;
const D=j.detail||{},H2=j.hw||{},DM=H2.dimms||[];
const row=(k,v)=>`<span style="display:inline-block;min-width:230px;margin-right:12px">${k} <b class="num">${v}</b></span>`;
document.getElementById('meminfo').style.display='';
@@ -182,7 +188,12 @@ document.getElementById('netinfo').innerHTML=`<div class="model">出口 ${esc(NW
}
}catch(e){if(manual)alert('加载失败: '+e)}
}
load(true);setInterval(()=>load(false),3000);
load(true);
let ivalSec=3,timer=null,paused=false;
function startTimer(){if(timer)clearInterval(timer);if(!paused)timer=setInterval(()=>load(false),ivalSec*1000)}
function setIval(v){ivalSec=+v;startTimer()}
function togglePause(){paused=!paused;document.getElementById('pauseBtn').textContent=paused?'▶ 恢复刷新':'⏸ 停止刷新';startTimer()}
startTimer();
</script>
<footer style="position:fixed;bottom:0;left:0;right:0;text-align:center;padding:6px;font-size:12px;color:#999;background:#fff;border-top:1px solid #eee;z-index:999">
<a href="https://nav.88531.cn" target="_blank" rel="noopener">资享导航站</a> ·
+1 -1
View File
@@ -1,2 +1,2 @@
Flask
flask
psutil
-827
View File
@@ -1,827 +0,0 @@
from flask import Flask, jsonify, request, send_from_directory
import psutil, os, time, signal, threading, subprocess, platform, glob, json, struct
try:
psutil.PROCFS_PATH = os.environ.get("PROCFS_PATH", "/proc")
except Exception:
pass
PROCFS = os.environ.get("PROCFS_PATH", "/host/proc")
if os.path.isdir(PROCFS):
psutil.PROCFS_PATH = PROCFS
app = Flask(__name__, static_folder="public", static_url_path="")
PORT = int(os.environ.get("PORT", "7031"))
# ---------- background sampler ----------
cache = {"procs": [], "cpu": 0.0, "load": [0, 0, 0], "ts": 0}
net_prev = {}
net_rate = {}
def fmt_status(s):
m = {
psutil.STATUS_RUNNING: ("活动", 1),
psutil.STATUS_SLEEPING: ("睡眠", 0),
psutil.STATUS_DISK_SLEEP: ("睡眠", 0),
psutil.STATUS_STOPPED: ("停止", 0),
psutil.STATUS_ZOMBIE: ("僵尸", 0),
}
return m.get(s, ("睡眠", 0))
_uid_cache = {"map": {}, "ts": 0}
def uid_name(uid_str):
# 容器内 /etc/passwd 没有宿主用户,uid 会显示成数字,用宿主 passwd 还原
if not uid_str or not uid_str.isdigit():
return uid_str
now = time.time()
if now - _uid_cache["ts"] > 300:
m = {}
try:
with open("/host/etc/passwd") as f:
for line in f:
p = line.split(":")
if len(p) > 3:
try:
m[p[2]] = p[0]
except Exception:
pass
except Exception:
pass
_uid_cache["map"] = m
_uid_cache["ts"] = now
return _uid_cache["map"].get(uid_str, uid_str)
def sample_procs():
# init cpu percent calculation
for p in psutil.process_iter():
try:
p.cpu_percent(None)
except Exception:
pass
time.sleep(1.0)
procs = []
try:
cpu_total = psutil.cpu_percent(interval=None)
except Exception:
cpu_total = 0
try:
load = list(os.getloadavg())
except Exception:
load = [0, 0, 0]
for p in psutil.process_iter(["pid", "name", "username", "status", "num_threads", "cmdline"]):
try:
with p.oneshot():
cpu = p.cpu_percent(None)
mem = p.memory_percent()
try:
rss = p.memory_info().rss
except Exception:
rss = 0
uss = 0
swap = 0
try:
mfi = p.memory_full_info()
uss = getattr(mfi, "uss", 0) or 0
swap = getattr(mfi, "swap", 0) or 0
except Exception:
pass
try:
io = p.io_counters()
rd, wr = io.read_bytes, io.write_bytes
except Exception:
rd, wr = 0, 0
try:
conns = len(p.net_connections())
except Exception:
conns = 0
info = p.info
name = info.get("name") or ""
cmd = " ".join(info.get("cmdline") or [])[:120]
desc = cmd if cmd else name
st, act = fmt_status(info.get("status"))
if (not act) and (cpu or 0) > 1.0:
st, act = "活动", 1
procs.append({
"name": name[:40], "pid": info["pid"],
"cpu": round(cpu or 0, 2), "mem": round(mem or 0, 2),
"rss": rss, "uss": uss, "swap": swap, "threads": info.get("num_threads") or 0,
"user": uid_name(str(info.get("username") or ""))[:20],
"status": st, "active": act,
"desc": desc[:80], "io_r": rd, "io_w": wr, "conns": conns,
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except Exception:
continue
procs.sort(key=lambda x: x["cpu"], reverse=True)
cache["procs"] = procs
cache["cpu"] = round(cpu_total, 1)
cache["load"] = [round(x, 2) for x in load]
cache["ts"] = time.time()
def sampler_loop():
while True:
try:
sample_procs()
except Exception:
pass
time.sleep(2)
threading.Thread(target=sampler_loop, daemon=True).start()
# warm up immediately in background; serve stale until ready
@app.route("/")
def index():
return send_from_directory("public", "index.html")
@app.route("/api/cpu")
def api_cpu():
active = sum(1 for p in cache["procs"] if p["active"])
return jsonify({"cpu": cache["cpu"], "load": cache["load"],
"active": active, "total": len(cache["procs"]),
"info": get_cpu_info(), "percpu": get_percpu(),
"items": cache["procs"][:100]})
_cpu_static = {}
def _parse_host_cpuinfo():
info = {}
try:
with open("/host/proc/cpuinfo") as f:
txt = f.read()
except Exception:
try:
with open("/proc/cpuinfo") as f:
txt = f.read()
except Exception:
return info
# 取第一个 processor 段的 model name / cache size / cpu MHz
for line in txt.splitlines():
if ":" not in line:
continue
k, v = line.split(":", 1)
k, v = k.strip(), v.strip()
if k == "model name" and "model" not in info:
info["model"] = v
elif k == "cache size" and "cache" not in info:
info["cache"] = v
return info
def _parse_lscpu():
out = {}
try:
txt = subprocess.run(["lscpu"], capture_output=True, text=True,
timeout=5).stdout
except Exception:
return out
for line in txt.splitlines():
if ":" not in line and ":" not in line:
continue
line = line.replace(":", ":")
k, v = line.split(":", 1)
k, v = k.strip(), v.strip()
out[k] = v
return out
def get_cpu_info():
global _cpu_static
if not _cpu_static:
ci = _parse_host_cpuinfo()
lc = _parse_lscpu()
def pick(*keys):
for k in keys:
if k in lc and lc[k]:
return lc[k]
return ""
# L1d/L1i/L2/L3 键名中英文兼容
l1d = pick("L1d 缓存", "L1d cache")
l1i = pick("L1i 缓存", "L1i cache")
l2 = pick("L2 缓存", "L2 cache")
l3 = pick("L3 缓存", "L3 cache")
_cpu_static = {
"model": ci.get("model") or pick("型号名称", "Model name"),
"arch": platform.machine(),
"sockets": pick("座", "Socket(s)"),
"cores_per_socket": pick("每个座的核数", "Core(s) per socket"),
"threads_per_core": pick("每个核的线程数", "Thread(s) per core"),
"l1d": l1d, "l1i": l1i, "l2": l2, "l3": l3,
"cache": ci.get("cache", ""),
}
try:
f = psutil.cpu_freq()
cur = round(f.current, 0) if f else 0
fmin = round(f.min, 0) if f and f.min else 0
fmax = round(f.max, 0) if f and f.max else 0
except Exception:
cur = fmin = fmax = 0
d = dict(_cpu_static)
d.update({
"logical": psutil.cpu_count() or 0,
"physical": psutil.cpu_count(logical=False) or 0,
"freq_cur": cur, "freq_min": fmin, "freq_max": fmax,
})
return d
def get_percpu():
try:
vals = psutil.cpu_percent(interval=None, percpu=True)
return [round(v, 1) for v in (vals or [])]
except Exception:
return []
@app.route("/api/mem")
def api_mem():
vm = psutil.virtual_memory()
sm = psutil.swap_memory()
items = sorted(cache["procs"], key=lambda x: x["mem"], reverse=True)[:100]
mi = _read_meminfo_host()
dm = get_mem_hw()
return jsonify({
"total": vm.total, "used": vm.used, "available": vm.available,
"percent": vm.percent, "cached": getattr(vm, "cached", 0),
"swap_total": sm.total, "swap_used": sm.used, "swap_percent": sm.percent,
"detail": mi, "hw": dm,
"items": items})
_mem_hw_static = {}
def _read_meminfo_host():
mi = {}
for p in ("/host/proc/meminfo", "/proc/meminfo"):
try:
with open(p) as f:
for line in f:
if ":" not in line:
continue
k, v = line.split(":", 1)
parts = v.strip().split()
if not parts:
continue
try:
num = int(parts[0])
except ValueError:
continue
# 统一转 bytes
if len(parts) > 1 and parts[1] == "kB":
num *= 1024
mi[k.strip()] = num
if mi:
break
except Exception:
continue
return mi
def _walk_dmi(path):
"""yield (type, raw_bytes, strings)"""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return
off, n = 0, 0
L = len(data)
while off + 4 <= L and n < 200:
typ, ln = data[off], data[off + 1]
if ln < 4 or off + ln > L:
break
if typ == 127: # end of table
break
s = off + ln
strs = []
ok = True
while s < L:
try:
e = data.index(b"\x00", s)
except ValueError:
ok = False
break
if e == s:
s += 1
break
strs.append(data[s:e].decode("utf-8", "replace").strip())
s = e + 1
if not ok:
break
yield typ, data[off:off + ln], strs
off = s
n += 1
_MEM_TYPE = {0x02: "Unknown", 0x03: "DRAM", 0x12: "DDR", 0x13: "DDR2",
0x14: "DDR2 FB-DIMM", 0x18: "DDR3", 0x19: "FBD2", 0x1A: "DDR4",
0x1B: "LPDDR", 0x1C: "LPDDR2", 0x1D: "LPDDR3", 0x1E: "LPDDR4",
0x1F: "Logical NV", 0x20: "HBM", 0x21: "HBM2", 0x22: "DDR5",
0x23: "LPDDR5", 0x24: "HBM3"}
def _parse_dmi_mem():
dimms, max_cap_kb = [], 0
for dmi_path in ("/host/sys/firmware/dmi/tables/DMI",
"/sys/firmware/dmi/tables/DMI"):
found = False
for typ, raw, strs in _walk_dmi(dmi_path):
found = True
def S(i):
return strs[i - 1] if 1 <= i <= len(strs) else ""
if typ == 16 and len(raw) >= 11:
import struct
(cap,) = struct.unpack_from("<I", raw, 7)
if cap not in (0, 0x80000000):
max_cap_kb = cap # SMBIOS 单位 kB
elif typ == 17 and len(raw) >= 28:
import struct
(size,) = struct.unpack_from("<H", raw, 12)
if size == 0 or size == 0xFFFF:
continue # 空槽
if size & 0x8000:
mb = size & 0x7FFF # bit15=1 时单位 KB→转MB(向上)
size_mb = max(mb // 1024, 1) if mb >= 1024 else 1
size_b = (size & 0x7FFF) * 1024
else:
size_mb = size & 0x7FFF
size_b = size_mb * 1024 * 1024
if size_mb == 0x7FFF:
continue
(speed,) = struct.unpack_from("<H", raw, 20)
cspeed = 0
if len(raw) >= 30:
(cspeed,) = struct.unpack_from("<H", raw, 28)
mtype = raw[18] if len(raw) > 18 else 0
dimms.append({
"locator": S(raw[16]) if len(raw) > 16 else "",
"bank": S(raw[17]) if len(raw) > 17 else "",
"size_b": size_b, "size_mb": size_mb,
"type": _MEM_TYPE.get(mtype, "DDR4" if mtype == 0 else hex(mtype)),
"speed": speed or 0, "cspeed": cspeed or 0,
"maker": S(raw[22]) if len(raw) > 22 else "",
"serial": S(raw[23]) if len(raw) > 23 else "",
"part": S(raw[25]) if len(raw) > 25 else "",
})
if found:
break
return dimms, max_cap_kb
def _get_vram():
total = 0
for base in ("/host/sys/class/drm", "/sys/class/drm"):
try:
for p in glob.glob(base + "/card*/device/mem_info_vram_total"):
try:
with open(p) as f:
total += int(f.read().strip())
except Exception:
pass
except Exception:
pass
if total:
break
return total
def get_mem_hw():
global _mem_hw_static
if not _mem_hw_static:
dimms, max_cap_kb = [], 0
# 优先读构建时从宿主 dmidecode 导出的静态槽位表
for jp in ("hw_mem.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_mem.json")):
try:
with open(jp) as f:
hj = json.load(f)
dimms = hj.get("dimms", []) or []
max_cap_kb = int((hj.get("max_cap_b", 0) or 0) // 1024)
if dimms:
break
except Exception:
continue
if not dimms:
dimms, max_cap_kb = _parse_dmi_mem()
installed = sum(d["size_b"] for d in dimms)
_mem_hw_static = {"dimms": dimms, "installed": installed,
"max_cap_kb": max_cap_kb}
d = {"dimms": _mem_hw_static["dimms"],
"installed": _mem_hw_static["installed"],
"max_cap_kb": _mem_hw_static["max_cap_kb"]}
try:
mem_total = psutil.virtual_memory().total
except Exception:
mem_total = 0
d["vram"] = _get_vram()
d["system_total"] = mem_total
d["reserved"] = (d["installed"] - mem_total) if d["installed"] and mem_total else 0
return d
HOST_MNTS = [("/", "/host"), ("/vol1", "/host/vol1"),
("/vol2", "/host/vol2"), ("/vol3", "/host/vol3")]
def _df_host():
"""容器内 df 直读宿主路径(经 bind 进来的宿主目录),返回 {mnt: {...}}"""
try:
out = subprocess.run(
["df", "-B1", "--output=source,target,fstype,size,used,avail",
"/host", "/host/vol1", "/host/vol2", "/host/vol3"],
capture_output=True, text=True, timeout=5).stdout.splitlines()
res = {}
for line in out[1:]:
p = line.split()
if len(p) < 6:
continue
dev, tgt, fst, size, used, avail = p[0], p[1], p[2], int(p[3]), int(p[4]), int(p[5])
mnt = tgt.replace("/host", "", 1) or "/"
res[mnt] = {"device": dev, "mount": mnt, "fstype": fst,
"total": size, "used": used, "free": avail,
"percent": round(used / size * 100, 1) if size else 0}
return res
except Exception:
return {}
@app.route("/api/mem/drop", methods=["POST"])
def api_mem_drop():
import subprocess
try:
subprocess.run(["sync"], check=False, timeout=10)
p = os.environ.get("PROCFS_PATH", "/proc")
with open(os.path.join(p, "sys", "vm", "drop_caches"), "w") as f:
f.write("3")
vm = psutil.virtual_memory()
return jsonify({"ok": True, "available": vm.available, "percent": vm.percent})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/disk")
def api_disk():
parts = []
dfm = _df_host()
for mp, hp in HOST_MNTS:
if mp in dfm:
parts.append(dfm[mp])
continue
try:
st = os.statvfs(hp)
total = st.f_blocks * st.f_frsize
free = st.f_bavail * st.f_frsize
used = total - free
parts.append({"device": "?", "mount": mp, "fstype": "?",
"total": total, "used": used, "free": free,
"percent": round(used / total * 100, 1) if total else 0})
except Exception:
continue
try:
dio = psutil.disk_io_counters()
dio_d = {"read": dio.read_bytes, "write": dio.write_bytes} if dio else {}
except Exception:
dio_d = {}
# top io processes
items = sorted(cache["procs"], key=lambda x: x["io_r"] + x["io_w"], reverse=True)[:60]
return jsonify({"parts": parts, "io": dio_d, "hw": get_disk_hw(),
"items": items})
_disk_hw_static = {}
def _read(p, default=""):
try:
with open(p) as f:
return f.read().strip()
except Exception:
return default
def _sys_block_base():
for base in ("/host/sys/block", "/sys/block"):
try:
if os.path.isdir(base):
return base
except Exception:
continue
return "/host/sys/block"
def _live_sysfs(name):
"""实时 sysfs: 容量/转速/型号(覆盖静态快照中的漂移项)"""
base = _sys_block_base()
d = os.path.join(base, name)
out = {}
try:
sectors = _read(os.path.join(d, "size"))
if sectors.isdigit():
out["cap_b"] = int(sectors) * 512
except Exception:
pass
rot = _read(os.path.join(d, "queue/rotational"))
if rot in ("0", "1"):
out["rotational"] = (rot == "1")
model = _read(os.path.join(d, "device/model"))
if model:
out["sys_model"] = model
vendor = _read(os.path.join(d, "device/vendor"))
if vendor:
out["sys_vendor"] = vendor
return out
def _live_mdstat():
"""实时 mdstat: 每组阵列状态/同步进度,键为 md 名"""
st = {}
for p in ("/host/proc/mdstat", "/proc/mdstat"):
try:
with open(p) as f:
txt = f.read()
break
except Exception:
txt = ""
else:
return st
import re
cur = None
for line in txt.splitlines():
m = re.match(r"^(md\d+)\s*:\s*(\w+)\s+(\w+)(.*)$", line)
if m:
cur = m.group(1)
st[cur] = {"status": m.group(2), "level": m.group(3),
"devs": m.group(4).strip(), "sync": ""}
continue
if cur and ("recovery" in line or "resync" in line or "blocks" in line):
s = line.strip()
if "recovery" in line or "resync" in line:
st[cur]["sync"] = s
elif "blocks" in line and not st[cur].get("blocks"):
st[cur]["blocks"] = s
return st
def _live_perdisk_io():
try:
d = psutil.disk_io_counters(perdisk=True) or {}
except Exception:
return {}
out = {}
for k, v in d.items():
try:
out[k] = {"read": v.read_bytes, "write": v.write_bytes}
except Exception:
continue
return out
def get_disk_hw():
global _disk_hw_static
if not _disk_hw_static:
hj = {}
for jp in ("hw_disk.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_disk.json")):
try:
with open(jp) as f:
hj = json.load(f)
if hj.get("disks"):
break
except Exception:
continue
_disk_hw_static = hj or {"disks": [], "arrays": []}
disks = [dict(x) for x in _disk_hw_static.get("disks", [])]
arrays = [dict(x) for x in _disk_hw_static.get("arrays", [])]
# 合并实时数据
pio = _live_perdisk_io()
for dk in disks:
nm = dk.get("name", "")
dk.update(_live_sysfs(nm))
if nm in pio:
dk["io_read"] = pio[nm]["read"]
dk["io_write"] = pio[nm]["write"]
# 转速文字
if dk.get("rotational") is True and not dk.get("rpm"):
dk["rpm"] = 5400
md_live = _live_mdstat()
for a in arrays:
md = a.get("md", "")
lv = md_live.get(md, {})
if lv:
a["live_status"] = lv.get("status", "")
a["live_devs"] = lv.get("devs", "")
a["live_sync"] = lv.get("sync", "")
if md in pio:
a["io_read"] = pio[md]["read"]
a["io_write"] = pio[md]["write"]
return {"disks": disks, "arrays": arrays,
"extra": _disk_hw_static.get("extra", {}),
"snapshot": _disk_hw_static.get("snapshot", ""),
"note": _disk_hw_static.get("note", "")}
@app.route("/api/net")
def api_net():
global net_prev, net_rate
now = time.time()
try:
counters = psutil.net_io_counters(pernic=True)
except Exception:
counters = {}
rates = []
for nic, c in counters.items():
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
prev = net_prev.get(nic)
if prev:
dt = max(now - prev["t"], 0.5)
up = (c.bytes_sent - prev["sent"]) / dt
down = (c.bytes_recv - prev["recv"]) / dt
else:
up = down = 0
net_prev[nic] = {"sent": c.bytes_sent, "recv": c.bytes_recv, "t": now}
rates.append({"nic": nic, "up": max(up, 0), "down": max(down, 0),
"sent": c.bytes_sent, "recv": c.bytes_recv})
rates.sort(key=lambda x: x["up"] + x["down"], reverse=True)
try:
conns = len(psutil.net_connections())
except Exception:
conns = 0
total_recv = 0
total_sent = 0
for nic, c in counters.items():
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
total_recv += getattr(c, "bytes_recv", 0) or 0
total_sent += getattr(c, "bytes_sent", 0) or 0
items = sorted(cache["procs"], key=lambda x: x["conns"], reverse=True)[:60]
tcp_list = []
listen_list = []
try:
name_by_pid = {p["pid"]: p["name"] for p in cache.get("procs", [])}
for c in psutil.net_connections(kind="tcp"):
laddr = f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else ""
raddr = f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else ""
family = "ipv6" if ":" in (c.laddr.ip if c.laddr else "") and "." not in (c.laddr.ip if c.laddr else "") else "ipv4"
# better family detection
import socket
family = "ipv6" if (c.family == socket.AF_INET6) else "ipv4"
if c.status == "LISTEN":
listen_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": c.laddr.ip if c.laddr else "", "port": c.laddr.port if c.laddr else 0, "family": family, "pid": c.pid, "proto": "tcp"})
else:
tcp_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": laddr, "raddr": raddr, "family": family, "pid": c.pid, "status": c.status})
except Exception:
pass
tcp_list = tcp_list[:80]
listen_list = listen_list[:80]
return jsonify({"nics": rates, "conns": conns, "items": items,
"hw": get_net_hw(),
"total_recv": total_recv, "total_sent": total_sent,
"tcp": tcp_list, "listen": listen_list})
_net_hw_static = {}
def _net_sysfs(name):
"""实时 sysfs: 状态/MTU/速率/MAC,键名与 psutil 速率互补"""
out = {}
for base in ("/host/sys/class/net", "/sys/class/net"):
d = os.path.join(base, name)
try:
if not os.path.isdir(d):
continue
for k in ("address", "operstate", "mtu", "speed", "duplex",
"carrier"):
v = _read(os.path.join(d, k))
if v:
out[k] = v
# 驱动名
try:
drv = os.path.basename(os.readlink(os.path.join(d, "device/driver")))
if drv:
out["live_driver"] = drv
except Exception:
pass
if out:
break
except Exception:
continue
return out
def _net_addrs():
try:
ad = psutil.net_if_addrs() or {}
except Exception:
return {}
out = {}
for nic, lst in ad.items():
v4, v6, mac = [], [], ""
for a in lst:
try:
fam = a.family.name if hasattr(a.family, "name") else str(a.family)
except Exception:
fam = ""
addr = a.address or ""
if "PACKET" in fam:
if addr:
mac = mac or addr
elif "AF_INET6" in fam:
if addr and not addr.startswith("fe80"):
v6.append(addr.split("%")[0])
elif addr:
v6.append(addr.split("%")[0] + "(链路)")
elif "AF_INET" in fam:
if addr and len(addr.split(".")) == 4:
v4.append(addr)
out[nic] = {"v4": v4, "v6": v6, "mac": mac}
return out
def get_net_hw():
global _net_hw_static
if not _net_hw_static:
hj = {}
for jp in ("hw_net.json",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"hw_net.json")):
try:
with open(jp) as f:
hj = json.load(f)
if hj.get("nics"):
break
except Exception:
continue
_net_hw_static = hj or {"nics": []}
statics = {x.get("name"): dict(x) for x in _net_hw_static.get("nics", [])}
try:
st = psutil.net_if_stats() or {}
except Exception:
st = {}
addrs = _net_addrs()
# 展示的网卡: 静态档案 + 实际有流量的非虚拟口
names = list(statics.keys())
try:
counters = psutil.net_io_counters(pernic=True) or {}
except Exception:
counters = {}
for nic in counters:
if nic.startswith(("lo", "docker", "br-", "veth")):
continue
if nic not in statics:
names.append(nic)
out = []
for nic in names:
base = dict(statics.get(nic, {"name": nic}))
s = st.get(nic)
if s is not None:
try:
base["isup"] = bool(s.isup)
base["live_speed"] = s.speed or 0
base["live_mtu"] = s.mtu or 0
except Exception:
pass
base.update(_net_sysfs(nic))
a = addrs.get(nic, {})
if a:
base["ipv4"] = a.get("v4", [])
base["ipv6"] = a.get("v6", [])
if a.get("mac") and not base.get("mac"):
base["mac"] = a["mac"]
c = counters.get(nic)
if c is not None:
try:
base["io_sent"] = c.bytes_sent
base["io_recv"] = c.bytes_recv
except Exception:
pass
out.append(base)
# 默认网关/DNS 实时读
gw, dns = _net_hw_static.get("gateway", ""), _net_hw_static.get("dns", "")
base_if = ""
try:
with open("/host/proc/net/route" if os.path.exists("/host/proc/net/route") else "/proc/net/route") as f:
for line in f.read().splitlines()[1:]:
f_ = line.split()
if len(f_) > 2 and f_[1] == "00000000" and f_[7] == "00000000":
import socket, struct
gw = socket.inet_ntoa(struct.pack("<L", int(f_[2], 16)))
base_if = f_[0]
break
except Exception:
pass
try:
rp = "/host/etc/resolv.conf" if os.path.exists("/host/etc/resolv.conf") else "/etc/resolv.conf"
with open(rp) as f:
for line in f:
if line.startswith("nameserver"):
dns = line.split()[1]
break
except Exception:
pass
return {"nics": out, "gateway": gw, "dns": dns,
"egress": base_if,
"snapshot": _net_hw_static.get("snapshot", ""),
"note": _net_hw_static.get("note", "")}
@app.route("/api/kill", methods=["POST"])
def api_kill():
d = request.get_json(force=True, silent=True) or {}
try:
pid = int(d.get("pid", 0))
except Exception:
return jsonify({"ok": False, "error": "pid无效"}), 400
if pid <= 1:
return jsonify({"ok": False, "error": "不能结束该进程"}), 400
try:
os.kill(pid, signal.SIGTERM)
return jsonify({"ok": True})
except ProcessLookupError:
return jsonify({"ok": False, "error": "进程已不存在"}), 404
except PermissionError:
return jsonify({"ok": False, "error": "无权限"}), 403
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/health")
def health():
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)
-6
View File
@@ -1,6 +0,0 @@
1|#!/bin/bash
2|# /vol1/1000/HD1/APP/hermes/awork/fnmon/start.sh
3|# Start the CPU Monitor service
4|cd "$(dirname "$0")"
5|exec python3 app.py
6|
-1274
View File
File diff suppressed because it is too large. Load diff
-231
View File
@@ -1,231 +0,0 @@
#!/usr/bin/env python3
"""Traffic tracker: total / daily / monthly cumulative counters + per-container stats with speed."""
import json
import os
import re
import sqlite3
import subprocess
import time
from datetime import date
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'traffic.db')
# Module-level cache for per-container speed calculation
# { container_name: { 'rx': int, 'tx': int, 'ts': float }, ... }
_PREV_CONTAINER_STATS = {}
def _get_db():
"""Get SQLite connection with schema init."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS traffic_base (
id TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
c.execute('''CREATE TABLE IF NOT EXISTS traffic_daily (
date TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
c.execute('''CREATE TABLE IF NOT EXISTS traffic_monthly (
month TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
conn.commit()
return conn
def _get_interfaces():
"""Read /proc/net/dev and return {iface: {rx, tx}} for all relevant interfaces."""
interfaces = {}
try:
with open('/proc/net/dev') as f:
for line in f.readlines()[2:]: # skip headers
parts = line.strip().split()
if len(parts) >= 10:
iface = parts[0].rstrip(':')
# Skip loopback
if iface == 'lo':
continue
# Skip veth (Docker internal — counted per-container)
if iface.startswith('veth'):
continue
# Skip individual Docker bridges
if re.match(r'^br-[a-f0-9]{12}$', iface):
continue
interfaces[iface] = {
'rx': int(parts[1]),
'tx': int(parts[9]),
}
except Exception:
pass
return interfaces
def _get_main_traffic(interfaces):
"""Aggregate main interfaces (eno, enx, eth)."""
rx = tx = 0
for iface, data in interfaces.items():
if iface.startswith(('eno', 'enx', 'eth')):
rx += data['rx']
tx += data['tx']
return rx, tx
def _parse_bytes(s):
"""Parse '1.23kB', '45.4MB', '960B' etc to bytes."""
s = s.strip().replace(',', '')
m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s)
if not m:
return 0
val = float(m.group(1))
unit = m.group(2).lower()
multipliers = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6, 'mib': 1048576,
'gb': 1e9, 'gib': 1073741824, 'tb': 1e12, 'tib': 1099511627776}
return int(val * multipliers.get(unit, 1))
def _get_docker_containers():
"""
Get per-container network traffic + real-time speed.
Returns list sorted by total traffic descending.
Results cached for 30 seconds to avoid blocking SSE.
"""
global _PREV_CONTAINER_STATS, _DOCKER_CACHE, _DOCKER_CACHE_TIME
now = time.time()
# Return cache if fresh (30s)
if '_DOCKER_CACHE' in globals() and now - _DOCKER_CACHE_TIME < 30:
return _DOCKER_CACHE
containers = []
try:
result = subprocess.run(
['docker', 'stats', '--no-stream', '--format',
'{{.Name}}\t{{.NetIO}}'],
capture_output=True, text=True, timeout=30
)
lines = result.stdout.strip().split('\n')
now = time.time()
current_snapshot = {}
for line in lines:
parts = line.strip().split('\t')
if len(parts) >= 2:
name = parts[0]
netio = parts[1].strip()
rx_str, tx_str = '', ''
if ' / ' in netio:
rx_str, tx_str = netio.split(' / ', 1)
rx = _parse_bytes(rx_str.strip())
tx = _parse_bytes(tx_str.strip())
current_snapshot[name] = {'rx': rx, 'tx': tx, 'ts': now}
# Compute speed from previous snapshot
rx_speed = tx_speed = 0
prev = _PREV_CONTAINER_STATS.get(name)
if prev:
dt = now - prev['ts']
if dt > 0:
rx_speed = max(0, (rx - prev['rx']) / dt)
tx_speed = max(0, (tx - prev['tx']) / dt)
containers.append({
'name': name,
'rx': rx,
'tx': tx,
'rx_speed': round(rx_speed),
'tx_speed': round(tx_speed),
})
_PREV_CONTAINER_STATS = current_snapshot
except subprocess.TimeoutExpired:
pass
except FileNotFoundError:
pass # Docker not available
except Exception:
pass
# Sort by total traffic (rx + tx) descending
containers.sort(key=lambda c: c['rx'] + c['tx'], reverse=True)
_DOCKER_CACHE = containers
_DOCKER_CACHE_TIME = time.time()
return containers
def get_traffic_stats():
"""
Return traffic stats:
- total: cumulative since tracking started
- daily: cumulative for today
- monthly: cumulative for this month
- containers: top Docker containers with cumulative + speed
"""
conn = _get_db()
c = conn.cursor()
today = date.today().isoformat()
this_month = today[:7] # YYYY-MM
interfaces = _get_interfaces()
main_rx, main_tx = _get_main_traffic(interfaces)
# ---- Total traffic baseline (set once) ----
c.execute('SELECT rx_base, tx_base FROM traffic_base WHERE id=?', ('_main',))
row = c.fetchone()
if row:
rx_base, tx_base = row
else:
rx_base, tx_base = main_rx, main_tx
c.execute('INSERT OR REPLACE INTO traffic_base VALUES (?,?,?)',
('_main', rx_base, tx_base))
conn.commit()
total_rx = max(0, main_rx - rx_base)
total_tx = max(0, main_tx - tx_base)
# ---- Daily cumulative (baseline at start of day) ----
c.execute('SELECT rx_base, tx_base FROM traffic_daily WHERE date=?', (today,))
row = c.fetchone()
if row:
day_rx_base, day_tx_base = row
else:
# First call today: set baseline = current total, so daily starts at 0
day_rx_base, day_tx_base = total_rx, total_tx
c.execute('INSERT OR REPLACE INTO traffic_daily VALUES (?,?,?)',
(today, day_rx_base, day_tx_base))
conn.commit()
daily_rx = max(0, total_rx - day_rx_base)
daily_tx = max(0, total_tx - day_tx_base)
# ---- Monthly cumulative (baseline at start of month) ----
c.execute('SELECT rx_base, tx_base FROM traffic_monthly WHERE month=?',
(this_month,))
row = c.fetchone()
if row:
mon_rx_base, mon_tx_base = row
else:
mon_rx_base, mon_tx_base = total_rx, total_tx
c.execute('INSERT OR REPLACE INTO traffic_monthly VALUES (?,?,?)',
(this_month, mon_rx_base, mon_tx_base))
conn.commit()
monthly_rx = max(0, total_rx - mon_rx_base)
monthly_tx = max(0, total_tx - mon_tx_base)
conn.close()
# ---- Docker containers ----
containers = _get_docker_containers()
return {
'total_rx': total_rx,
'total_tx': total_tx,
'daily_rx': daily_rx,
'daily_tx': daily_tx,
'monthly_rx': monthly_rx,
'monthly_tx': monthly_tx,
'containers': containers[:15], # top 15
}
if __name__ == '__main__':
print(json.dumps(get_traffic_stats(), indent=2))