Compare commits

...
48 Commits
Author SHA1 Message Date
3970260761 755c19849e v1.3.16: 测速加停止按钮(AbortController中断等待,停止不记录) 2026-11-08 01:11:59 +08:00
3970260761 d53c616f34 v1.3.15: 四页测速加预计用时(标题/按钮/等待提示) 2026-11-08 01:09:34 +08:00
3970260761 392f6a737c v1.3.14 fix: 网络上传测速改浏览器UA(Cloudflare拦默认UA) 2026-11-08 01:00:03 +08:00
3970260761 1aff3138cb v1.3.14: CPU每核MHz显示+四页测速(CPU运行速度/内存读写/硬盘读写/网络上下行)+最多5条历史自动删旧 2026-11-08 00:59:09 +08:00
3970260761 f417f8b01a fix: 切换页签回来时性能块恢复显示(防闪命中也恢复display) 2026-11-08 00:36:30 +08:00
3970260761 133959ef74 feat: CPU页系统性能三档切换 v1.3.13(省电/平衡/高性能,写宿主cpufreq即时生效,宿主文件持久记忆,旁注说明) 2026-11-08 00:32:32 +08:00
3970260761 e7ee434306 chore: compose 补授权 hub 环境变量与密钥挂载 v1.3.12 2026-11-08 00:23:13 +08:00
3970260761 02cbda07e1 feat: 接入授权中心 hub 形态 v1.3.12(安装统计/在线/公告/验新,右上角铃铛+版本号点击验新,失败开放) 2026-11-08 00:22:46 +08:00
3970260761 214a62d872 feat: 按页签记住用户排序刷新/切换不丢 v1.3.11 2026-11-08 00:05:42 +08:00
3970260761 9849be2ab2 fix: 自动刷新走排序渲染点排序不再被刷回 v1.3.10 2026-11-07 23:48:15 +08:00
3970260761 78c16f9f3e feat: 表头排序加手形+悬停高亮 v1.3.9 2026-11-07 23:45:30 +08:00
3970260761 573b0c5a24 fix: 停止按钮仅运行中才显示停止中文案 v1.3.8 2026-11-07 23:29:16 +08:00
3970260761 7fd72e2106 fix: 优化状态独立保存诊断重绘后自动恢复 v1.3.7 2026-11-07 23:26:45 +08:00
3970260761 1d4fc7a691 feat: 一键优化旁加停止按钮可中断+取消标记 v1.3.6 2026-11-07 23:24:41 +08:00
3970260761 0624129c2a fix: 修正优化建议文案去碎片整理 v1.3.5 2026-11-07 23:22:40 +08:00
3970260761 bb7101af67 fix: 一键优化改后台线程去compact防掉线+说明+按钮变灰+进度显示 v1.3.5 2026-11-07 23:20:17 +08:00
3970260761 ec38cd80e0 docs: 推广文章按v1.3.4重写(温度风扇+负载诊断+一键优化)+四页截图重截上传 2026-11-07 14:52:47 +08:00
3970260761 f5ad6babc2 docs: 更新CPU页截图 v1.3.4 含温度风扇面板 2026-11-07 14:48:57 +08:00
3970260761 555d8839e7 feat: CPU页加温度/风扇显示,过滤0转速空接口 v1.3.4 2026-11-07 14:46:36 +08:00
jinghui 497510c32b docs: 更新四页高清截图 v1.3.3 2026-11-07 14:42:05 +08:00
jinghui a430e3a791 feat: CPU页加负载诊断+优化建议+一键优化系统 2026-11-07 14:37:52 +08:00
jinghui eab6475199 feat: 右上角加刷新间隔设置与停止/恢复刷新 2026-11-07 14:31:02 +08:00
jinghui b9757e2ccb feat: 内存回收改为大按钮样式 + 恢复Docker源码 2026-11-07 14:24:37 +08:00
jinghui de153140f4 docs: CPU截图换高清半屏版,删旧长图 2026-11-07 14:18:17 +08:00
jinghui 41194e2935 docs: 高清半屏截图,每页一图 2026-11-07 14:15:28 +08:00
jinghui 082a94293a chore: remove FPK packaging, docker-only 2026-11-07 14:03:40 +08:00
jinghui bf5a1c7301 更新 README.md 2026-11-06 17:28:30 +08:00
jinghui 6620377934 更新 README.md 2026-11-06 17:25:07 +08:00
jinghui 81df67fb20 更新 README.md 2026-11-06 17:21:11 +08:00
jinghui 96442db19c 上传文件至「docs」 2026-11-06 17:19:26 +08:00
jinghui 0936a86d0e 更新 README.md 2026-11-06 17:18:48 +08:00
jinghui ed23092cd6 feat: rename memory recycle button 2026-11-06 17:16:27 +08:00
jinghui d5e78908fb docs: full-page screenshots 2026-11-06 17:10:57 +08:00
jinghui e0627a9158 docs: add per-page screenshots 2026-11-06 17:08:54 +08:00
jinghui 986dc63179 更新 README.md 2026-11-06 17:02:22 +08:00
jinghui fc764b9faa 更新 README.md 2026-11-06 16:52:04 +08:00
jinghui 44eccf9a75 更新 README.md 2026-11-06 16:43:52 +08:00
jinghui 1abd96f081 v1.2.6 version in top-right 2026-11-06 16:37:59 +08:00
jinghui 37727fa9df feat: add install.sh and docker-compose for auto-update 2026-11-06 16:37:28 +08:00
jinghui c13dfb6df8 v1.2.5 top-right links 2026-11-06 16:30:10 +08:00
jinghui 42f1d20a99 docs: advantage comparison 2026-11-06 16:28:17 +08:00
jinghui 9e2475dfe0 docs: detailed README 2026-11-06 16:27:02 +08:00
jinghui 5d46667aef docs: use public TCR image address 2026-11-06 16:23:47 +08:00
jinghui d1c923d59e docs: add screenshot and details 2026-11-06 16:23:17 +08:00
jinghui 28eecb55ed docs: clean README for fnos-resmon 2026-11-06 16:21:59 +08:00
jinghui 3f72ef7412 feat: add source code 2026-11-06 16:20:25 +08:00
jinghui 843322534f v1.2.4 floating footer 2026-11-06 16:16:46 +08:00
jinghui b52df4b192 v1.2.3 public links 2026-11-06 16:14:34 +08:00
49 changed files with 2037 additions and 3380 deletions

No files matched your search

+10 -26
View File
@@ -1,27 +1,11 @@
# Use an official Python runtime as a parent image
FROM python:3.9-slim-bullseye
# Set the working directory in the container
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies (for psutil if needed)
RUN apt-get update && apt-get install -y \
smartmontools \
net-tools \
iproute2 \
procps \
lm-sensors \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Make port 5000 available to the world outside this container
EXPOSE 5000
# Run app.py when the container launches
CMD ["python3", "app.py"]
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 util-linux && rm -rf /var/lib/apt/lists/* && pip install --no-cache-dir flask psutil -i https://mirrors.aliyun.com/pypi/simple/
COPY app.py .
COPY hw_mem.json .
COPY hw_disk.json .
COPY hw_net.json .
COPY public ./public
ENV PORT=7031
EXPOSE 7031
CMD ["python", "app.py"]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-192
View File
@@ -1,192 +0,0 @@
1|# 💻 fnmon — fnOS 全功能系统监控面板
2|
3|> 一款专为飞牛 fnOS 打造的**实时系统监控面板**,一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面!
4|
5|---
6|
7|## 🎯 简介
8|
9|**fnmon**是一个轻量级的 Web 监控工具,以 Docker 容器方式运行在飞牛系统上。无需安装额外驱动、无需修改系统文件,一条命令跑起来就能用。
10|
11|它提供了传统 NAS 系统(如群晖 DSM、威联通 QTS)才有的可视化监控体验,但专为飞牛 OS 深度定制。
12|
13|---
14|
15|## ✨ 功能亮点
16|
17|### ⚡ CPU 监控
18|- 实时频率显示(每个核心独立展示)
19|- Governor / EPP 运行模式
20|- 核心温度(支持 SATA/HDD/SDD/NVMe)
21|- Turbo Boost / 超频检测
22|- 60 秒频率历史曲线图
23|- **CPU 评测跑分数据**(PassMark、Cinebench、Geekbench)
24|- **同级 CPU 横向对比柱状图**(N100/J4125/i5-8400T/i3-12100...)
25|
26|### 🧠 内存监控
27|- 总容量 / 已用 / 可用 / 缓存
28|- SWAP 使用情况
29|- 内存历史趋势图
30|
31|### 🌐 网络监控
32|- 实时网速(上传/下载)
33|- 总流量统计
34|- **Docker 容器流量排行**(哪个容器最占带宽一目了然)
35|- 按日/月/年统计
36|
37|### 💾 磁盘监控
38|- 所有磁盘设备列表
39|- **物理硬盘健康状态**(SMART 信息)
40| - 🌡 硬盘温度
41| - ❤️ NVMe 寿命百分比
42| - ✅ SATA 健康状态(PASSED/FAILED)
43| - 🔄 通电时间
44| - 💾 总写入量
45|
46|### 📱 应用用量排行
47|- 综合 CPU + 内存 + 网络 三维度评分
48|- 按 **日/周/月/年/总计** 排行
49|- 一键切换时间维度
50|
51|### 🔝 进程 CPU 占用排行(TOP 20)
52|- 实时刷新,按 CPU 占用排序
53|- 显示 PID、用户、启动时间
54|- CPU% 颜色标识(红 > 50% / 橙 > 20% / 黄 > 5%)
55|
56|---
57|
58|## 📷 界面预览
59|
60|```
61|+----------------------------------------------------------+
62|| ⚡ CPU i3-9100T@ 3.10GHz 💠 4C |
63|| Governor EPP 温度 频率范围 负载 风扇 模式 Boost |
64|| ███████ ███ ███ ███████ ███ ███ ███ ████████ |
65|+----------------------------------------------------------+
66|| [CPU0 799MHz ████] [CPU1 800MHz ████] |
67|| [CPU2 799MHz ████] [CPU3 800MHz ████] |
68|+----------------------------------------------------------+
69|| 📈 频率历史 (60s) |
70|| ╱╲ |
71|| ╱ ╲___ 频率曲线图 |
72|+----------------------------------------------------------+
73|| 🆚 同级CPU对比 (PassMark多核) |
74|| i3-9100T ← 你 ██████████ 5500 |
75|| i5-8400T ████████████████ 8500 |
76|| N100 ████████ 4200 |
77|+----------------------------------------------------------+
78|| 📊 性能评测 |
79|| PassMark: 5500 Cinebench: 3200 Geekbench: 3500 |
80|+----------------------------------------------------------+
81|| 🔝 进程排行 | PID | 用户 | CPU% | 内存 | 启动 | 进程名 |
82|+----------------------------------------------------------+
83|```
84|
85|(实际界面为深色主题,数据实时刷新,更美观)
86|
87|---
88|
89|## 🚀 快速安装
90|
91|### 方式一:Docker 一键运行(推荐 👍)
92|
93|```bash
94|docker run -d \
95| --name fnmon \
96| -p 5000:5000 \
97| --privileged \
98| -v /proc:/proc:ro \
99| -v /sys:/sys:ro \
100| jinghui1984/fnmon:latest
101|```
102|
103|启动后浏览器访问:**http://飞牛IP:5000**
104|
105|### 方式二:Docker Compose
106|
107|创建 `docker-compose.yml`:
108|
109|```yaml
110|version: '3'
111|services:
112| fnmon:
113| image: jinghui1984/fnmon:latest
114| container_name: fnmon
115| restart: always
116| ports:
117| - "5000:5000"
118| privileged: true
119| volumes:
120| - /proc:/proc:ro
121| - /sys:/sys:ro
122|```
123|
124|运行:
125|```bash
126|docker compose up -d
127|```
128|
129|### 方式三:SSH 离线安装
130|
131|下载 fpk + 脚本:
132|
133|| 文件 | 说明 |
134||------|------|
135|| `fnmon-v1.0.15.fpk` | fnOS 应用包(44KB) |
136|| `install.sh` | 一键安装脚本 |
137|
138|```bash
139|sudo bash install.sh
140|```
141|
142|---
143|
144|## ⚙️ 技术架构
145|
146|```
147|┌─────────────┐ ┌─────────────┐ ┌─────────────┐
148|│ Flask 后端 │────▶│ SQLite 存储 │────▶│ 前端 Dashboard │
149|│ (Python) │ │ (用量追踪) │ │ (HTML+JS) │
150|└──────┬──────┘ └─────────────┘ └─────────────┘
151| │
152| ├──▶ psutil / smartctl / /proc (系统数据采集)
153| ├──▶ Docker API (容器流量)
154| └──▶ SSE 推送 (实时刷新)
155|```
156|
157|---
158|
159|## 📦 下载链接
160|
161|| 资源 | 链接 / 位置 |
162||------|------------|
163|| Docker Hub | `jinghui1984/fnmon:latest` |
164|| Docker Compose | 见上方 `docker-compose.yml` |
165|| 离线 fpk | 评论区下载 |
166|
167|---
168|
169|## 🔄 更新计划
170|
171|- [x] CPU 频率 + 核心温度监控
172|- [x] 内存 / SWAP 趋势图
173|- [x] 网络流量 + 容器排行
174|- [x] 硬盘健康 (SMART / NVMe 寿命)
175|- [x] 进程 CPU 排行
176|- [x] 应用用量排行(日/周/月/年)
177|- [x] CPU 评测 + 对比
178|- [ ] 暗色/亮色主题切换
179|- [ ] 自定义端口配置
180|- [ ] 微信/邮件告警(CPU温度过高)
181|- [ ] 多语言支持
182|
183|---
184|
185|## 💬 交流反馈
186|
187|- 使用中遇到问题请在评论区留言
188|- 想加新功能请在这里提出建议
189|- GitHub:https://github.com/jinghui1984/fnmon
190|
191|**觉得好用的话记得回帖支持一下!** 🎉
192|
+64 -175
View File
@@ -1,78 +1,78 @@
<div align="center">
# 飞牛NAS必装:进程级资源监控,比自带强10倍,一眼看穿谁在偷吃你的CPU/内存/磁盘/网络全透视,带一键内存回收的监控面板
CPU/内存/磁盘/网络 + 进程排行 + 一键回收 + SMART/连接明细,Docker一键部署
# 🖥️ 小白系统监控器 — fnOS飞牛实时系统监控面板 - docker应用
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
**一键 Docker 部署 | 6 大监控维度 | 数据可导出**
[![Docker Hub](https://img.shields.io/badge/Docker-Hub-2496ED?style=flat&logo=docker)](https://hub.docker.com/r/jinghui1984/fnmon)
[![GitHub stars](https://img.shields.io/github/stars/personal82555/fnmon?style=social)](https://github.com/personal82555/fnmon)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![fnOS](https://img.shields.io/badge/fnOS-%E9%A3%9E%E7%89%9B-orange)](https://www.fnnas.com)
---
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dd6dbc5.png" alt="fnmon 总览" width="80%"/>
</p>
## ✨ 为什么选择 fnos-resmon?与飞牛自带的对比
**fnmon** 是一款专为飞牛 fnOS 打造的实时系统监控面板。一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面,涵盖 **CPU、内存、网络、磁盘、进程排行、应用用量** 六大维度,所有历史数据支持一键导出。
飞牛自带的系统信息只能看到 CPU/内存占用率、存储卷用量等**汇总数字**:哪个进程在吃 CPU?哪块盘 SMART 告警了?哪个端口被谁占用了?一键能回收内存吗?这些它都做不到。fnos-resmon 正好补上这些短板:
</div>
| 能力 | 飞牛自带 | fnos-resmon |
|------|---------|-------------|
| CPU/内存占用率 | ✅ 有 | ✅ 有 |
| 进程级 CPU/内存排行 | ❌ 无 | ✅ 进程名/PID/用户/状态/线程数 |
| 内存 RSS/USS/SWAP 明细 | ❌ 无 | ✅ 三列细分 |
| 一键内存回收(drop caches) | ❌ 无 | ✅ 一键释放 |
| 一键结束异常进程 | ❌ 无 | ✅ 一键 kill |
| 磁盘 SMART/温度/通电时间 | ❌ 需命令行 | ✅ Web 直观展示 |
| TCP 连接明细/侦听端口 | ❌ 无 | ✅ 进程+地址+状态 |
| 网卡驱动/MTU/双工 | ❌ 无 | ✅ 有 |
| 表头排序/箭头指示 | ❌ 无 | ✅ 点击即排序 |
---
## ✨ 核心特性
| 特性 | 说明 |
|------|------|
| 🚀 **一键部署** | 一条 `docker run` 命令启动,无需安装驱动 |
| ⚡ **实时推送** | SSE 技术,2 秒刷新一次,延迟极低 |
| 📊 **维度全面** | 6 大面板覆盖系统全部关键指标 |
| 💾 **数据导出** | 一键导出所有历史数据到 NAS 目录,JSON 格式 |
| 📱 **移动适配** | 深色主题,侧边栏导航,屏幕利用率高 |
| 🐳 **零侵入** | Docker 容器运行,不修改系统文件 |
- 🚀 **一键部署**:一条 `docker run` 启动,无需额外驱动
- ⚡ **实时刷新**:3 秒自动更新,数据实时
- 📊 **维度全面**:CPU / 内存 / 磁盘 / 网络 四大面板,进程级明细
- 🔍 **进程级洞察**:每个进程 CPU、内存、IO、连接数一目了然
- 🧹 **内存回收**:一键 drop caches 释放可回收内存
- 🔪 **进程管理**:一键结束异常进程
- 📱 **移动适配**:响应式布局,手机也能看
- 🐳 **零侵入**:Docker 容器运行,不修改系统文件
---
## 📸 功能截图
### ⚡ CPU 监控
实时显示 CPU 型号、每个核心的当前频率、Governor/EPP 运行模式、核心温度(支持 SATA/NVMe 硬盘温度)、Turbo Boost 状态、60 秒频率历史曲线图。还包括 CPU 评测跑分(PassMark/Cinebench/Geekbench)和同级 CPU 横向对比。
![CPU](docs/cpu.png)
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1db91017.png" alt="CPU 面板" width="90%"/>
</p>
![内存](docs/mem.png)
### 🧠 内存监控
总容量 / 已用 / 可用 / 缓存 / SWAP 全面展示,进度条可视化,数据实时更新。
![磁盘](docs/disk.png)
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dcc7cc3.png" alt="内存面板" width="90%"/>
</p>
![网络](docs/net.png)
### 🌐 网络流量
实时上传/下载网速、当月/当日累计流量、Docker 容器流量排行(TOP 15)、60 秒流量趋势图。
---
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dc13916.png" alt="网络面板" width="90%"/>
</p>
## 📋 功能清单
### 💾 磁盘健康
物理硬盘列表(型号、容量、接口类型)、SMART 健康状态、硬盘温度、NVMe 寿命百分比、通电时间、总写入量。磁盘分区挂载点使用率 + 60 秒 I/O 趋势图。
### CPU 监控
- CPU 型号、架构、物理/逻辑核数
- 实时 CPU 占用率、负载(1/5/15 分钟)
- 每核心频率与占用率可视化
- L1/L2/L3 缓存信息
- 进程 CPU 占用排行(TOP N)
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1de2fcce.png" alt="磁盘面板" width="90%"/>
</p>
### 内存监控
- 内存占用率、已用/总共/可用
- SWAP 使用情况
- 物理内存槽位详情(容量、类型、频率、厂商)
- 进程 RSS / USS / SWAP 明细
- 一键回收可回收内存(drop caches)
### 📱 应用用量排行
综合 CPU + 内存 + 网络 三维度加权评分,可按 **日/周/月/年/总计** 切换时间维度,快速定位资源消耗大户。
### 磁盘监控
- 挂载点使用率、读写累计
- 物理硬盘 SMART 健康状态
- 硬盘温度、通电时间、坏道重映射
- 阵列拓扑展示
<p align="center">
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1df0cf82.png" alt="应用用量排行" width="90%"/>
</p>
### 🔝 进程 CPU 排名
实时 TOP 20 进程,含 PID、用户、CPU%、内存%、启动时间。CPU% 用颜色标识(🔴>50% / 🟠>20% / 🟡>5%)。
### 网络监控
- 实时上传/下载速率、累计收发量
- 连接数统计
- TCP 连接明细表(进程、本地/远程地址、状态)
- 侦听端口表
- 网卡信息(IP、MAC、速率、双工、MTU、驱动)
---
@@ -81,62 +81,13 @@
### 方式一:Docker 运行(推荐)
```bash
docker run -d --name fnmon -p 5000:5000 --privileged \
-v /proc:/proc:ro -v /sys:/sys:ro \
-v /vol2/fnmon-exports:/var/exports \
jinghui1984/fnmon
docker run -d --name fnos-resmon -p 7031:7031 --privileged \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
```
启动后浏览器访问 `http://你的NASIP:5000` 即可。
启动后浏览器访问 `http://你的NASIP:7031` 即可。
> **📌 导出目录说明:** `-v /vol2/fnmon-exports:/var/exports` 用于保存导出的历史数据,如不需要可省略。
### 方式二:Docker Compose
```yaml
version: '3'
services:
fnmon:
image: jinghui1984/fnmon
container_name: fnmon
restart: always
ports:
- "5000:5000"
privileged: true
volumes:
- /proc:/proc:ro
- /sys:/sys:ro
- /vol2/fnmon-exports:/var/exports
environment:
- TZ=Asia/Shanghai
```
---
## 🔧 功能详解
### 数据导出
点击侧边栏底部的 **⬇️ 导出** 按钮,一键将所有历史数据保存为 JSON 文件到挂载的 NAS 目录。文件名格式:`fnmon-export-YYYY-MM-DD-HHMMSS.json`。
导出的数据包含:
| 内容 | 说明 |
|------|------|
| CPU 实时数据 | 频率、温度、Governor、EPP |
| 内存快照 | 已用、缓存、SWAP |
| 网络历史 | 60 秒流量队列 |
| 磁盘 I/O | 设备读写速率 |
| 应用用量 | SQLite 全量数据(日/周/月/年 聚合) |
| Docker 流量 | 容器网络存档 |
通过 ⚙️ **设置** 按钮可自由配置导出路径。
### 历史数据追踪
- **网络流量**:累计统计总/日/月 流量,Docker 容器排行
- **应用用量**:60 秒采集一次进程 + Docker 资源快照,自动聚合到日/周/月/年维度
- **SSE 实时推送**:2 秒间隔推送所有数据,前端自动渲染
---
@@ -146,29 +97,8 @@ services:
|------|---------|
| 后端框架 | Flask (Python) |
| 前端 | 纯 HTML/CSS/JS(无额外依赖) |
| 实时通信 | Server-Sent Events (SSE) |
| 数据采集 | psutil、smartctl、/proc 文件系统 |
| 数据存储 | SQLite(应用用量 + 流量审计) |
| 容器化 | Docker(基于 python:3.9-slim) |
| 镜像大小 | ~342MB |
---
## 📋 功能清单
- [x] CPU 频率 / 核心温度 / Governor / EPP
- [x] 内存 / SWAP 实时 & 趋势
- [x] 网络流量实时 & 累计(日 / 月 / 总)
- [x] Docker 容器流量排行
- [x] 磁盘分区使用率 & I/O 趋势
- [x] 物理硬盘健康(SMART / NVMe 寿命)
- [x] 进程 CPU 占用排行(TOP 20)
- [x] 应用用量排行(日/周/月/年)
- [x] CPU 评测跑分 & 同级对比
- [x] 一键数据导出
- [ ] 暗色 / 亮色主题切换
- [ ] 微信 / 邮件告警
- [ ] 多语言支持
| 容器化 | Docker |
---
@@ -176,55 +106,14 @@ services:
| 资源 | 地址 |
|------|------|
| Docker Hub | `jinghui1984/fnmon` |
| GitHub | 本仓库 |
| 论坛讨论 | [飞牛论坛](https://club.fnnas.com) |
| Docker 镜像 | `jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest` |
| Gitea 仓库 | https://gitea.88531.cn/jinghui/fnos-resmon |
欢迎 Issue 和 PR!
---
## 🤝 贡献
欢迎 Issue 和 PR!如果你有好的想法或发现了 Bug,请到 [Issues](https://github.com/personal82555/fnmon/issues) 提出。
---
<div align="center">
---
## 📦 安装步骤
1. 下载 FPK:[fnos-resmon-v1.2.1.fpk](http://192.168.68.19:3200/attachments/61f85108-f698-4682-a273-8f9b6ebc7fac)
2. 飞牛应用中心 → 本地安装 → 选择 FPK
3. 安装完成后打开「飞牛资源管理器」,访问 `http://<NAS IP>:7031`
或直接拉取镜像:
```bash
docker pull 192.168.68.19:3200/jinghui/fnos-resmon:latest
```
## fnos-resmon(飞牛资源管理器)
本仓库另含 fnos-resmon:CPU / 内存 / 磁盘 / 网络 四页签资源监视器,读取宿主真实数据。
### 功能
- CPU:进程 CPU 占用、线程数、PID、描述、用户、状态,可按列排序
- 内存:RSS / USS / SWAP 三列
- 磁盘:进程读写累计
- 网络:连接数进程、TCP 连接表、侦听端口表
- 内存回收:一键 drop caches
- 进程结束:一键 kill
- 表头排序方向可视化,内部滚动自适应
### 下载
- FPK:`/vol2/1000/HD2/fpk/fnos-resmon-v1.2.0.fpk`
- 镜像:`192.168.68.19:3200/jinghui/fnos-resmon:latest`
### 链接
## 🔗 链接
- 资享导航站:https://nav.88531.cn
- 开源地址:http://192.168.68.19:3200/jinghui/fnos-resmon
</div>
- 开源地址:https://gitea.88531.cn/jinghui/fnos-resmon
+1504 -722
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
-16
View File
@@ -1,16 +0,0 @@
services:
fnos-resmon:
image: 192.168.68.19:3200/jinghui/fnos-resmon:latest
container_name: fnos-resmon
network_mode: host
pid: host
restart: unless-stopped
environment:
- PORT=7031
- PROCFS_PATH=/host/proc
privileged: true
volumes:
- /proc:/host/proc:rw
- /sys:/host/sys:ro
- /etc/passwd:/host/etc/passwd:ro
- /:/host:ro
BIN
View File
Binary file not shown.
-13
View File
@@ -1,13 +0,0 @@
{
".url": {
"fnos-resmon.Application": {
"title": "飞牛资源管理器",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "http",
"port": "7031",
"url": "/",
"allUsers": false
}
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 326 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

BIN
View File
Binary file not shown.
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""Generate a self-contained fpk for distribution — includes app code + static assets."""
import os, re, struct, zlib, tarfile, io, json, hashlib, time as tmod
OUTDIR = '/vol2/1000/HD2/fpk'
APP_NAME = 'fnmon'
APP_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(OUTDIR, exist_ok=True)
# === Auto-version ===
latest_ver = '0.0.0'
# Also account for legacy cpu-monitor-v* fpks
for f in os.listdir(OUTDIR):
m = re.match(r'(?:fnmon|cpu-monitor)-v(\d+\.\d+\.\d+)\.fpk', f)
if m:
v = tuple(int(x) for x in m.group(1).split('.'))
if v > tuple(int(x) for x in latest_ver.split('.')):
latest_ver = m.group(1)
v_parts = [int(x) for x in latest_ver.split('.')]
v_parts[2] += 1
NEW_VER = '.'.join(str(x) for x in v_parts)
OUTPATH = f'{OUTDIR}/fnmon-v{NEW_VER}.fpk'
now_ts = int(tmod.time())
def add_file(tar, path, content=None, filepath=None, mode=0o644):
"""Add a file to the tar archive."""
info = tarfile.TarInfo(name=f'{APP_NAME}/{path}')
if filepath:
with open(filepath, 'rb') as f:
data = f.read()
elif isinstance(content, bytes):
data = content
else:
data = content.encode('utf-8')
info.size = len(data)
info.mode = mode
info.mtime = now_ts
info.uid = info.gid = 0
info.uname = 'root'
info.gname = 'root'
tar.addfile(info, io.BytesIO(data))
# === Build manifest ===
manifest_content = f'''appname = {APP_NAME}
version = {NEW_VER}
display_name = fnmon - 系统监视器
desc = 实时系统监控面板 - CPU/内存/网络/磁盘/进程 全能监控
platform = all
source = thirdparty
maintainer = Hermes
maintainer_url = https://github.com/nousresearch/hermes-agent
distributor = Community
os_min_version = 0.8.1
desktop_uidir = ui
desktop_applaunchname = {APP_NAME}.Application
service_port = 5000
checkport = true
ctl_stop = true
'''
manifest_checksum = hashlib.md5(manifest_content.encode('utf-8')).hexdigest()
manifest_full = manifest_content + f'checksum = {manifest_checksum}\n'
# === Build tar ===
with tarfile.open(OUTPATH, 'w:gz') as tar:
add_file(tar, 'manifest', manifest_full)
# Config
add_file(tar, 'config/privilege', json.dumps({
'defaults': {'run-as': 'root'},
}, indent=2))
add_file(tar, 'config/resource', '{\n "systemd-unit": {}\n}\n')
# Lifecycle scripts
scripts = {
'install_init': '#!/bin/bash\nexit 0\n',
'install_callback': f'''#!/bin/bash
# Create and enable systemd service
cat > /etc/systemd/system/{APP_NAME}.service << 'SERVEOF'
[Unit]
Description=fnmon - 系统监视器
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/apps/{APP_NAME}
ExecStart=/usr/bin/python3 /var/apps/{APP_NAME}/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVEOF
systemctl daemon-reload
systemctl enable {APP_NAME}.service
systemctl start {APP_NAME}.service
exit 0
''',
'uninstall_init': f'#!/bin/bash\nsystemctl stop {APP_NAME}.service 2>/dev/null\nexit 0\n',
'uninstall_callback': f'''#!/bin/bash
systemctl disable {APP_NAME}.service 2>/dev/null
rm -f /etc/systemd/system/{APP_NAME}.service
systemctl daemon-reload
exit 0
''',
'upgrade_init': '#!/bin/bash\nexit 0\n',
'upgrade_callback': f'#!/bin/bash\nsystemctl restart {APP_NAME}.service 2>/dev/null\nexit 0\n',
'config_init': '#!/bin/bash\nexit 0\n',
'config_callback': '#!/bin/bash\nexit 0\n',
}
for name, content in scripts.items():
add_file(tar, f'cmd/{name}', content, mode=0o755)
# Main lifecycle
add_file(tar, 'cmd/main', f'''#!/bin/bash
case $1 in
start)
systemctl start {APP_NAME}.service 2>/dev/null
sleep 2
curl -s -o /dev/null http://localhost:5000 && exit 0
exit 1
;;
stop) exit 0 ;;
status)
curl -s -o /dev/null http://localhost:5000 && exit 0 || exit 3
;;
*) exit 1 ;;
esac
''', mode=0o755)
# === Bundle app code ===
add_file(tar, 'app.py', filepath=os.path.join(APP_DIR, 'app.py'), mode=0o755)
add_file(tar, 'traffic_tracker.py', filepath=os.path.join(APP_DIR, 'traffic_tracker.py'), mode=0o644)
add_file(tar, 'app_usage_tracker.py', filepath=os.path.join(APP_DIR, 'app_usage_tracker.py'), mode=0o644)
add_file(tar, 'static/index.html', filepath=os.path.join(APP_DIR, 'static/index.html'), mode=0o644)
# UI config
add_file(tar, 'ui/index.json', json.dumps({
APP_NAME: {
'application': {
'type': 'url',
'protocol': 'http',
'port': 5000,
'url': 'http://${host}:5000',
'title': 'fnmon - 系统监视器',
'description': '实时系统监控面板'
}
}
}, ensure_ascii=False, indent=2))
# Icons
for fname, fpk_name in [(f'{APP_NAME}-icon-128.png', 'ICON.PNG'),
(f'{APP_NAME}-icon-256.png', 'ICON_256.PNG')]:
ipath = os.path.join(OUTDIR, fname)
if os.path.exists(ipath):
add_file(tar, fpk_name, filepath=ipath)
else:
# Fallback: simple blue icon
add_file(tar, fpk_name, _gen_png(128 if '128' in fname else 256, (80, 120, 255)))
# === Done ===
size = os.path.getsize(OUTPATH)
print(f'✅ fpk created: {OUTPATH} ({size} bytes)')
print(f' version: v{NEW_VER} (previous: v{latest_ver})')
print(f' checksum: {manifest_checksum}')
print(f' bundled: app.py + frontend + trackers')
with tarfile.open(OUTPATH) as t:
for m in t.getmembers():
print(f' {m.name}: {m.size}B')
def _gen_png(size, color):
"""Quick PNG generator (fallback if no icon)."""
r, g, b = color
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
hdr = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0))
raw = b''
for y in range(size):
raw += b'\x00'
for x in range(size):
d = ((x-size/2)**2 + (y-size/2)**2) ** 0.5
rad = size/2 - 4
if d < rad:
t = d / rad
raw += struct.pack('BBB', int(r-(r-50)*t), int(g-(g-100)*t), int(b-(b-200)*t))
else:
raw += b'\x00\x00\x00'
return hdr + ihdr + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Generate a system-monitoring-themed icon for the fnmon fpk."""
from PIL import Image, ImageDraw, ImageFont
import math
SIZE = 256
OUT = '/vol2/1000/HD2/fpk/fnmon-icon.png'
draw_icon = ImageDraw.Draw(Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0)))
img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Colors
BG_DARK = (16, 22, 36)
ACCENT = (56, 189, 248)
GREEN = (74, 222, 128)
YELLOW = (251, 191, 36)
RED = (248, 113, 113)
WHITE = (230, 240, 255)
CARD = (30, 41, 59)
cx, cy = SIZE//2, SIZE//2
r = SIZE//2 - 16
# Rounded square background
draw.rounded_rectangle([4, 4, SIZE-5, SIZE-5], radius=28, fill=BG_DARK)
# -- Gauge arc --
arc_r = r - 10
for deg in range(60, 241):
rad = math.radians(deg)
x1 = cx + int((arc_r-2) * math.cos(rad))
y1 = cy + int((arc_r-2) * math.sin(rad))
x2 = cx + int(arc_r * math.cos(rad))
y2 = cy + int(arc_r * math.sin(rad))
# green 60-120, yellow 120-180, red 180-240
if deg < 120:
c = GREEN
elif deg < 180:
c = YELLOW
else:
c = RED
draw.line([(x1, y1), (x2, y2)], fill=c, width=4)
# Gauge needle at ~80°
needle_deg = 100
rad = math.radians(needle_deg)
nx = cx + int(arc_r * 0.7 * math.cos(rad))
ny = cy + int(arc_r * 0.7 * math.sin(rad))
draw.line([(cx, cy), (nx, ny)], fill=WHITE, width=3)
draw.ellipse([cx-5, cy-5, cx+5, cy+5], fill=ACCENT)
# -- Bar chart bars at bottom left --
bar_x, bar_y = cx - arc_r + 5, cy + 8
bar_w, bar_h = 8, 30
for i, (h, c) in enumerate([(24, GREEN), (16, YELLOW), (24, ACCENT)]):
bx = bar_x + i * (bar_w + 4)
draw.rectangle([bx, bar_y + bar_h - h, bx + bar_w, bar_y + bar_h], fill=c, width=0)
# -- Small CPU chip top-left --
chip_x, chip_y = cx - arc_r + 5, cy - arc_r + 5
draw.rounded_rectangle([chip_x, chip_y, chip_x+24, chip_y+24], radius=3, fill=ACCENT)
draw.rectangle([chip_x+4, chip_y+4, chip_x+20, chip_y+20], fill=(20, 40, 60))
# Dot
draw.rectangle([chip_x+10, chip_y+9, chip_x+14, chip_y+15], fill=(30, 80, 120))
# -- Signal dots (network) top right --
sig_x, sig_y = cx + arc_r - 20, cy - arc_r + 5
for i in range(3):
sh = 6 + i * 5
draw.rectangle([sig_x + i * 5, sig_y + 16 - sh, sig_x + i * 5 + 3, sig_y + 16], fill=ACCENT if i < 2 else (100, 150, 200))
# Resize for 128
img_128 = img.resize((128, 128), Image.LANCZOS if hasattr(Image, 'LANCZOS') else Image.ANTIALIAS)
img.save(OUT)
img_128.save('/vol2/1000/HD2/fpk/fnmon-icon-128.png')
print(f'✅ 256x256 saved: {OUT}')
print(f'✅ 128x128 saved: /vol2/1000/HD2/fpk/fnmon-icon-128.png')
# Also save 256 copy
img.save('/vol2/1000/HD2/fpk/fnmon-icon-256.png')
print(f'✅ 256x256 saved: /vol2/1000/HD2/fpk/fnmon-icon-256.png')
+1
View File
@@ -0,0 +1 @@
{"arrays":[{"devices":["sdc3"],"level":"raid1","lvm":"trim_43d5e563_4991_48af_89f2_514933c40b28-0","md":"md0","members":"1/1","mount":"/vol1","note":"单成员 RAID1,无冗余"},{"devices":["sda1"],"level":"raid1","lvm":"trim_3f9de466_c96d_406a_a13b_9a3d4bab6b42","md":"md1","members":"1/1","mount":"/vol3","note":"单成员 RAID1,无冗余"},{"devices":["sdb1"],"level":"raid1","lvm":"trim_a03ba591_eb54_4c55_a112_00d2903bd71c-0","md":"md3","members":"1/1","mount":"/vol2","note":"单成员 RAID1,无冗余"}],"disks":[{"cap_b":1024209543168,"form":"2.5英寸","health":"PASSED","hours":18878,"iface":"SATA 6Gb/s","model":"QUANXING S101 1TB","name":"sda","power_cycles":452,"realloc":0,"serial":"AD20221216A0101609","temp_c":40,"type":"SSD","wear":0},{"cap_b":12000138625024,"form":"3.5英寸","health":"PASSED","helium":100,"hours":4396,"iface":"SATA 6Gb/s","model":"WDC WD120EDGZ-11B1PA1","model_family":"Western Digital Ultrastar (He10/12)","name":"sdb","power_cycles":237,"realloc":0,"rpm":7200,"serial":"9MJB45YT","temp_c":37,"type":"HDD 氦气盘"},{"cap_b":512110190592,"form":"2.5英寸","health":"PASSED","hours":16661,"iface":"SATA 6Gb/s","model":"QUANXING S101 512G","name":"sdc","power_cycles":735,"realloc":0,"serial":"AC20221216A0100212","temp_c":40,"type":"SSD","wear":0}],"extra":{"mount":"/","note":"系统盘直挂分区,无 RAID/LVM","part":"sdc2"},"note":"SMART 为构建时快照,温度/通电时间随时间漂移;容量/型号/序列号为静态","snapshot":"2026-11-06"}
+42
View File
@@ -0,0 +1,42 @@
{
"dimms": [
{
"locator": "DIMM 0",
"bank": "P0 CHANNEL A",
"size_b": 8589934592,
"size_mb": 8192,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "A-DATA Technology",
"serial": "3E8A0000",
"part": ""
},
{
"locator": "DIMM 0",
"bank": "P0 CHANNEL B",
"size_b": 17179869184,
"size_mb": 16384,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "Unknown",
"serial": "22522642",
"part": "QXD016GD4U266600"
},
{
"locator": "DIMM 1",
"bank": "P0 CHANNEL B",
"size_b": 17179869184,
"size_mb": 16384,
"type": "DDR4",
"speed": 0,
"cspeed": 2400,
"maker": "Unknown",
"serial": "62805591",
"part": "EAGET-16GB-3200"
}
],
"installed": 42949672960,
"max_cap_b": 137438953472
}
+1
View File
@@ -0,0 +1 @@
{"dns":"192.168.68.1","gateway":"192.168.68.1","nics":[{"driver":"r8169","model":"Realtek RTL8125 2.5GbE","name":"enp42s0","pci":"10EC:8125","role":"有线主网口(已接入 OVS,IP 挂在 enp42s0-ovs 上)"},{"driver":"mt7921e","model":"MEDIATEK MT7921K Wi-Fi 6E","name":"wlp41s0","pci":"14C3:0608","role":"无线备用(DHCP,metric 600)","ssid":"css_5G"},{"model":"EasyTier VPN 虚拟网卡","name":"tun0","role":"组网隧道"},{"model":"OVS 内部端口(与 enp42s0 同 MAC)","name":"enp42s0-ovs","role":"宿主实际出口 IP 承载口"},{"model":"Open vSwitch 数据通道","name":"ovs-system","role":"OVS datapath(DOWN 属正常)"}],"note":"型号/驱动/网关/DNS 为构建时快照;地址/状态/速率/流量为实时读取","snapshot":"2026-11-06"}
-7
View File
@@ -1,7 +0,0 @@
appname: fnos-resmon
version: 1.2.2
display_name: 飞牛资源管理器
desc: 飞牛 NAS 资源监视器(CPU/内存/磁盘/网络四页签,读取宿主真实数据)
arch: x86_64
source: docker
maintainer: jinghui
+102
View File
@@ -0,0 +1,102 @@
<h1>飞牛NAS必装:进程级资源监控,比自带强10倍</h1>
<blockquote>
<p>飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。</p>
</blockquote>
<p>你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?CPU 烧到多少度?风扇转不转?当前配置到底顶不顶得住?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。</p>
<p><strong>飞牛资源管理器(fnos-resmon)</strong> 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,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>CPU 温度/风扇转速</td>
<td>❌ 无</td>
<td>✅ 11 路温度 + 风扇 RPM,超温变色预警</td>
</tr>
<tr>
<td>负载诊断:配置顶不顶得住</td>
<td>❌ 无</td>
<td>✅ 单核负载公式 + 四档结论 + 流畅建议</td>
</tr>
<tr>
<td>一键优化系统</td>
<td>❌ 无</td>
<td>✅ 🚀 落盘 + 回收缓存 + 内存整理</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>
<tr>
<td>刷新间隔设置/停止刷新</td>
<td>❌ 无</td>
<td>✅ 3s/5s/10s/30s/60s + ⏸ 随时暂停</td>
</tr>
</tbody>
</table>
<h2>CPU 面板:温度风扇 + 负载诊断 + 一键优化</h2>
<p>CPU 型号、架构、物理/逻辑核数、每核频率占用一目了然,进程级 CPU 排行实时刷新,点表头即排序。</p>
<p>本版新增三样硬货:🌡️ 温度 / 🌀 风扇面板(CPU 63°C 红色预警、主板/硬盘/网卡全路温度、风扇1 1200 转上下实时 RPM,读不到的机器显示友好提示不报错);📊 负载诊断(1分钟负载 ÷ 逻辑核数 = 单核负载,直接告诉你“顶得住还是顶不住”,并点名头号吃 CPU 进程);🚀 一键优化系统(数据落盘 + 回收内存缓存 + 内存碎片整理,实测一次释放 500MB+)。</p>
<p><img alt="CPU面板" src="https://img.88531.cn/i/2026/11/07/6aeeca97f31e3.png" /></p>
<h2>内存面板:一键回收释放空间</h2>
<p>每个进程 RSS/USS/SWAP 三列细分,物理内存槽位(容量/类型/频率/厂商)全展示。右上角 🧹 绿色大按钮一键回收未使用的内存,异常进程一键结束,Swap 用量一目了然。</p>
<p><img alt="内存面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9a62947.png" /></p>
<h2>磁盘面板:硬盘健康提前预警</h2>
<p>SMART 健康、温度、通电时间、坏道重映射 Web 直观展示,阵列拓扑、挂载点用量、进程 IO 排行全有,硬盘故障提前发现。</p>
<p><img alt="磁盘面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9d2b3a2.png" /></p>
<h2>网络面板:谁在联网一目了然</h2>
<p>实时上下行速率、累计收发、连接数统计,所有 TCP 连接和侦听端口明细(进程+地址+状态),网卡驱动/速率/双工/MTU 全展示,BT 占带宽一眼揪出。</p>
<p><img alt="网络面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9f70e61.png" /></p>
<h2>一条命令安装</h2>
<pre><code class="language-bash">docker run -d --name fnos-resmon -p 7031:7031 --privileged \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
</code></pre>
<p>浏览器打开 <code>http://NAS-IP:7031</code> 即可使用,手机也能看。</p>
<ul>
<li>开源地址:https://gitea.88531.cn/jinghui/fnos-resmon</li>
<li>右上角可调刷新间隔(3s/5s/10s/30s/60s),随时暂停刷新</li>
<li>零侵入,Docker 运行不修改系统文件</li>
</ul>
+64
View File
@@ -0,0 +1,64 @@
# 飞牛NAS必装:进程级资源监控,比自带强10倍
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?CPU 烧到多少度?风扇转不转?当前配置到底顶不顶得住?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。
**飞牛资源管理器(fnos-resmon)** 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,CPU 温度风扇、负载诊断、一键优化全都有,一条 docker 命令启动。
## 一句话说清优势
| 能力 | 飞牛自带 | fnos-resmon |
|------|---------|-------------|
| CPU/内存占用率 | ✅ 有 | ✅ 有 |
| 进程级 CPU/内存排行 | ❌ 无 | ✅ 进程名/PID/用户/状态/线程数 |
| CPU 温度/风扇转速 | ❌ 无 | ✅ 11 路温度 + 风扇 RPM,超温变色预警 |
| 负载诊断:配置顶不顶得住 | ❌ 无 | ✅ 单核负载公式 + 四档结论 + 流畅建议 |
| 一键优化系统 | ❌ 无 | ✅ 🚀 落盘 + 回收缓存 + 内存整理 |
| 内存 RSS/USS/SWAP 明细 | ❌ 无 | ✅ 三列细分 |
| 一键内存回收 | ❌ 无 | ✅ 🧹 绿色大按钮,一键释放 |
| 一键结束异常进程 | ❌ 无 | ✅ 一键 kill |
| 磁盘 SMART/温度/通电时间 | ❌ 需命令行 | ✅ Web 直观展示 |
| TCP 连接明细/侦听端口 | ❌ 无 | ✅ 进程+地址+状态 |
| 网卡驱动/MTU/双工 | ❌ 无 | ✅ 有 |
| 刷新间隔设置/停止刷新 | ❌ 无 | ✅ 3s/5s/10s/30s/60s + ⏸ 随时暂停 |
## CPU 面板:温度风扇 + 负载诊断 + 一键优化
CPU 型号、架构、物理/逻辑核数、每核频率占用一目了然,进程级 CPU 排行实时刷新,点表头即排序。
本版新增三样硬货:🌡️ 温度 / 🌀 风扇面板(CPU 63°C 红色预警、主板/硬盘/网卡全路温度、风扇1 1200 转上下实时 RPM,读不到的机器显示友好提示不报错);📊 负载诊断(1分钟负载 ÷ 逻辑核数 = 单核负载,直接告诉你"顶得住还是顶不住",并点名头号吃 CPU 进程);🚀 一键优化系统(数据落盘 + 回收内存缓存 + 内存碎片整理,实测一次释放 500MB+)。
![CPU面板](https://img.88531.cn/i/2026/11/07/6aeeca97f31e3.png)
## 内存面板:一键回收释放空间
每个进程 RSS/USS/SWAP 三列细分,物理内存槽位(容量/类型/频率/厂商)全展示。右上角 🧹 绿色大按钮一键回收未使用的内存,异常进程一键结束,Swap 用量一目了然。
![内存面板](https://img.88531.cn/i/2026/11/07/6aeeca9a62947.png)
## 磁盘面板:硬盘健康提前预警
SMART 健康、温度、通电时间、坏道重映射 Web 直观展示,阵列拓扑、挂载点用量、进程 IO 排行全有,硬盘故障提前发现。
![磁盘面板](https://img.88531.cn/i/2026/11/07/6aeeca9d2b3a2.png)
## 网络面板:谁在联网一目了然
实时上下行速率、累计收发、连接数统计,所有 TCP 连接和侦听端口明细(进程+地址+状态),网卡驱动/速率/双工/MTU 全展示,BT 占带宽一眼揪出。
![网络面板](https://img.88531.cn/i/2026/11/07/6aeeca9f70e61.png)
## 一条命令安装
```bash
docker run -d --name fnos-resmon -p 7031:7031 --privileged \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
```
浏览器打开 `http://NAS-IP:7031` 即可使用,手机也能看。
- 开源地址:https://gitea.88531.cn/jinghui/fnos-resmon
- 右上角可调刷新间隔(3s/5s/10s/30s/60s),随时暂停刷新
- 零侵入,Docker 运行不修改系统文件
+136
View File
@@ -0,0 +1,136 @@
<h2>前言:飞牛自带的监控,为什么不够用</h2>
<p>飞牛 NAS 自带的资源监控只有一个百分比数字:CPU 多少、内存多少,没了。但实际运维中真正要回答的问题是:<strong>是哪个进程在吃 CPU?内存被谁占了?CPU 烧到多少度?风扇转不转?这台机器的配置到底顶不顶得住?哪块盘快坏了?哪个端口被谁占着?</strong></p>
<p>于是我写了 <strong>fnos-resmon(飞牛资源管理器)</strong>:一个单文件 Flask + psutil 的轻量监控面板,专为飞牛 NAS 设计,一条 docker 命令启动,浏览器打开 <code>http://NAS-IP:7031</code> 就能看,手机也能看。</p>
<p>开源地址:https://gitea.88531.cn/jinghui/fnos-resmon</p>
<h2>一、它能做什么</h2>
<ul>
<li><strong>CPU 面板</strong>:型号 / 架构 / 物理核 / 逻辑核 / 每核频率占用,进程级 CPU 排行(进程名 / PID / 用户 / 状态 / 线程数),点表头即排序</li>
<li><strong>🌡️ 温度 / 🌀 风扇面板</strong>:11 路温度 + 风扇实时 RPM,超温变色预警,读不到的机器显示友好提示不报错</li>
<li><strong>📊 负载诊断</strong>:1 分钟负载 ÷ 逻辑核数 = 单核负载,直接告诉你"顶得住还是顶不住",并点名头号吃 CPU 进程</li>
<li><strong>🚀 一键优化系统</strong>:数据落盘 + 回收内存缓存 + 内存碎片整理,实测一次释放 500MB+</li>
<li><strong>内存面板</strong>:每个进程 RSS / USS / SWAP 三列细分,物理内存槽位(容量 / 类型 / 频率 / 厂商)全展示,🧹 绿色大按钮一键回收内存</li>
<li><strong>磁盘面板</strong>:SMART 健康 / 温度 / 通电时间 / 坏道重映射,阵列拓扑、挂载点用量、进程 IO 排行</li>
<li><strong>网络面板</strong>:实时上下行速率、累计收发、连接数,所有 TCP 连接和侦听端口明细(进程 + 地址 + 状态),网卡驱动 / 速率 / 双工 / MTU</li>
<li><strong>一键 kill 异常进程</strong>,右上角可调刷新间隔(3s / 5s / 10s / 30s / 60s)+ ⏸ 随时暂停</li>
</ul>
<h2>二、实现原理:容器里怎么读到宿主真实数据</h2>
<p>监控跑在 Docker 里,默认只能看到容器自己的 namespace。要读宿主真实数据,靠三招:</p>
<pre><code class="language-yaml">services:
fnos-resmon:
image: jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
network_mode: host
pid: host # 与宿主共享 PID 命名空间
privileged: true
volumes:
- /proc:/host/proc:rw # 宿主 proc
- /sys:/host/sys:ro # 宿主 sysfs(温度/风扇/网卡从这读)</code></pre>
<p><strong>1. CPU / 内存 / 进程:psutil 重定向 PROCFS。</strong>启动时把 <code>psutil.PROCFS_PATH</code> 指向 <code>/host/proc</code>,之后所有 <code>psutil.process_iter / cpu_percent / virtual_memory</code> 读到的都是宿主数据:</p>
<pre><code class="language-python">PROCFS = os.environ.get("PROCFS_PATH", "/host/proc")
if os.path.isdir(PROCFS):
psutil.PROCFS_PATH = PROCFS</code></pre>
<p><strong>2. 网络:nsenter 进宿主 netns。</strong>容器即使 bridged 模式,直读 <code>/proc/net</code> 也只能看到容器自身。网络类数据统一经 <code>nsenter --target 1 --mount --net</code> 在宿主(PID 1)netns 里执行 <code>cat /proc/net/dev</code> 和 <code>ss -tunHn state all</code>,失败才回退到 psutil 容器视角:</p>
<pre><code class="language-python">NSENTER = ["nsenter", "--target", "1", "--mount", "--net"]
def _host_ss():
"""宿主 tcp/udp 连接+监听,经 nsenter 在宿主 netns 执行 ss"""
out = _nsenter_run(["ss", "-tunHn", "state", "all"])</code></pre>
<p>ss 行尾的 <code>("进程名",pid=1234,...)</code> 会被正则抠出来,再用进程表按 PID 补齐进程名,前 80 条连接 + 前 80 条监听返回前端。</p>
<p><strong>3. 后台采样线程 + 2 秒缓存。</strong>进程 CPU 统计需要两次采样算差值,所以 <code>sample_procs()</code> 先全员 <code>cpu_percent(None)</code> 打点,sleep 1 秒后再读数,按 CPU 倒序,前端每 2 秒拿到的是缓存,不会每次请求都卡 1 秒:</p>
<pre><code class="language-python">def sampler_loop():
while True:
try:
sample_procs()
except Exception:
pass
time.sleep(2)
threading.Thread(target=sampler_loop, daemon=True).start()</code></pre>
<p>每个进程采集的字段:cpu / mem / rss / uss / swap / 线程数 / 用户 / 状态 / IO 读写 / 连接数。容器内 <code>/etc/passwd</code> 没有宿主用户,纯数字 uid 会经 <code>/host/etc/passwd</code> 还原用户名(5 分钟缓存)。</p>
<h2>三、两个最实用的接口:负载诊断与一键优化</h2>
<p><strong>GET /api/cpu/advise —— 配置顶不顶得住,一句话结论。</strong>公式很简单:<code>单核负载 = 1分钟load ÷ 逻辑核数</code>,分四档给结论,并点名最吃 CPU 的进程、给出扩容 / 杀进程 / 属正常的建议:</p>
<pre><code class="language-python">@app.route("/api/cpu/advise")
def api_cpu_advise():
load = list(os.getloadavg()) # 1/5/15 分钟
per_core = load[0] / logical # 单核负载
# 四档:&lt;0.7 轻松 / &lt;1.0 饱和 / &lt;2.0 吃力 / 更高 过载</code></pre>
<p><strong>POST /api/sys/optimize —— 一键优化。</strong>依次做三件事:<code>sync</code> 数据落盘 → 写 <code>/host/proc/sys/vm/drop_caches</code> 回收 pagecache → 写 <code>/host/proc/sys/vm/compact_memory</code> 整理碎片。内存面板另有 <strong>POST /api/mem/drop</strong> 单独回收。零侵入:只调内核已有接口,不改系统文件。</p>
<p><strong>GET /api/sensors —— 温度风扇。</strong>优先读 <code>/host/sys/class/hwmon</code>,回退 <code>/sys/class/hwmon</code>,<code>temp*_input ÷ 1000</code> 得摄氏度,<code>fan*_input</code> 即 RPM,label 优先用 <code>temp*_label</code>。超温阈值前端变色预警,风扇节点不存在(如纯 NAS 小主机无风扇上报)的机器返回空 + 友好提示,不阻塞主流程。</p>
<h2>四、API 一览</h2>
<table>
<thead>
<tr>
<th>方法</th>
<th>路径</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/cpu</code></td>
<td>CPU 信息 + 每核占用 + 进程排行 + load</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/mem</code></td>
<td>内存占用 + RSS/USS/SWAP 进程明细 + 槽位硬件</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/disk</code></td>
<td>挂载用量 + SMART/温度/通电时间 + 阵列 + IO 排行</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/net</code></td>
<td>网卡速率 + TCP/监听明细 + 硬件(驱动/MTU/双工)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/sensors</code></td>
<td>11 路温度 + 风扇 RPM</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/cpu/advise</code></td>
<td>负载诊断:四档结论 + 头号进程 + 建议</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/sys/optimize</code></td>
<td>一键优化:落盘 + 回收缓存 + 内存整理</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/mem/drop</code></td>
<td>一键内存回收</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/kill</code></td>
<td>结束异常进程(限宿主 PID)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/health</code></td>
<td>健康检查</td>
</tr>
</tbody>
</table>
<h2>五、部署:一条命令</h2>
<pre><code class="language-bash">docker run -d --name fnos-resmon -p 7031:7031 --privileged \
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest</code></pre>
<p>浏览器打开 <code>http://NAS-IP:7031</code> 即可。前端是单个静态页(改前端必须重建镜像),后端一个 <code>app.py</code> 约 1100 行,无数据库,开箱即用。</p>
<h2>六、实测截图(v1.3.4 当前版本)</h2>
<p>CPU 面板:63°C 红色预警、风扇 1200 转 RPM、负载诊断四档结论、一键优化按钮:</p>
<p><img alt="CPU面板" src="https://img.88531.cn/i/2026/11/07/6aeeca97f31e3.png" /></p>
<p>内存面板:RSS/USS/SWAP 三列细分 + 🧹 一键回收大按钮:</p>
<p><img alt="内存面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9a62947.png" /></p>
<p>磁盘面板:3 盘 SMART 全 PASSED + 温度通电时间:</p>
<p><img alt="磁盘面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9d2b3a2.png" /></p>
<p>网络面板:qbittorrent 482 连接一眼揪出吃带宽进程:</p>
<p><img alt="网络面板" src="https://img.88531.cn/i/2026/11/07/6aeeca9f70e61.png" /></p>
<h2>七、总结</h2>
<p>飞牛自带监控解决"有没有",fnos-resmon 解决"是谁、多少度、顶不顶得住、能不能一键收拾"。核心就三招:<strong>psutil 重定向宿主 proc、nsenter 进宿主 netns 读网络、后台线程 2 秒缓存进程快照</strong>,剩下的都是把数据摆整齐。如果你也在飞牛上跑了很多 Docker 服务,装一个看看头号吃 CPU 的到底是谁 —— 八成有惊喜。</p>
<p>镜像:<code>jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest</code> | 开源:https://gitea.88531.cn/jinghui/fnos-resmon | 端口 7031,欢迎提 issue。</p>
+83 -14
View File
@@ -20,7 +20,7 @@ body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;backgr
.stat .v{font-size:15px}
.v.green{color:#20a53a}
table{width:calc(100% - 32px);margin:0 16px 20px;border-collapse:collapse;font-size:13px;table-layout:fixed}
thead th{background:#f7f7f7;color:#555;font-weight:400;text-align:left;padding:10px 8px;white-space:nowrap;overflow:hidden}
thead th{background:#f7f7f7;color:#555;font-weight:400;text-align:left;padding:10px 8px;white-space:nowrap;overflow:hidden}thead th[onclick]{cursor:pointer;user-select:none}thead th[onclick]:hover{color:#20a53a}
tbody td{padding:9px 8px;border-bottom:1px solid #f2f2f2;vertical-align:top;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
tbody td.desc{max-width:340px}
a.proc{color:#20a53a;text-decoration:none;white-space:nowrap}
@@ -28,6 +28,9 @@ col.c-name{width:150px}col.c-num{width:90px}col.c-pid{width:80px}col.c-user{widt
.st-sleep{color:#ff9900}.st-run{color:#20a53a}
.kill{color:#20a53a;cursor:pointer;background:none;border:none;font-size:13px}
.kill:hover{text-decoration:underline}
.bigbtn{background:linear-gradient(180deg,#1f9d55,#157347);border:none;color:#fff;font-size:18px;font-weight:700;border-radius:10px;padding:14px 28px;cursor:pointer;box-shadow:0 4px 14px rgba(31,157,85,.4);display:inline-block;margin:6px 0}
.bigbtn:hover{filter:brightness(1.1)}
.bigbtn:active{transform:scale(.97)}
.num{font-variant-numeric:tabular-nums}
.bar{height:8px;background:#f0f0f0;border-radius:4px;overflow:hidden;margin-top:6px}
.bar>i{display:block;height:100%;background:#20a53a;border-radius:4px}
@@ -44,6 +47,11 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
.corecell .cb{height:8px;background:#f0f0f0;border-radius:4px;overflow:hidden}
.corecell .cb>i{display:block;height:100%;background:#20a53a;border-radius:4px}
.corecell .ct{font-size:11px;color:#999;margin-top:2px;font-variant-numeric:tabular-nums}
.perfbtn{border:1px solid #ddd;background:#fff;border-radius:6px;padding:8px 18px;font-size:14px;cursor:pointer;margin-right:8px}
.perfbtn.cur{background:#20a53a;border-color:#20a53a;color:#fff;font-weight:700}
.benchbtn{border:1px solid #20a53a;background:#20a53a;color:#fff;border-radius:6px;padding:8px 18px;font-size:14px;cursor:pointer}
.benchbtn:disabled{background:#ccc;border-color:#ccc;cursor:wait}
.benchhist{font-size:12px;color:#666;line-height:1.8;margin-top:4px}
</style>
</head>
<body>
@@ -52,28 +60,62 @@ button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;paddi
<div class="tab" data-t="mem">内存</div>
<div class="tab" data-t="disk">磁盘</div>
<div class="tab" data-t="net">网络</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:12px;padding-right:16px;font-size:12px">
<span id="licBell" onclick="toggleAnn()" title="公告" style="cursor:pointer;position:relative;font-size:15px">🔔<i id="licDot" style="display:none;position:absolute;top:-2px;right:-4px;width:8px;height:8px;border-radius:50%;background:#f00"></i></span>
<span id="licVer" onclick="checkVer(true)" title="点击检查新版本" style="color:#999;cursor:pointer">v1.3.16</span>
<label style="color:#999">刷新 <select id="ival" onchange="setIval(this.value)" style="border:1px solid #ddd;border-radius:3px;padding:2px 4px;color:#333"><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="60">60s</option></select></label>
<button id="pauseBtn" onclick="togglePause()" style="background:#ff9900;color:#fff;border:none;border-radius:3px;padding:3px 10px;cursor:pointer">⏸ 停止刷新</button>
<a href="https://nav.88531.cn" target="_blank" style="color:#20a53a;text-decoration:none">资享导航站</a>
<a href="https://gitea.88531.cn/jinghui/fnos-resmon" target="_blank" style="color:#20a53a;text-decoration:none">开源地址</a>
</div>
</div>
<div class="card" id="summary"></div>
<div class="cpuinfo" id="cpuinfo" style="display:none"></div>
<div class="cpuinfo" id="cpuperf" style="display:none"></div>
<div class="cpuinfo" id="cpubench" style="display:none"></div>
<div class="cpuinfo" id="cpusensor" style="display:none"></div>
<div class="cpuinfo" id="cpuadvise" style="display:none"></div>
<div class="cpuinfo" id="meminfo" style="display:none"></div>
<div class="cpuinfo" id="membench" style="display:none"></div>
<div class="cpuinfo" id="diskinfo" style="display:none"></div>
<div class="cpuinfo" id="diskbench" style="display:none"></div>
<div class="cpuinfo" id="netinfo" style="display:none"></div>
<div class="cpuinfo" id="netbench" style="display:none"></div>
<div class="toolbar"><span id="sub"></span><span><span id="ts" style="margin-right:10px"></span><button class="refresh" onclick="load(true)">刷新</button></span></div>
<div class="scrollarea"><table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
<div id="disks"></div><div id="netextra"></div></div>
<div id="annPanel" style="display:none;position:fixed;top:46px;right:12px;width:340px;max-height:60vh;overflow:auto;background:#fff;border:1px solid #ddd;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.15);z-index:1000;padding:12px 14px;font-size:13px;line-height:1.8"></div>
<div id="annPop" style="display:none;position:fixed;right:12px;bottom:40px;width:340px;background:#fffbe6;border:1px solid #f0c36d;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.15);z-index:1000;padding:12px 14px;font-size:13px;line-height:1.8"></div>
<script>
let LIC={announcements:[],popup:[]};
let cur='cpu';
let sortState={key:null,dir:-1};
function loadSort(){try{const v=JSON.parse(localStorage.getItem('fnos_sort_'+cur)||'null');if(v&&v.key){sortState=v;return}}catch(e){}sortState={key:null,dir:-1};}
function saveSort(){try{localStorage.setItem('fnos_sort_'+cur,JSON.stringify(sortState))}catch(e){}}
loadSort();
let curData=null;
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));t.classList.add('active');cur=t.dataset.t;document.getElementById('disks').innerHTML='';const ci=document.getElementById('cpuinfo');if(ci)ci.style.display='none';const mi2=document.getElementById('meminfo');if(mi2)mi2.style.display='none';const di=document.getElementById('diskinfo');if(di)di.style.display='none';const ni=document.getElementById('netinfo');if(ni)ni.style.display='none';load(true)});
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));t.classList.add('active');cur=t.dataset.t;loadSort();document.getElementById('disks').innerHTML='';const ci=document.getElementById('cpuinfo');if(ci)ci.style.display='none';const cs=document.getElementById('cpusensor');if(cs)cs.style.display='none';const ca=document.getElementById('cpuadvise');if(ca)ca.style.display='none';const cp=document.getElementById('cpuperf');if(cp)cp.style.display='none';const mi2=document.getElementById('meminfo');if(mi2)mi2.style.display='none';const di=document.getElementById('diskinfo');if(di)di.style.display='none';const ni=document.getElementById('netinfo');if(ni)ni.style.display='none';['cpubench','membench','diskbench','netbench'].forEach(id=>{const e=document.getElementById(id);if(e)e.style.display='none'});load(true)});
function fmtB(b){if(b==null)return '-';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<4){b/=1024;i++}return b.toFixed(b>=100?0:1)+u[i]}
function fmtR(b){return fmtB(b)+'/s'}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;')}
async function dropMem(){if(!confirm('回收可回收内存(drop caches)?'))return;const r=await fetch('/api/mem/drop',{method:'POST'});const j=await r.json();alert(j.ok?('已回收,可用 '+fmtB(j.available)):('失败: '+j.error));load(true)}
async function dropMem(){if(!confirm('一键回收未使用的内存(drop caches)?'))return;const r=await fetch('/api/mem/drop',{method:'POST'});const j=await r.json();alert(j.ok?('已回收,可用 '+fmtB(j.available)):('失败: '+j.error));load(true)}
let optPoll=null;
let optState={running:false,text:''};
function restoreOptUI(){const b=document.getElementById('optBtn');const st=document.getElementById('optStopBtn');const s=document.getElementById('optStatus');if(!b||!st||!s)return;if(optState.running){b.disabled=true;b.style.cssText='background:#ccc;color:#fff;border:none;border-radius:3px;padding:6px 14px;cursor:not-allowed';st.style.display='';}else{st.style.display='none';}if(optState.text)s.textContent=optState.text;}
async function optimizeSys(){const b=document.getElementById('optBtn');const st=document.getElementById('optStopBtn');if(!b||b.disabled)return;try{const r=await fetch('/api/sys/optimize',{method:'POST'});const j=await r.json();if(r.status===409&&j.running){return}if(!j.started&&!j.ok){const s=document.getElementById('optStatus');if(s)s.textContent='启动失败,请稍后重试';return}b.disabled=true;b.style.cssText='background:#ccc;color:#fff;border:none;border-radius:3px;padding:6px 14px;cursor:not-allowed';const s=document.getElementById('optStatus');optState={running:true,text:'⏳ 正在优化系统,预计约20秒,页面数据照常刷新…'};if(s)s.textContent=optState.text;if(st)st.style.display='';if(optPoll)clearInterval(optPoll);optPoll=setInterval(pollOptStatus,2000)}catch(e){const s=document.getElementById('optStatus');if(s)s.textContent='启动失败:网络异常'}}
async function stopOpt(){let j=null;try{const r=await fetch('/api/sys/optimize/cancel',{method:'POST'});j=await r.json()}catch(e){}if(!j||!j.ok)return;const st=document.getElementById('optStopBtn');if(st)st.disabled=true;optState={running:true,stopping:true,text:'⏳ 正在停止优化…'};const s=document.getElementById('optStatus');if(s)s.textContent=optState.text}
async function pollOptStatus(){try{const r=await fetch('/api/sys/optimize/status');const j=await r.json();const s=document.getElementById('optStatus');if(j.running){optState={running:true,stopping:!!optState.stopping,text:optState.stopping?'⏳ 正在停止优化…(已用时 '+j.elapsed+' 秒)':'⏳ 正在优化系统,已用时 '+j.elapsed+' 秒,预计约20秒,页面数据照常刷新…'};if(s)s.textContent=optState.text;return}if(optPoll){clearInterval(optPoll);optPoll=null}const b=document.getElementById('optBtn');const st=document.getElementById('optStopBtn');if(b){b.disabled=false;b.style.cssText='';b.className='bigbtn'}if(st){st.style.display='none';st.disabled=false}if(j.cancelled){optState={running:false,text:'⏹ 已停止优化:'+j.steps.join(';')}}else if(j.done){optState={running:false,text:'✅ 优化完成:'+j.steps.join(';')+',释放 '+fmtB(j.freed)+',当前可用 '+fmtB(j.available)}}if(s&&optState.text)s.textContent=optState.text;load(true);setTimeout(restoreOptUI,500)}catch(e){}}
let lastAdvise=0;
async function cpuSensor(){const el=document.getElementById('cpusensor');try{const r=await fetch('/api/sensors');const j=await r.json();el.style.display='';let h='<div class="model">🌡️ 温度 / 🌀 风扇</div>';if(j.temps&&j.temps.length){const cpu=j.temps.filter(t=>t.name.indexOf('CPU')===0);const oth=j.temps.filter(t=>t.name.indexOf('CPU')!==0);const chip=t=>{const c=t.value>=85?'#f00':(t.value>=70?'#ff9900':'#20a53a');return `<span style="display:inline-block;margin:2px 10px 2px 0">${esc(t.name)} <b style="color:${c}">${t.value}°C</b></span>`};if(cpu.length)h+='<div>'+cpu.map(chip).join('')+'</div>';if(oth.length)h+='<div class="mut">'+oth.map(chip).join('')+'</div>'}if(j.fans&&j.fans.length){h+='<div>'+j.fans.map(f=>`<span style="display:inline-block;margin:2px 10px 2px 0">🌀 ${esc(f.name)} <b>${f.value} RPM</b></span>`).join('')+'</div>'}else{h+='<div class="mut">🌀 风扇转速:宿主未暴露(家用 NAS/主板多无此传感器,属正常)</div>'}if(j.note&&!j.temps.length)h+=`<div class="mut">${esc(j.note)}</div>`;el.innerHTML=h}catch(e){el.style.display='';el.innerHTML='<div class="mut">🌡️ 温度读取失败</div>'}}
async function cpuAdvise(){const el=document.getElementById('cpuadvise');const now=Date.now();if(now-lastAdvise<15000){if(el.innerHTML)el.style.display='';return;}lastAdvise=now;try{const r=await fetch('/api/cpu/advise');const j=await r.json();const col=j.level==='轻松'||j.level==='正常'?'#20a53a':(j.level==='偏高'?'#ff9900':'#f00');document.getElementById('cpuadvise').style.display='';document.getElementById('cpuadvise').innerHTML=`<div class="model">📊 负载诊断:<b style="color:${col}">${j.level}</b> <span class="mut">(1分钟负载 ${j.load[0]} ÷ ${j.logical}核 = 单核 ${j.per_core})</span></div><div style="margin:4px 0">${esc(j.verdict)}</div><div class="mut">如何更流畅:</div><div>${j.tips.map(t=>`<div>· ${esc(t)}</div>`).join('')}</div><div style="margin-top:6px"><button class="bigbtn" id="optBtn" onclick="optimizeSys()">🚀 一键优化系统</button> <button id="optStopBtn" onclick="stopOpt()" style="display:none;background:#f00;color:#fff;border:none;border-radius:3px;padding:6px 14px;cursor:pointer">⏹ 停止</button><div class="mut" id="optDesc" style="margin-top:4px">会做两件事:① 把内存里暂存的数据写回硬盘(sync,防断电丢数据);② 回收系统缓存占用的内存(drop caches,只清缓存、不动正在运行的程序)。全程不重启、不杀进程,页面照常刷新。</div><div class="mut" id="optStatus"></div></div>`;restoreOptUI()}catch(e){}}
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)}
const PERF_DESC={powersave:'省电模式:限制睿频、能效优先,适合7×24待机/下载机,功耗噪音最低',balanced:'平衡模式:按负载自动升降频,日常使用推荐,性能功耗兼顾',performance:'高性能模式:火力全开,适合转码/编译/虚拟机等重负载,功耗温度最高'};
let perfCur='';
async function cpuPerf(){const el=document.getElementById('cpuperf');try{const r=await fetch('/api/cpu/perf');const j=await r.json();const m=j.mode||j.saved||'balanced';const key=m+'|'+(j.governor||'')+'|'+(j.epp||'');if(perfCur===key&&el.innerHTML){el.style.display='';return;}perfCur=key;el.style.display='';el.innerHTML=`<div class="model">⚡ 系统性能:<b>${m==='powersave'?'省电模式':m==='performance'?'高性能模式':'平衡模式'}</b> <span class="mut">(governor=${esc(j.governor||'-')} epp=${esc(j.epp||'-')},不重启电脑)</span></div><div style="margin-top:6px">${['powersave','balanced','performance'].map(k=>`<button class="perfbtn${m===k?' cur':''}" onclick="setPerf('${k}')">${k==='powersave'?'🌱 省电模式':k==='balanced'?'⚖️ 平衡模式':'🔥 高性能模式'}</button>`).join('')}</div><div class="mut" style="margin-top:6px">${PERF_DESC[m]||''}</div><div id="perfMsg" class="mut" style="margin-top:4px"></div>`}catch(e){}}
async function setPerf(mode){const msg=document.getElementById('perfMsg');if(msg)msg.textContent='⏳ 正在切换…';try{const r=await fetch('/api/cpu/perf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});const j=await r.json();if(!j.ok){if(msg)msg.textContent='❌ 切换失败:'+(j.error||'未知错误');else alert('切换失败: '+(j.error||'未知'));return}cpuPerf()}catch(e){if(msg)msg.textContent='❌ 切换失败:网络错误'}}
function cmpVal(p,key){
if(key==='name')return String(p.name||'');
if(key==='desc')return String(p.desc||'');
@@ -117,7 +159,7 @@ function renderHeader(){
}
function sortBy(key){
if(sortState.key===key) sortState.dir*=-1; else {sortState.key=key;sortState.dir=-1;}
renderHeader(); renderBody();
saveSort();renderHeader(); renderBody();
}
async function load(manual){
document.getElementById('disks').innerHTML='';
@@ -129,30 +171,32 @@ document.getElementById('ts').textContent='更新于 '+new Date().toLocaleTimeSt
const S=document.getElementById('summary'),H=document.getElementById('thead'),B=document.getElementById('tbody');
if(cur==='cpu'){
S.innerHTML=`<div class="stat"><div class="k">CPU占用</div><div class="v green">${j.cpu}%</div></div><div class="stat"><div class="k">活跃进程数</div><div class="v green">${j.active}</div></div><div class="stat"><div class="k">负载(1分钟,5分钟,15分钟)</div><div class="v num">${j.load.join(', ')}</div></div>`;
const I=j.info||{},P=j.percpu||[];
const I=j.info||{},P=j.percpu||[],MHZ=j.mhz||[];
document.getElementById('cpuinfo').style.display='';
document.getElementById('cpuinfo').innerHTML=`<div class="model">${esc(I.model||'未知CPU')} <span class="mut">${esc(I.arch||'')}</span></div><div>物理 ${I.physical||'-'} 核 / 逻辑 ${I.logical||'-'} 核 · ${I.sockets?('共 '+I.sockets+' 路 × '+I.cores_per_socket+'核 × '+I.threads_per_core+'线程'):''} · 当前 ${I.freq_cur||'-'} MHz(${I.freq_min||'-'} ~ ${I.freq_max||'-'} MHz)</div><div class="mut">L1d ${esc(I.l1d||'-')} · L1i ${esc(I.l1i||'-')} · L2 ${esc(I.l2||'-')} · L3 ${esc(I.l3||'-')}</div><div class="corebar">${P.map((v,i)=>`<div class="corecell"><div class="cb"><i style="width:${Math.min(v,100)}%"></i></div><div class="ct">#${i} ${v}%</div></div>`).join('')}</div>`;
document.getElementById('cpuinfo').innerHTML=`<div class="model">${esc(I.model||'未知CPU')} <span class="mut">${esc(I.arch||'')}</span></div><div>物理 ${I.physical||'-'} 核 / 逻辑 ${I.logical||'-'} 核 · ${I.sockets?('共 '+I.sockets+' 路 × '+I.cores_per_socket+'核 × '+I.threads_per_core+'线程'):''} · 当前 ${I.freq_cur||'-'} MHz(${I.freq_min||'-'} ~ ${I.freq_max||'-'} MHz)</div><div class="mut">L1d ${esc(I.l1d||'-')} · L1i ${esc(I.l1i||'-')} · L2 ${esc(I.l2||'-')} · L3 ${esc(I.l3||'-')}</div><div class="corebar">${P.map((v,i)=>`<div class="corecell"><div class="cb"><i style="width:${Math.min(v,100)}%"></i></div><div class="ct">#${i} ${v}%${MHZ[i]!=null?'<br>'+MHZ[i]+'MHz':''}</div></div>`).join('')}</div>`;
document.getElementById('sub').textContent=`共 ${j.total} 个进程,显示占用最高的前 ${j.items.length} 个(进程CPU为单进程占用,单核满=100%,16核满=1600%,进程之和÷16≈整机占用)`;
cpuPerf();cpuAdvise();cpuSensor();showBench('cpu');
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('');
renderBody();
}else if(cur==='mem'){
S.innerHTML=`<div class="stat"><div class="k">内存占用</div><div class="v green">${j.percent}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(j.used)} / ${fmtB(j.total)}</div></div><div class="stat"><div class="k">可用 / Swap</div><div class="v num">${fmtB(j.available)} / ${fmtB(j.swap_used)}/${fmtB(j.swap_total)} (Swap ${j.swap_percent||0}%)</div></div><div class="stat"><div class="k">操作</div><div class="v"><button class="kill" onclick="dropMem()">回收可回收内存</button></div></div>`;
S.innerHTML=`<div class="stat"><div class="k">内存占用</div><div class="v green">${j.percent}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(j.used)} / ${fmtB(j.total)}</div></div><div class="stat"><div class="k">可用 / Swap</div><div class="v num">${fmtB(j.available)} / ${fmtB(j.swap_used)}/${fmtB(j.swap_total)} (Swap ${j.swap_percent||0}%)</div></div><div class="stat"><div class="k">操作</div><div class="v"><button class="bigbtn" onclick="dropMem()">🧹 一键回收未使用的内存</button></div></div>`;
const D=j.detail||{},H2=j.hw||{},DM=H2.dimms||[];
const row=(k,v)=>`<span style="display:inline-block;min-width:230px;margin-right:12px">${k} <b class="num">${v}</b></span>`;
document.getElementById('meminfo').style.display='';
document.getElementById('meminfo').innerHTML=`<div class="model">物理 ${fmtB(H2.installed||0)}(${DM.length} 条) · 系统可用 ${fmtB(H2.system_total||j.total)} · 预留 ${fmtB(H2.reserved||0)}(核显 ${fmtB(H2.vram||0)} + BIOS/PCI 保留)</div>`
+(DM.length?`<div class="mut">槽位:</div><div>${DM.map(d=>row(esc((d.locator||'')+' / '+(d.bank||'')), esc(fmtB(d.size_b)+' '+d.type+' '+(d.cspeed||d.speed||'')+'MT/s '+(d.maker||'')+' '+(d.part||'')))).join('')}</div>`:`<div class="mut">未读到内存槽位表(DMI 不可用)</div>`)
+`<div class="mut">占用拆解:</div><div>${row('空闲 MemFree',fmtB(D.MemFree))+row('可用 MemAvailable',fmtB(D.MemAvailable))+row('缓冲 Buffers',fmtB(D.Buffers))+row('缓存 Cached',fmtB(D.Cached))+row('Swap缓存',fmtB(D.SwapCached))+row('匿名页 AnonPages',fmtB(D.AnonPages))+row('映射 Mapped',fmtB(D.Mapped))+row('共享 Shmem',fmtB(D.Shmem))+row('Slab',fmtB(D.Slab))+row('可回收',fmtB(D.SReclaimable))+row('不可回收',fmtB(D.SUnreclaim))+row('页表',fmtB(D.PageTables))+row('内核栈',fmtB(D.KernelStack))+row('已提交',fmtB(D.Committed_AS))+row('提交上限',fmtB(D.CommitLimit))+row('脏页',fmtB(D.Dirty))}</div>`;
showBench('mem');
document.getElementById('sub').textContent=`按内存占用排序,前 ${j.items.length} 个进程`;
renderHeader();
B.innerHTML=j.items.map(p=>`<tr><td><a class="proc">${esc(p.name)}</a></td><td class="num">${fmtB(p.rss)}</td><td class="num">${fmtB(p.uss)}</td><td class="num">${fmtB(p.swap)}</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('');
renderBody();
}else if(cur==='disk'){
let tot=0,used=0;j.parts.forEach(p=>{tot+=p.total;used+=p.used});
const pct=tot?Math.round(used/tot*100):0;
S.innerHTML=`<div class="stat"><div class="k">磁盘占用</div><div class="v green">${pct}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(used)} / ${fmtB(tot)}</div></div><div class="stat"><div class="k">读 / 写累计</div><div class="v num">${fmtB(j.io.read||0)} / ${fmtB(j.io.write||0)}</div></div>`;
document.getElementById('sub').textContent=`${j.parts.length} 个挂载点,下表按 IO 占用排序`;
renderHeader();
B.innerHTML=j.items.map(p=>`<tr><td><a class="proc">${esc(p.name)}</a></td><td class="num">${fmtB(p.io_r)}</td><td class="num">${fmtB(p.io_w)}</td><td class="num">${p.pid}</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('');
renderBody();
document.getElementById('disks').innerHTML=j.parts.map(p=>`<div class="diskrow"><div class="t">${esc(p.mount)} <span class="mut">${esc(p.device)} ${esc(p.fstype)}</span></div><div class="num">${fmtB(p.used)} / ${fmtB(p.total)} (${p.percent}%) · 可用 ${fmtB(p.free)}</div><div class="bar"><i style="width:${p.percent}%"></i></div></div>`).join('');
const W=j.hw||{},DS=W.disks||[],AR=W.arrays||[],EX=W.extra||{};
const drow=(k,v)=>`<span style="display:inline-block;min-width:250px;margin-right:12px">${k} <b class="num">${v}</b></span>`;
@@ -161,12 +205,13 @@ document.getElementById('diskinfo').innerHTML=`<div class="model">共 ${DS.lengt
+(DS.length?DS.map(d=>`<div style="margin-top:8px;padding-top:8px;border-top:1px dashed #eee"><div><b>/dev/${esc(d.name||'')}</b> ${esc(d.model||d.sys_model||'')} <span class="mut">${esc(d.type||'')} · ${fmtB(d.cap_b)} · ${esc(d.iface||'')}</span> <span style="color:${String(d.health||'').includes('PASSED')?'#20a53a':'#f00'}">${esc(d.health||'')}</span></div><div>${drow('序列号',esc(d.serial||'-'))+drow('温度',d.temp_c!=null?(d.temp_c+'°C 快照'):'-')+drow('通电',d.hours!=null?(Math.floor(d.hours/24)+' 天('+d.hours+' 小时)'):'-')+drow('通电次数',d.power_cycles!=null?d.power_cycles:'-')+drow('坏道重映射',d.realloc!=null?d.realloc:'-')+(d.wear!=null?drow('磨损',d.wear+'%'):'')+(d.helium!=null?drow('氦气',d.helium+'%'):'')+(d.rpm?drow('转速',d.rpm+' 转'):drow('转速',d.rotational?'机械':'固态'))+drow('尺寸',esc(d.form||'-'))+drow('读写累计',fmtB(d.io_read||0)+' / '+fmtB(d.io_write||0))}</div></div>`).join(''):`<div class="mut">未读到硬盘信息</div>`)
+(AR.length?`<div class="mut" style="margin-top:8px">阵列拓扑(单成员 RAID1,无冗余):</div><div>${AR.map(a=>drow(esc(a.md+' → '+(a.mount||'')),esc((a.level||'')+' '+(a.members||'')+' '+(a.live_status||'')+' '+(a.live_devs||'')))).join('')+(AR.some(a=>a.live_sync)?`<div class="mut">${esc((AR.find(a=>a.live_sync)||{}).live_sync||'')}</div>`:''+(EX&&EX.mount?`<div>${drow('系统盘 '+esc(EX.mount||'/'),esc((EX.part||'')+'('+(EX.note||'')+')'))}</div>`:''))}</div>`:'')
+((W.note)?`<div class="mut">${esc(W.note)}</div>`:'');
showBench('disk');
}else{
const up=j.nics.reduce((a,n)=>a+n.up,0),down=j.nics.reduce((a,n)=>a+n.down,0);
S.innerHTML=`<div class="stat"><div class="k">上行速率</div><div class="v green">${fmtR(up)}</div></div><div class="stat"><div class="k">下行速率</div><div class="v green">${fmtR(down)}</div></div><div class="stat"><div class="k">累计接收</div><div class="v num">${fmtB(j.total_recv||0)}</div></div><div class="stat"><div class="k">累计发送</div><div class="v num">${fmtB(j.total_sent||0)}</div></div><div class="stat"><div class="k">连接数</div><div class="v num">${j.conns}</div></div>`;
document.getElementById('sub').textContent=j.nics.map(n=>`${n.nic}: ↑${fmtR(n.up)} ↓${fmtR(n.down)}`).join(' · ')||'无网卡数据';
renderHeader();
B.innerHTML=j.items.map(p=>`<tr><td><a class="proc">${esc(p.name)}</a></td><td class="num">${p.conns}</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('');
renderBody();
document.getElementById('netextra').innerHTML=`<h3 style="margin:12px 0 4px">TCP连接</h3><table class="t"><thead><tr><th>进程名</th><th>本地地址</th><th>远程地址</th><th>地址族</th><th>PID</th><th>状态</th></tr></thead><tbody>${(j.tcp||[]).map(c=>`<tr><td>${esc(c.name)}</td><td class="num">${esc(c.laddr)}</td><td class="num">${esc(c.raddr)}</td><td>${esc(c.family)}</td><td class="num">${c.pid||''}</td><td>${esc(c.status)}</td></tr>`).join('')}</tbody></table><h3 style="margin:12px 0 4px">侦听端口</h3><table class="t"><thead><tr><th>进程名</th><th>监听地址</th><th>端口</th><th>地址族</th><th>防火墙</th><th>PID</th><th>协议</th></tr></thead><tbody>${(j.listen||[]).map(c=>`<tr><td>${esc(c.name)}</td><td class="num">${esc(c.laddr)}</td><td class="num">${c.port}</td><td>${esc(c.family)}</td><td>不放行</td><td class="num">${c.pid||''}</td><td>${esc(c.proto)}</td></tr>`).join('')}</tbody></table>`;
const NW=j.hw||{},NN=NW.nics||[];
const nrow=(k,v)=>`<span style="display:inline-block;min-width:260px;margin-right:12px">${k} <b class="num">${v}</b></span>`;
@@ -175,13 +220,37 @@ document.getElementById('netinfo').style.display='';
document.getElementById('netinfo').innerHTML=`<div class="model">出口 ${esc(NW.egress||'-')} · 网关 ${esc(NW.gateway||'-')} · DNS ${esc(NW.dns||'-')} <span class="mut">共 ${NN.length} 块网卡(档案快照 ${esc(NW.snapshot||'')},地址/状态/速率为实时)</span></div>`
+(NN.length?NN.map(n=>`<div style="margin-top:8px;padding-top:8px;border-top:1px dashed #eee"><div><b>${esc(n.name||'')}</b> ${esc(n.model||'')} <span class="mut">${esc(n.role||'')}</span> <span style="color:${(n.isup===false||n.operstate==='down')?'#999':'#20a53a'}">${esc(n.operstate||(n.isup===false?'down':'up'))}</span></div><div>${nrow('IPv4',esc((n.ipv4||[]).join(' / ')||'-'))+nrow('IPv6',esc((n.ipv6||[]).join(' / ')||'-'))+nrow('MAC',esc(n.address||n.mac||'-'))+nrow('协商速率',spd(n.speed||n.live_speed))+nrow('双工',esc(n.duplex||'-'))+nrow('MTU',esc(n.mtu||n.live_mtu||'-'))+nrow('驱动',esc(n.live_driver||n.driver||'-'))+(n.pci?nrow('PCI',esc(n.pci)):'')+(n.ssid?nrow('WiFi',esc(n.ssid)):'')+nrow('收发累计',fmtB(n.io_recv||0)+' / '+fmtB(n.io_sent||0))}</div></div>`).join(''):`<div class="mut">未读到网卡信息</div>`)
+((NW.note)?`<div class="mut">${esc(NW.note)}</div>`:'');
showBench('net');
}
}catch(e){if(manual)alert('加载失败: '+e)}
}
load(true);setInterval(()=>load(false),3000);
const BENCH_TXT={cpu:['🏃 系统运行速度测试','20万次整数运算,分数越高越快;自动记录当前性能模式/governor/平均MHz,方便对比省电/平衡/高性能三档效果','预计约1秒'],mem:['💾 内存读写测速','32MB内存写入/读取,测完即释放,不占内存','预计约1秒'],disk:['💽 硬盘读写测速','32MB临时文件写(含落盘)/读,测完自动删除,不占空间','预计约1~3秒'],net:['🌐 网络上下行测速','Cloudflare测速点:下行5MB/上行2MB','预计约5~15秒(看网速)']};
function benchHist(k){try{return JSON.parse(localStorage.getItem('fnos_bench_'+k)||'[]')}catch(e){return[]}}
function benchPush(k,t){try{const h=benchHist(k);h.unshift(t);localStorage.setItem('fnos_bench_'+k,JSON.stringify(h.slice(0,5)))}catch(e){}}
function renderBench(k,msg){const el=document.getElementById(k+'bench');if(!el)return;const T=BENCH_TXT[k],h=benchHist(k);el.innerHTML='<div class="model">'+T[0]+' <span class="mut">⏱ '+T[2]+'</span></div><div class="mut">'+T[1]+'</div><div style="margin-top:6px"><button class="benchbtn" id="benchbtn_'+k+'" onclick="runBench(\''+k+'\')">开始测速('+T[2]+')</button> <button class="benchbtn" id="benchstop_'+k+'" onclick="stopBench(\''+k+'\')" style="display:none;background:#e33;border-color:#e33">⏹ 停止</button> <span id="benchmsg_'+k+'" class="mut">'+(msg||'')+'</span></div>'+(h.length?'<div class="benchhist">历史记录(最多5条,超出自动删旧):<br>'+h.map(x=>esc(x)).join('<br>')+'</div>':'<div class="benchhist">暂无测速记录,点「开始测速」跑一次</div>')}
const BENCH_CTL={};
function stopBench(k){const c=BENCH_CTL[k];if(c){try{c.abort()}catch(e){}}}
function showBench(k){const el=document.getElementById(k+'bench');if(!el)return;el.style.display='';if(!el.innerHTML)renderBench(k)}
async function runBench(k){const b=document.getElementById('benchbtn_'+k),m=document.getElementById('benchmsg_'+k),st=document.getElementById('benchstop_'+k);if(b){b.disabled=true;b.textContent='测速中…'}if(st)st.style.display='';if(m)m.textContent='正在测速,请稍候('+BENCH_TXT[k][2]+'),可点 ⏹ 停止';const ctl=new AbortController();BENCH_CTL[k]=ctl;try{const r=await fetch('/api/bench/'+k,{method:'POST',signal:ctl.signal});const j=await r.json();let line='';if(j.ok){if(k==='cpu')line=j.at+' ['+j.mode+'|'+(j.governor||'-')+'] '+j.ms+'ms '+j.kops+'kops AVG'+j.mhz_avg+'MHz';else if(k==='mem')line=j.at+' W'+j.write_mbs+'MB/s R'+j.read_mbs+'MB/s';else if(k==='disk')line=j.at+' W'+j.write_mbs+'MB/s R'+j.read_mbs+'MB/s '+(j.file||'');else if(k==='net')line=j.at+' DOWN'+j.down_mbps+'Mbps'+(j.up_mbps?' UP'+j.up_mbps+'Mbps':' UPFAIL');benchPush(k,line);renderBench(k,'✅ '+line)}else renderBench(k,'❌ '+(j.error||'测速失败'))}catch(e){if(e&&e.name==='AbortError'){renderBench(k,'⏹ 已停止(本次不记录)')}else renderBench(k,'❌ 测速失败: '+e)}finally{delete BENCH_CTL[k]}}
load(true);
let ivalSec=3,timer=null,paused=false;
function startTimer(){if(timer)clearInterval(timer);if(!paused)timer=setInterval(()=>load(false),ivalSec*1000)}
function setIval(v){ivalSec=+v;startTimer()}
function togglePause(){paused=!paused;document.getElementById('pauseBtn').textContent=paused?'▶ 恢复刷新':'⏸ 停止刷新';startTimer()}
startTimer();
async function licPoll(){try{const r=await fetch('/api/license-config',{cache:'no-store'});const j=await r.json();LIC=j;
const dot=document.getElementById('licDot');if(dot)dot.style.display=(j.announcements&&j.announcements.length)?'':'none';
const v=document.getElementById('licVer');if(v)v.textContent='v'+(j.version||'1.3.16')+(j.update_available?' ●':'');
if(j.popup&&j.popup.length){try{const seen=JSON.parse(localStorage.getItem('fnos_ann_seen')||'[]');const fresh=j.popup.filter(a=>seen.indexOf(a.id)<0);if(fresh.length){const a=fresh[0];const el=document.getElementById('annPop');el.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:center"><b>📢 ${esc(a.title||'公告')}</b><span onclick="dismissPop(${a.id})" style="cursor:pointer;color:#999">✕</span></div><div style="margin-top:6px">${a.content||''}</div>`;el.style.display='';}}catch(e){}}
if(document.getElementById('annPanel').style.display!=='none')renderAnn();}catch(e){}}
function renderAnn(){const el=document.getElementById('annPanel');const list=LIC.announcements||[];el.innerHTML=list.length?(`<b>📢 公告(${list.length})</b>`+list.map(a=>`<div style="margin-top:8px;padding-top:8px;border-top:1px dashed #eee"><div><b>${esc(a.title||'')}</b></div><div style="color:#666">${a.content||''}</div></div>`).join('')):'<span style="color:#999">暂无公告</span>';}
function toggleAnn(){const el=document.getElementById('annPanel');if(el.style.display==='none'){renderAnn();el.style.display=''}else el.style.display='none'}
function dismissPop(id){try{const seen=JSON.parse(localStorage.getItem('fnos_ann_seen')||'[]');seen.push(id);localStorage.setItem('fnos_ann_seen',JSON.stringify(seen))}catch(e){}document.getElementById('annPop').style.display='none'}
async function checkVer(manual){try{const r=await fetch('/api/license-config',{cache:'no-store'});const j=await r.json();LIC=j;if(j.update_available){alert('发现新版本 v'+(j.latest||'')+'\n'+(j.changelog||'')+(j.download_url?('\n下载:'+j.download_url):''))}else if(manual){alert('当前已是最新版本 v'+(j.version||''))}}catch(e){if(manual)alert('版本检查失败,稍后重试')}}
licPoll();setInterval(licPoll,10*60*1000);
</script>
<footer style="text-align:center;padding:8px;font-size:12px;color:#999;border-top:1px solid #eee">
<footer style="position:fixed;bottom:0;left:0;right:0;text-align:center;padding:6px;font-size:12px;color:#999;background:#fff;border-top:1px solid #eee;z-index:999">
<a href="https://nav.88531.cn" target="_blank" rel="noopener">资享导航站</a> ·
<a href="http://192.168.68.19:3200/jinghui/fnos-resmon" target="_blank" rel="noopener">开源地址 (Gitea)</a>
<a href="https://gitea.88531.cn/jinghui/fnos-resmon" target="_blank" rel="noopener">开源地址 (Gitea)</a>
</footer>
</html>
+1 -1
View File
@@ -1,2 +1,2 @@
Flask
flask
psutil
-6
View File
@@ -1,6 +0,0 @@
1|#!/bin/bash
2|# /vol1/1000/HD1/APP/hermes/awork/fnmon/start.sh
3|# Start the CPU Monitor service
4|cd "$(dirname "$0")"
5|exec python3 app.py
6|
-1274
View File
File diff suppressed because it is too large. Load diff
-231
View File
@@ -1,231 +0,0 @@
#!/usr/bin/env python3
"""Traffic tracker: total / daily / monthly cumulative counters + per-container stats with speed."""
import json
import os
import re
import sqlite3
import subprocess
import time
from datetime import date
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'traffic.db')
# Module-level cache for per-container speed calculation
# { container_name: { 'rx': int, 'tx': int, 'ts': float }, ... }
_PREV_CONTAINER_STATS = {}
def _get_db():
"""Get SQLite connection with schema init."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS traffic_base (
id TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
c.execute('''CREATE TABLE IF NOT EXISTS traffic_daily (
date TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
c.execute('''CREATE TABLE IF NOT EXISTS traffic_monthly (
month TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER
)''')
conn.commit()
return conn
def _get_interfaces():
"""Read /proc/net/dev and return {iface: {rx, tx}} for all relevant interfaces."""
interfaces = {}
try:
with open('/proc/net/dev') as f:
for line in f.readlines()[2:]: # skip headers
parts = line.strip().split()
if len(parts) >= 10:
iface = parts[0].rstrip(':')
# Skip loopback
if iface == 'lo':
continue
# Skip veth (Docker internal — counted per-container)
if iface.startswith('veth'):
continue
# Skip individual Docker bridges
if re.match(r'^br-[a-f0-9]{12}$', iface):
continue
interfaces[iface] = {
'rx': int(parts[1]),
'tx': int(parts[9]),
}
except Exception:
pass
return interfaces
def _get_main_traffic(interfaces):
"""Aggregate main interfaces (eno, enx, eth)."""
rx = tx = 0
for iface, data in interfaces.items():
if iface.startswith(('eno', 'enx', 'eth')):
rx += data['rx']
tx += data['tx']
return rx, tx
def _parse_bytes(s):
"""Parse '1.23kB', '45.4MB', '960B' etc to bytes."""
s = s.strip().replace(',', '')
m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s)
if not m:
return 0
val = float(m.group(1))
unit = m.group(2).lower()
multipliers = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6, 'mib': 1048576,
'gb': 1e9, 'gib': 1073741824, 'tb': 1e12, 'tib': 1099511627776}
return int(val * multipliers.get(unit, 1))
def _get_docker_containers():
"""
Get per-container network traffic + real-time speed.
Returns list sorted by total traffic descending.
Results cached for 30 seconds to avoid blocking SSE.
"""
global _PREV_CONTAINER_STATS, _DOCKER_CACHE, _DOCKER_CACHE_TIME
now = time.time()
# Return cache if fresh (30s)
if '_DOCKER_CACHE' in globals() and now - _DOCKER_CACHE_TIME < 30:
return _DOCKER_CACHE
containers = []
try:
result = subprocess.run(
['docker', 'stats', '--no-stream', '--format',
'{{.Name}}\t{{.NetIO}}'],
capture_output=True, text=True, timeout=30
)
lines = result.stdout.strip().split('\n')
now = time.time()
current_snapshot = {}
for line in lines:
parts = line.strip().split('\t')
if len(parts) >= 2:
name = parts[0]
netio = parts[1].strip()
rx_str, tx_str = '', ''
if ' / ' in netio:
rx_str, tx_str = netio.split(' / ', 1)
rx = _parse_bytes(rx_str.strip())
tx = _parse_bytes(tx_str.strip())
current_snapshot[name] = {'rx': rx, 'tx': tx, 'ts': now}
# Compute speed from previous snapshot
rx_speed = tx_speed = 0
prev = _PREV_CONTAINER_STATS.get(name)
if prev:
dt = now - prev['ts']
if dt > 0:
rx_speed = max(0, (rx - prev['rx']) / dt)
tx_speed = max(0, (tx - prev['tx']) / dt)
containers.append({
'name': name,
'rx': rx,
'tx': tx,
'rx_speed': round(rx_speed),
'tx_speed': round(tx_speed),
})
_PREV_CONTAINER_STATS = current_snapshot
except subprocess.TimeoutExpired:
pass
except FileNotFoundError:
pass # Docker not available
except Exception:
pass
# Sort by total traffic (rx + tx) descending
containers.sort(key=lambda c: c['rx'] + c['tx'], reverse=True)
_DOCKER_CACHE = containers
_DOCKER_CACHE_TIME = time.time()
return containers
def get_traffic_stats():
"""
Return traffic stats:
- total: cumulative since tracking started
- daily: cumulative for today
- monthly: cumulative for this month
- containers: top Docker containers with cumulative + speed
"""
conn = _get_db()
c = conn.cursor()
today = date.today().isoformat()
this_month = today[:7] # YYYY-MM
interfaces = _get_interfaces()
main_rx, main_tx = _get_main_traffic(interfaces)
# ---- Total traffic baseline (set once) ----
c.execute('SELECT rx_base, tx_base FROM traffic_base WHERE id=?', ('_main',))
row = c.fetchone()
if row:
rx_base, tx_base = row
else:
rx_base, tx_base = main_rx, main_tx
c.execute('INSERT OR REPLACE INTO traffic_base VALUES (?,?,?)',
('_main', rx_base, tx_base))
conn.commit()
total_rx = max(0, main_rx - rx_base)
total_tx = max(0, main_tx - tx_base)
# ---- Daily cumulative (baseline at start of day) ----
c.execute('SELECT rx_base, tx_base FROM traffic_daily WHERE date=?', (today,))
row = c.fetchone()
if row:
day_rx_base, day_tx_base = row
else:
# First call today: set baseline = current total, so daily starts at 0
day_rx_base, day_tx_base = total_rx, total_tx
c.execute('INSERT OR REPLACE INTO traffic_daily VALUES (?,?,?)',
(today, day_rx_base, day_tx_base))
conn.commit()
daily_rx = max(0, total_rx - day_rx_base)
daily_tx = max(0, total_tx - day_tx_base)
# ---- Monthly cumulative (baseline at start of month) ----
c.execute('SELECT rx_base, tx_base FROM traffic_monthly WHERE month=?',
(this_month,))
row = c.fetchone()
if row:
mon_rx_base, mon_tx_base = row
else:
mon_rx_base, mon_tx_base = total_rx, total_tx
c.execute('INSERT OR REPLACE INTO traffic_monthly VALUES (?,?,?)',
(this_month, mon_rx_base, mon_tx_base))
conn.commit()
monthly_rx = max(0, total_rx - mon_rx_base)
monthly_tx = max(0, total_tx - mon_tx_base)
conn.close()
# ---- Docker containers ----
containers = _get_docker_containers()
return {
'total_rx': total_rx,
'total_tx': total_tx,
'daily_rx': daily_rx,
'daily_tx': daily_tx,
'monthly_rx': monthly_rx,
'monthly_tx': monthly_tx,
'containers': containers[:15], # top 15
}
if __name__ == '__main__':
print(json.dumps(get_traffic_stats(), indent=2))