Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9757e2ccb | ||
|
|
de153140f4 | ||
|
|
41194e2935 | ||
|
|
082a94293a | ||
|
|
bf5a1c7301 | ||
|
|
6620377934 | ||
|
|
81df67fb20 | ||
|
|
96442db19c | ||
|
|
0936a86d0e | ||
|
|
ed23092cd6 | ||
|
|
d5e78908fb | ||
|
|
e0627a9158 | ||
|
|
986dc63179 | ||
|
|
fc764b9faa | ||
|
|
44eccf9a75 | ||
|
|
1abd96f081 | ||
|
|
37727fa9df | ||
|
|
c13dfb6df8 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
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 app.py .
|
||||||
COPY hw_mem.json .
|
COPY hw_mem.json .
|
||||||
COPY hw_disk.json .
|
COPY hw_disk.json .
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -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|
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
# 飞牛资源管理器 (fnos-resmon)
|
# 飞牛NAS必装:进程级资源监控,比自带强10倍,一眼看穿谁在偷吃你的CPU/内存/磁盘/网络全透视,带一键内存回收的监控面板
|
||||||
|
CPU/内存/磁盘/网络 + 进程排行 + 一键回收 + SMART/连接明细,Docker一键部署
|
||||||
|
|
||||||
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
|
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[](https://gitea.88531.cn/jinghui/fnos-resmon)
|
|
||||||
[](https://gitea.88531.cn/jinghui/fnos-resmon)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,7 +35,13 @@
|
|||||||
|
|
||||||
## 📸 功能截图
|
## 📸 功能截图
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,11 +88,6 @@ docker run -d --name fnos-resmon -p 7031:7031 --privileged \
|
|||||||
|
|
||||||
启动后浏览器访问 `http://你的NASIP:7031` 即可。
|
启动后浏览器访问 `http://你的NASIP:7031` 即可。
|
||||||
|
|
||||||
### 方式二:飞牛应用中心安装 FPK
|
|
||||||
|
|
||||||
1. 下载 [Releases](https://gitea.88531.cn/jinghui/fnos-resmon/releases) 中的 FPK
|
|
||||||
2. 飞牛应用中心 → 本地安装 → 选择 FPK
|
|
||||||
3. 安装完成后打开「飞牛资源管理器」
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,11 +108,6 @@ docker run -d --name fnos-resmon -p 7031:7031 --privileged \
|
|||||||
|------|------|
|
|------|------|
|
||||||
| Docker 镜像 | `jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest` |
|
| Docker 镜像 | `jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest` |
|
||||||
| Gitea 仓库 | https://gitea.88531.cn/jinghui/fnos-resmon |
|
| Gitea 仓库 | https://gitea.88531.cn/jinghui/fnos-resmon |
|
||||||
| FPK 包 | [Releases](https://gitea.88531.cn/jinghui/fnos-resmon/releases) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 贡献
|
|
||||||
|
|
||||||
欢迎 Issue 和 PR!
|
欢迎 Issue 和 PR!
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
".url": {
|
|
||||||
"fnos-resmon.Application": {
|
|
||||||
"title": "飞牛资源管理器",
|
|
||||||
"icon": "images/icon_{0}.png",
|
|
||||||
"type": "url",
|
|
||||||
"protocol": "http",
|
|
||||||
"port": "7031",
|
|
||||||
"url": "/",
|
|
||||||
"allUsers": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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']}")
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
飞牛资源管理器:一眼看穿NAS谁在偷吃资源
|
||||||
|
你的飞牛NAS卡顿、内存飙升,系统自带却只能看汇总数字,根本找不到元凶。飞牛资源管理器,一个Docker命令就能装好,进程级监控一目了然。
|
||||||
|
CPU、内存、磁盘、网络四大面板实时刷新,每个进程的CPU和内存占用清清楚楚。
|
||||||
|
内存面板还能看RSS、USS、SWAP明细,一键回收未使用的内存,释放空间立竿见影。
|
||||||
|
磁盘面板直接显示SMART健康、温度、通电时间,硬盘故障提前预警。
|
||||||
|
网络面板列出所有TCP连接和侦听端口,哪个进程在联网一目了然。
|
||||||
|
表头点击排序,手机也能看,Docker运行不修改系统文件。开源免费,欢迎Star。
|
||||||
|
收藏这个视频,下次NAS卡顿时就知道该查哪里。
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called after the user change environment variables in application setting page.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called before the user change environment variables in application setting page.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called after the user installs the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called before the user installs the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called after the user uninstalls the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called after the user uninstalls the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called after the user upgrades the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
### This script is called before the user upgrades the application.
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"network": ["host"],
|
|
||||||
"pid": ["host"],
|
|
||||||
"volumes": ["/proc", "/sys", "/etc/passwd", "/"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -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|
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
fnos-resmon:
|
fnos-resmon:
|
||||||
image: 192.168.68.19:3200/jinghui/fnos-resmon:latest
|
image: jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
|
||||||
container_name: fnos-resmon
|
container_name: fnos-resmon
|
||||||
network_mode: host
|
network_mode: host
|
||||||
pid: host
|
pid: host
|
||||||
@@ -12,5 +12,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /proc:/host/proc:rw
|
- /proc:/host/proc:rw
|
||||||
- /sys:/host/sys:ro
|
- /sys:/host/sys:ro
|
||||||
- /etc/passwd:/host/etc/passwd:ro
|
|
||||||
- /:/host:ro
|
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -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'')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
appname: fnos-resmon
|
|
||||||
version: 1.2.4
|
|
||||||
display_name: 飞牛资源管理器
|
|
||||||
desc: 飞牛 NAS 资源监视器(CPU/内存/磁盘/网络四页签,读取宿主真实数据)
|
|
||||||
arch: x86_64
|
|
||||||
source: docker
|
|
||||||
maintainer: jinghui
|
|
||||||
@@ -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>
|
||||||
@@ -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 排行实时刷新,点表头即排序。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 内存面板:一键回收释放空间
|
||||||
|
|
||||||
|
每个进程 RSS/USS/SWAP 三列细分,🧹 一键回收未使用的内存,异常进程一键结束。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 磁盘面板:硬盘健康提前预警
|
||||||
|
|
||||||
|
SMART 健康、温度、通电时间 Web 直观展示,硬盘故障提前发现。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 网络面板:谁在联网一目了然
|
||||||
|
|
||||||
|
所有 TCP 连接和侦听端口明细,进程+地址+状态,网卡驱动/MTU/双工全展示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 一条命令安装
|
||||||
|
|
||||||
|
```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 运行不修改系统文件
|
||||||
@@ -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}
|
.st-sleep{color:#ff9900}.st-run{color:#20a53a}
|
||||||
.kill{color:#20a53a;cursor:pointer;background:none;border:none;font-size:13px}
|
.kill{color:#20a53a;cursor:pointer;background:none;border:none;font-size:13px}
|
||||||
.kill:hover{text-decoration:underline}
|
.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}
|
.num{font-variant-numeric:tabular-nums}
|
||||||
.bar{height:8px;background:#f0f0f0;border-radius:4px;overflow:hidden;margin-top:6px}
|
.bar{height:8px;background:#f0f0f0;border-radius:4px;overflow:hidden;margin-top:6px}
|
||||||
.bar>i{display:block;height:100%;background:#20a53a;border-radius:4px}
|
.bar>i{display:block;height:100%;background:#20a53a;border-radius:4px}
|
||||||
@@ -52,6 +55,11 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
|
|||||||
<div class="tab" data-t="mem">内存</div>
|
<div class="tab" data-t="mem">内存</div>
|
||||||
<div class="tab" data-t="disk">磁盘</div>
|
<div class="tab" data-t="disk">磁盘</div>
|
||||||
<div class="tab" data-t="net">网络</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.2.8</span>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="summary"></div>
|
<div class="card" id="summary"></div>
|
||||||
<div class="cpuinfo" id="cpuinfo" style="display:none"></div>
|
<div class="cpuinfo" id="cpuinfo" style="display:none"></div>
|
||||||
@@ -72,7 +80,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 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 fmtR(b){return fmtB(b)+'/s'}
|
||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<')}
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<')}
|
||||||
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)}
|
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){
|
function cmpVal(p,key){
|
||||||
if(key==='name')return String(p.name||'');
|
if(key==='name')return String(p.name||'');
|
||||||
@@ -136,7 +144,7 @@ document.getElementById('sub').textContent=`共 ${j.total} 个进程,显示占
|
|||||||
renderHeader();
|
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('');
|
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'){
|
}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 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>`;
|
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='';
|
document.getElementById('meminfo').style.display='';
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
Flask
|
flask
|
||||||
psutil
|
psutil
|
||||||
@@ -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)
|
|
||||||
@@ -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|
|
|
||||||
@@ -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))
|
|
||||||