Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082a94293a | ||
|
|
bf5a1c7301 | ||
|
|
6620377934 | ||
|
|
81df67fb20 | ||
|
|
96442db19c | ||
|
|
0936a86d0e | ||
|
|
ed23092cd6 | ||
|
|
d5e78908fb | ||
|
|
e0627a9158 | ||
|
|
986dc63179 | ||
|
|
fc764b9faa | ||
|
|
44eccf9a75 | ||
|
|
1abd96f081 | ||
|
|
37727fa9df | ||
|
|
c13dfb6df8 | ||
|
|
42f1d20a99 | ||
|
|
9e2475dfe0 | ||
|
|
5d46667aef | ||
|
|
d1c923d59e | ||
|
|
28eecb55ed | ||
|
|
3f72ef7412 | ||
|
|
843322534f | ||
|
|
b52df4b192 | ||
|
|
f49315dd23 |
No files matched your search
@@ -1,27 +0,0 @@
|
|||||||
# Use an official Python runtime as a parent image
|
|
||||||
FROM python:3.9-slim-bullseye
|
|
||||||
|
|
||||||
# Set the working directory in the container
|
|
||||||
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"]
|
|
||||||
|
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,78 +1,78 @@
|
|||||||
<div align="center">
|
# 飞牛NAS必装:进程级资源监控,比自带强10倍,一眼看穿谁在偷吃你的CPU/内存/磁盘/网络全透视,带一键内存回收的监控面板
|
||||||
|
CPU/内存/磁盘/网络 + 进程排行 + 一键回收 + SMART/连接明细,Docker一键部署
|
||||||
|
|
||||||
# 🖥️ 小白系统监控器 — fnOS飞牛实时系统监控面板 - docker应用
|
> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。
|
||||||
|
|
||||||
**一键 Docker 部署 | 6 大监控维度 | 数据可导出**
|
|
||||||
|
|
||||||
[](https://hub.docker.com/r/jinghui1984/fnmon)
|
|
||||||
[](https://github.com/personal82555/fnmon)
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://www.fnnas.com)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
## ✨ 为什么选择 fnos-resmon?与飞牛自带的对比
|
||||||
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dd6dbc5.png" alt="fnmon 总览" width="80%"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
**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` 启动,无需额外驱动
|
||||||
|
- ⚡ **实时刷新**:3 秒自动更新,数据实时
|
||||||
## ✨ 核心特性
|
- 📊 **维度全面**:CPU / 内存 / 磁盘 / 网络 四大面板,进程级明细
|
||||||
|
- 🔍 **进程级洞察**:每个进程 CPU、内存、IO、连接数一目了然
|
||||||
| 特性 | 说明 |
|
- 🧹 **内存回收**:一键 drop caches 释放可回收内存
|
||||||
|------|------|
|
- 🔪 **进程管理**:一键结束异常进程
|
||||||
| 🚀 **一键部署** | 一条 `docker run` 命令启动,无需安装驱动 |
|
- 📱 **移动适配**:响应式布局,手机也能看
|
||||||
| ⚡ **实时推送** | SSE 技术,2 秒刷新一次,延迟极低 |
|
- 🐳 **零侵入**:Docker 容器运行,不修改系统文件
|
||||||
| 📊 **维度全面** | 6 大面板覆盖系统全部关键指标 |
|
|
||||||
| 💾 **数据导出** | 一键导出所有历史数据到 NAS 目录,JSON 格式 |
|
|
||||||
| 📱 **移动适配** | 深色主题,侧边栏导航,屏幕利用率高 |
|
|
||||||
| 🐳 **零侵入** | Docker 容器运行,不修改系统文件 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 功能截图
|
## 📸 功能截图
|
||||||
|
|
||||||
### ⚡ CPU 监控
|

|
||||||
实时显示 CPU 型号、每个核心的当前频率、Governor/EPP 运行模式、核心温度(支持 SATA/NVMe 硬盘温度)、Turbo Boost 状态、60 秒频率历史曲线图。还包括 CPU 评测跑分(PassMark/Cinebench/Geekbench)和同级 CPU 横向对比。
|
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1db91017.png" alt="CPU 面板" width="90%"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### 🧠 内存监控
|

|
||||||
总容量 / 已用 / 可用 / 缓存 / SWAP 全面展示,进度条可视化,数据实时更新。
|
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dcc7cc3.png" alt="内存面板" width="90%"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### 🌐 网络流量
|
---
|
||||||
实时上传/下载网速、当月/当日累计流量、Docker 容器流量排行(TOP 15)、60 秒流量趋势图。
|
|
||||||
|
|
||||||
<p align="center">
|
## 📋 功能清单
|
||||||
<img src="https://img.88531.cn/i/2026/06/15/6a2ed1dc13916.png" alt="网络面板" width="90%"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### 💾 磁盘健康
|
### CPU 监控
|
||||||
物理硬盘列表(型号、容量、接口类型)、SMART 健康状态、硬盘温度、NVMe 寿命百分比、通电时间、总写入量。磁盘分区挂载点使用率 + 60 秒 I/O 趋势图。
|
- 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>
|
- 连接数统计
|
||||||
|
- TCP 连接明细表(进程、本地/远程地址、状态)
|
||||||
### 🔝 进程 CPU 排名
|
- 侦听端口表
|
||||||
实时 TOP 20 进程,含 PID、用户、CPU%、内存%、启动时间。CPU% 用颜色标识(🔴>50% / 🟠>20% / 🟡>5%)。
|
- 网卡信息(IP、MAC、速率、双工、MTU、驱动)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,62 +81,13 @@
|
|||||||
### 方式一:Docker 运行(推荐)
|
### 方式一:Docker 运行(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name fnmon -p 5000:5000 --privileged \
|
docker run -d --name fnos-resmon -p 7031:7031 --privileged \
|
||||||
-v /proc:/proc:ro -v /sys:/sys:ro \
|
-v /proc:/host/proc:rw -v /sys:/host/sys:ro \
|
||||||
-v /vol2/fnmon-exports:/var/exports \
|
jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest
|
||||||
jinghui1984/fnmon
|
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后浏览器访问 `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) |
|
| 后端框架 | Flask (Python) |
|
||||||
| 前端 | 纯 HTML/CSS/JS(无额外依赖) |
|
| 前端 | 纯 HTML/CSS/JS(无额外依赖) |
|
||||||
| 实时通信 | Server-Sent Events (SSE) |
|
|
||||||
| 数据采集 | psutil、smartctl、/proc 文件系统 |
|
| 数据采集 | psutil、smartctl、/proc 文件系统 |
|
||||||
| 数据存储 | SQLite(应用用量 + 流量审计) |
|
| 容器化 | Docker |
|
||||||
| 容器化 | 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] 一键数据导出
|
|
||||||
- [ ] 暗色 / 亮色主题切换
|
|
||||||
- [ ] 微信 / 邮件告警
|
|
||||||
- [ ] 多语言支持
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -176,55 +106,14 @@ services:
|
|||||||
|
|
||||||
| 资源 | 地址 |
|
| 资源 | 地址 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Docker Hub | `jinghui1984/fnmon` |
|
| Docker 镜像 | `jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest` |
|
||||||
| GitHub | 本仓库 |
|
| Gitea 仓库 | https://gitea.88531.cn/jinghui/fnos-resmon |
|
||||||
| 论坛讨论 | [飞牛论坛](https://club.fnnas.com) |
|
|
||||||
|
欢迎 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
|
- 资享导航站:https://nav.88531.cn
|
||||||
- 开源地址:http://192.168.68.19:3200/jinghui/fnos-resmon
|
- 开源地址:https://gitea.88531.cn/jinghui/fnos-resmon
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,770 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""fnOS System Monitor — CPU, 内存, 网络, 磁盘实时监控 (SSE + API)."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import glob
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from collections import deque
|
|
||||||
from datetime import datetime
|
|
||||||
from flask import Flask, Response, jsonify, request
|
|
||||||
from traffic_tracker import get_traffic_stats
|
|
||||||
import traffic_tracker
|
|
||||||
from app_usage_tracker import collect_snapshot, get_app_ranking, get_combined_ranking
|
|
||||||
import app_usage_tracker
|
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='static', static_url_path='')
|
|
||||||
|
|
||||||
SYSFS_CPU = '/sys/devices/system/cpu'
|
|
||||||
THERMAL = '/sys/class/thermal'
|
|
||||||
UPDATE_INTERVAL = 2 # seconds
|
|
||||||
|
|
||||||
# Network history (30 samples = 60s at 2s interval)
|
|
||||||
NET_HISTORY = deque(maxlen=30)
|
|
||||||
DISK_HISTORY = deque(maxlen=30)
|
|
||||||
CPU_HISTORY = {} # { 'cpu0': deque(maxlen=30), ... }
|
|
||||||
|
|
||||||
|
|
||||||
def read_sysfs(path, default='N/A'):
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except (FileNotFoundError, PermissionError, OSError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_stats():
|
|
||||||
"""CPU: per-core freq, governor, EPP, temp, load."""
|
|
||||||
stats = {}
|
|
||||||
freqs = {}
|
|
||||||
for cpu_dir in sorted(glob.glob(f'{SYSFS_CPU}/cpu[0-9]*')):
|
|
||||||
core = os.path.basename(cpu_dir)
|
|
||||||
freq = read_sysfs(f'{cpu_dir}/cpufreq/scaling_cur_freq')
|
|
||||||
freqs[core] = int(freq) // 1000 if freq != 'N/A' else 0
|
|
||||||
stats['freqs'] = freqs
|
|
||||||
|
|
||||||
stats['governor'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_governor')
|
|
||||||
stats['epp'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/energy_performance_preference')
|
|
||||||
stats['driver'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_driver')
|
|
||||||
stats['min_freq'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_min_freq', '0')) // 1000
|
|
||||||
stats['max_freq'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_max_freq', '0')) // 1000
|
|
||||||
stats['hw_max'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/cpuinfo_max_freq', '0')) // 1000
|
|
||||||
stats['hw_min'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/cpuinfo_min_freq', '0')) // 1000
|
|
||||||
|
|
||||||
# CPU info
|
|
||||||
try:
|
|
||||||
with open('/proc/cpuinfo') as f:
|
|
||||||
for line in f:
|
|
||||||
if 'model name' in line:
|
|
||||||
stats['model'] = line.split(':')[1].strip()
|
|
||||||
break
|
|
||||||
# Brand
|
|
||||||
stats['brand'] = 'Intel' if 'Intel' in stats.get('model', '') else \
|
|
||||||
'AMD' if 'AMD' in stats.get('model', '') else 'Unknown'
|
|
||||||
# Shorter display model
|
|
||||||
stats['model_short'] = stats.get('model', '').replace('Intel(R) Core(TM) ', '').replace(' CPU @', '@')
|
|
||||||
|
|
||||||
# Core/Thread count
|
|
||||||
with open('/proc/cpuinfo') as f:
|
|
||||||
lines = f.read()
|
|
||||||
stats['physical_cores'] = len(set(re.findall(r'core id\s+:\s+(\d+)', lines)))
|
|
||||||
stats['threads'] = len(re.findall(r'processor\s+:\s+(\d+)', lines))
|
|
||||||
stats['sockets'] = len(set(re.findall(r'physical id\s+:\s+(\d+)', lines)))
|
|
||||||
except Exception:
|
|
||||||
stats['model'] = 'Unknown'
|
|
||||||
stats['model_short'] = 'Unknown'
|
|
||||||
stats['physical_cores'] = 0
|
|
||||||
stats['threads'] = 0
|
|
||||||
stats['sockets'] = 0
|
|
||||||
|
|
||||||
# CPU benchmark database — matches by model_short substring
|
|
||||||
CPU_DB = {
|
|
||||||
'i3-9100T': {
|
|
||||||
'bench': {'passmark_single': 1850, 'passmark_multi': 5500, 'cinebench_r23_single': 850, 'cinebench_r23_multi': 3200, 'geekbench6_single': 1100, 'geekbench6_multi': 3500},
|
|
||||||
'tdp': 35,
|
|
||||||
},
|
|
||||||
'i5-8600T': {
|
|
||||||
'bench': {'passmark_single': 2034, 'passmark_multi': 10870, 'cinebench_r23_single': 1050, 'cinebench_r23_multi': 5500, 'geekbench6_single': 1400, 'geekbench6_multi': 5500},
|
|
||||||
'tdp': 35,
|
|
||||||
},
|
|
||||||
'i5-8400T': {
|
|
||||||
'bench': {'passmark_single': 1930, 'passmark_multi': 8500, 'cinebench_r23_single': 950, 'cinebench_r23_multi': 4200, 'geekbench6_single': 1300, 'geekbench6_multi': 4300},
|
|
||||||
'tdp': 35,
|
|
||||||
},
|
|
||||||
'i5-10500T': {
|
|
||||||
'bench': {'passmark_single': 2300, 'passmark_multi': 10800, 'cinebench_r23_single': 1100, 'cinebench_r23_multi': 5800, 'geekbench6_single': 1500, 'geekbench6_multi': 6000},
|
|
||||||
'tdp': 35,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cpu_model_name = stats.get('model_short', '')
|
|
||||||
matched = None
|
|
||||||
for key, data in CPU_DB.items():
|
|
||||||
if key.lower() in cpu_model_name.lower():
|
|
||||||
matched = key
|
|
||||||
stats['bench'] = data['bench']
|
|
||||||
stats['current_tdp'] = data['tdp']
|
|
||||||
break
|
|
||||||
if not matched:
|
|
||||||
# fallback: safe defaults
|
|
||||||
stats['bench'] = {'passmark_single': 0, 'passmark_multi': 0, 'cinebench_r23_single': 0, 'cinebench_r23_multi': 0, 'geekbench6_single': 0, 'geekbench6_multi': 0}
|
|
||||||
stats['current_tdp'] = 0
|
|
||||||
|
|
||||||
# Comparison: nearby CPUs
|
|
||||||
compare_list = [
|
|
||||||
{'name': 'N100', 'score': 4200, 'tdp': 6, 'note': '入门NAS神U'},
|
|
||||||
{'name': 'J4125', 'score': 2800, 'tdp': 10, 'note': '极低功耗'},
|
|
||||||
{'name': 'i3-12100', 'score': 12400, 'tdp': 60, 'note': '质变之选'},
|
|
||||||
{'name': 'i5-10500T', 'score': 10800, 'tdp': 35, 'note': '6C/12T'},
|
|
||||||
{'name': 'i5-8600T', 'score': 10870, 'tdp': 35, 'note': '6C/12T'},
|
|
||||||
{'name': 'i5-8400T', 'score': 8500, 'tdp': 35, 'note': '35W升级'},
|
|
||||||
{'name': 'i3-9100T', 'score': 5500, 'tdp': 35, 'note': '35W均衡'},
|
|
||||||
]
|
|
||||||
# Mark the matched CPU
|
|
||||||
for c in compare_list:
|
|
||||||
if matched and c['name'] == matched:
|
|
||||||
c['name'] = f"{c['name']} ←你"
|
|
||||||
break
|
|
||||||
stats['compare'] = compare_list
|
|
||||||
|
|
||||||
# Overclock check
|
|
||||||
cur_freq = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_cur_freq', '0')) // 1000
|
|
||||||
base_freq = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/base_frequency', '0')) // 1000
|
|
||||||
if cur_freq > stats['hw_max']:
|
|
||||||
stats['oc'] = True
|
|
||||||
elif cur_freq > base_freq and base_freq > 0:
|
|
||||||
stats['turbo'] = True
|
|
||||||
stats['base_freq'] = base_freq
|
|
||||||
else:
|
|
||||||
stats['turbo'] = False
|
|
||||||
stats['base_freq'] = base_freq
|
|
||||||
|
|
||||||
# Fan speed (check hwmon)
|
|
||||||
stats['fan_rpm'] = None
|
|
||||||
try:
|
|
||||||
for hw in glob.glob('/sys/class/hwmon/hwmon*'):
|
|
||||||
hw_name = read_sysfs(f'{hw}/name', '')
|
|
||||||
for fan_in in sorted(glob.glob(f'{hw}/fan*_input')):
|
|
||||||
rpm = read_sysfs(fan_in)
|
|
||||||
if rpm and rpm != 'N/A' and int(rpm) > 0:
|
|
||||||
label = read_sysfs(fan_in.replace('_input', '_label'), os.path.basename(fan_in))
|
|
||||||
if not stats['fan_rpm']:
|
|
||||||
stats['fan_rpm'] = {'rpm': int(rpm), 'label': label}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
temps = []
|
|
||||||
for tz in sorted(glob.glob(f'{THERMAL}/thermal_zone*')):
|
|
||||||
raw = read_sysfs(f'{tz}/temp')
|
|
||||||
if raw != 'N/A':
|
|
||||||
temps.append(round(int(raw) / 1000, 1))
|
|
||||||
stats['temps'] = temps
|
|
||||||
stats['temp_max'] = max(temps) if temps else 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open('/proc/loadavg') as f:
|
|
||||||
parts = f.read().strip().split()
|
|
||||||
stats['load_1'] = float(parts[0])
|
|
||||||
stats['load_5'] = float(parts[1])
|
|
||||||
stats['load_15'] = float(parts[2])
|
|
||||||
except OSError:
|
|
||||||
stats['load_1'] = stats['load_5'] = stats['load_15'] = 0
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def get_mem_stats():
|
|
||||||
"""Memory: total, used, available, swap, cached, buffers."""
|
|
||||||
stats = {}
|
|
||||||
try:
|
|
||||||
with open('/proc/meminfo') as f:
|
|
||||||
data = {}
|
|
||||||
for line in f:
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) == 2:
|
|
||||||
val = parts[1].strip().split()[0]
|
|
||||||
try:
|
|
||||||
data[parts[0]] = int(val)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
total = data.get('MemTotal', 0)
|
|
||||||
free = data.get('MemFree', 0)
|
|
||||||
avail = data.get('MemAvailable', 0)
|
|
||||||
cached = data.get('Cached', 0)
|
|
||||||
buffers = data.get('Buffers', 0)
|
|
||||||
swap_total = data.get('SwapTotal', 0)
|
|
||||||
swap_free = data.get('SwapFree', 0)
|
|
||||||
|
|
||||||
stats['total'] = total // 1024
|
|
||||||
stats['used'] = (total - avail) // 1024
|
|
||||||
stats['free'] = free // 1024
|
|
||||||
stats['available'] = avail // 1024
|
|
||||||
stats['cached'] = cached // 1024
|
|
||||||
stats['buffers'] = buffers // 1024
|
|
||||||
stats['swap_total'] = swap_total // 1024
|
|
||||||
stats['swap_free'] = swap_free // 1024
|
|
||||||
stats['swap_used'] = (swap_total - swap_free) // 1024
|
|
||||||
|
|
||||||
# Percentage
|
|
||||||
stats['used_pct'] = round((total - avail) / total * 100, 1) if total else 0
|
|
||||||
stats['swap_pct'] = round((swap_total - swap_free) / swap_total * 100, 1) if swap_total else 0
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def get_net_stats():
|
|
||||||
"""Network: per-interface bytes in/out, cumulative."""
|
|
||||||
stats = {}
|
|
||||||
try:
|
|
||||||
with open('/proc/net/dev') as f:
|
|
||||||
f.readline() # header
|
|
||||||
f.readline() # header
|
|
||||||
interfaces = {}
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split()
|
|
||||||
if len(parts) >= 10:
|
|
||||||
iface = parts[0].rstrip(':')
|
|
||||||
# Skip loopback
|
|
||||||
if iface == 'lo':
|
|
||||||
continue
|
|
||||||
# Skip veth (Docker internal)
|
|
||||||
if iface.startswith('veth'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip individual Docker bridges, show aggregated later
|
|
||||||
if re.match(r'^br-[a-f0-9]{12}$', iface):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip unused bridges
|
|
||||||
if iface in ('br-ikuai-lan',):
|
|
||||||
continue
|
|
||||||
|
|
||||||
rx_bytes = int(parts[1])
|
|
||||||
tx_bytes = int(parts[9])
|
|
||||||
interfaces[iface] = {
|
|
||||||
'display': iface,
|
|
||||||
'rx_bytes': rx_bytes,
|
|
||||||
'tx_bytes': tx_bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate speed from history
|
|
||||||
now = time.time()
|
|
||||||
prev = {}
|
|
||||||
if NET_HISTORY:
|
|
||||||
prev = NET_HISTORY[-1]
|
|
||||||
|
|
||||||
for iface, data in interfaces.items():
|
|
||||||
if iface in prev:
|
|
||||||
dt = now - prev.get('_ts', now)
|
|
||||||
if dt > 0:
|
|
||||||
rx_speed = (data['rx_bytes'] - prev[iface]['rx_bytes']) / dt
|
|
||||||
tx_speed = (data['tx_bytes'] - prev[iface]['tx_bytes']) / dt
|
|
||||||
data['rx_speed'] = round(rx_speed)
|
|
||||||
data['tx_speed'] = round(tx_speed)
|
|
||||||
else:
|
|
||||||
data['rx_speed'] = 0
|
|
||||||
data['tx_speed'] = 0
|
|
||||||
else:
|
|
||||||
data['rx_speed'] = 0
|
|
||||||
data['tx_speed'] = 0
|
|
||||||
|
|
||||||
# Store for next speed calc
|
|
||||||
current_snapshot = {iface: {'rx_bytes': d['rx_bytes'], 'tx_bytes': d['tx_bytes']}
|
|
||||||
for iface, d in interfaces.items()}
|
|
||||||
current_snapshot['_ts'] = now
|
|
||||||
NET_HISTORY.append(current_snapshot)
|
|
||||||
|
|
||||||
# Sort: main interfaces first (eno1, enx...), then bridges
|
|
||||||
def sort_key(iface):
|
|
||||||
name = iface[0]
|
|
||||||
if name.startswith('eno') or name.startswith('enx'):
|
|
||||||
return (0, name)
|
|
||||||
elif name.startswith('tun'):
|
|
||||||
return (1, name)
|
|
||||||
elif name.startswith('br-') or name.startswith('docker'):
|
|
||||||
return (2, name)
|
|
||||||
else:
|
|
||||||
return (3, name)
|
|
||||||
|
|
||||||
sorted_ifaces = sorted(interfaces.items(), key=lambda x: sort_key(x[0]))
|
|
||||||
stats['interfaces'] = [v for _, v in sorted_ifaces]
|
|
||||||
stats['interface_names'] = [k for k, _ in sorted_ifaces]
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def get_drive_health():
|
|
||||||
"""Physical drive health: type, temp, lifespan, reallocated sectors."""
|
|
||||||
drives = []
|
|
||||||
try:
|
|
||||||
# Parse lsblk for physical disks
|
|
||||||
blk = os.popen("lsblk -d -o NAME,SIZE,TYPE,ROTA,TRAN,MODEL --json 2>/dev/null").read()
|
|
||||||
if blk:
|
|
||||||
data = json.loads(blk)
|
|
||||||
for dev in data.get('blockdevices', []):
|
|
||||||
if dev.get('type') != 'disk':
|
|
||||||
continue
|
|
||||||
name = dev['name']
|
|
||||||
size = dev.get('size', '?')
|
|
||||||
# lsblk JSON: rota can be bool (True/False) or int (0/1)
|
|
||||||
rota_raw = dev.get('rota', True)
|
|
||||||
if isinstance(rota_raw, bool):
|
|
||||||
rota = '1' if rota_raw else '0'
|
|
||||||
else:
|
|
||||||
rota = str(rota_raw)
|
|
||||||
tran = dev.get('tran', 'sata') or 'sata'
|
|
||||||
model = dev.get('model', name)
|
|
||||||
|
|
||||||
drive_type = 'NVMe' if (tran or '').lower() == 'nvme' else ('SSD' if rota == '0' else 'HDD')
|
|
||||||
dev_path = f'/dev/{name}'
|
|
||||||
if tran == 'nvme':
|
|
||||||
# NVMe might be named nvme0n1, smartctl needs nvme0
|
|
||||||
dev_path = '/dev/' + re.sub(r'n\d+$', '', name)
|
|
||||||
|
|
||||||
health = {}
|
|
||||||
try:
|
|
||||||
result = os.popen(f'sudo smartctl -A {dev_path} 2>/dev/null').read()
|
|
||||||
if 'SMART/Health' in result or 'SMART Attributes' in result:
|
|
||||||
# Temperature
|
|
||||||
if 'Temperature:' in result:
|
|
||||||
# NVMe: "Temperature: 48 Celsius"
|
|
||||||
m = re.search(r'Temperature:\s+(\d+)', result)
|
|
||||||
if m:
|
|
||||||
health['temp'] = int(m.group(1))
|
|
||||||
else:
|
|
||||||
# SATA: line "194 Temperature_Celsius ... - 39 (Min/Max 22/60)"
|
|
||||||
for line in result.split('\n'):
|
|
||||||
if 'Temperature_Celsius' in line:
|
|
||||||
# Get the last number before the parenthetical
|
|
||||||
parts = line.split()
|
|
||||||
# Find the raw value (after rightmost '-' or last number)
|
|
||||||
for p in reversed(parts):
|
|
||||||
if p.isdigit():
|
|
||||||
health['temp'] = int(p)
|
|
||||||
break
|
|
||||||
break
|
|
||||||
# Power on hours
|
|
||||||
pwr = re.findall(r'Power_On_Hours.*?(\d+)\s*$', result, re.MULTILINE)
|
|
||||||
if pwr:
|
|
||||||
hours = int(pwr[-1])
|
|
||||||
health['power_on_hours'] = hours
|
|
||||||
health['power_on_days'] = round(hours / 24, 1)
|
|
||||||
# Reallocated sectors
|
|
||||||
realloc = re.findall(r'Reallocated_Sector_Ct.*?(\d+)\s*$', result, re.MULTILINE)
|
|
||||||
if realloc:
|
|
||||||
health['reallocated'] = int(realloc[-1])
|
|
||||||
# Percentage Used (NVMe)
|
|
||||||
pct = re.findall(r'Percentage Used[:\s]+(\d+)%', result)
|
|
||||||
if pct:
|
|
||||||
health['pct_used'] = int(pct[0])
|
|
||||||
health['life_remaining'] = 100 - int(pct[0])
|
|
||||||
# Available Spare (NVMe)
|
|
||||||
spare = re.findall(r'Available Spare[:\s]+(\d+)%', result)
|
|
||||||
if spare:
|
|
||||||
health['available_spare'] = int(spare[0])
|
|
||||||
# Data written (NVMe)
|
|
||||||
writ = re.findall(r'Data Units Written[:\s]+[\d,]+ \[([\d.]+ [GMTP]B)\]', result)
|
|
||||||
if writ:
|
|
||||||
health['data_written'] = writ[0]
|
|
||||||
read = re.findall(r'Data Units Read[:\s]+[\d,]+ \[([\d.]+ [GMTP]B)\]', result)
|
|
||||||
if read:
|
|
||||||
health['data_read'] = read[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Health status
|
|
||||||
try:
|
|
||||||
h = os.popen(f'sudo smartctl -H {dev_path} 2>/dev/null').read()
|
|
||||||
if 'PASSED' in h:
|
|
||||||
health['status'] = 'PASSED'
|
|
||||||
elif 'FAILED' in h:
|
|
||||||
health['status'] = 'FAILED'
|
|
||||||
else:
|
|
||||||
health['status'] = 'UNKNOWN'
|
|
||||||
except Exception:
|
|
||||||
health['status'] = 'UNKNOWN'
|
|
||||||
|
|
||||||
drives.append({
|
|
||||||
'name': name,
|
|
||||||
'model': model,
|
|
||||||
'size': size,
|
|
||||||
'type': drive_type,
|
|
||||||
'interface': tran.upper(),
|
|
||||||
'health': health,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return drives
|
|
||||||
|
|
||||||
|
|
||||||
def get_disk_stats():
|
|
||||||
"""Disk: usage, I/O stats."""
|
|
||||||
stats = {}
|
|
||||||
|
|
||||||
# Disk usage (df)
|
|
||||||
mounts = []
|
|
||||||
try:
|
|
||||||
result = os.popen('df -B1 2>/dev/null').read()
|
|
||||||
for line in result.strip().split('\n')[1:]:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 6:
|
|
||||||
fs = parts[0]
|
|
||||||
total = int(parts[1])
|
|
||||||
used = int(parts[2])
|
|
||||||
avail = int(parts[3])
|
|
||||||
pct = parts[4].rstrip('%')
|
|
||||||
mnt = parts[5]
|
|
||||||
|
|
||||||
# Skip virtual filesystems
|
|
||||||
if fs.startswith(('tmpfs', 'devtmpfs', 'efivarfs', 'overlay', 'shm',
|
|
||||||
'nsfs', 'proc', 'sysfs', 'cgroup', 'devpts')):
|
|
||||||
continue
|
|
||||||
# Skip Docker overlay mounts
|
|
||||||
if mnt.startswith('/var/lib/docker'):
|
|
||||||
continue
|
|
||||||
# Skip /dev, /proc, /sys
|
|
||||||
if mnt in ('/dev', '/proc', '/sys', '/run'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
mounts.append({
|
|
||||||
'filesystem': fs,
|
|
||||||
'mount': mnt,
|
|
||||||
'total': total,
|
|
||||||
'used': used,
|
|
||||||
'avail': avail,
|
|
||||||
'used_pct': int(pct) if pct else 0,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
stats['mounts'] = mounts
|
|
||||||
|
|
||||||
# Disk I/O (from /proc/diskstats, aggregate by device)
|
|
||||||
devices = {}
|
|
||||||
try:
|
|
||||||
with open('/proc/diskstats') as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split()
|
|
||||||
if len(parts) >= 14:
|
|
||||||
major = parts[0]
|
|
||||||
minor = parts[1]
|
|
||||||
name = parts[2]
|
|
||||||
|
|
||||||
# Only physical devices and md/dm
|
|
||||||
if not re.match(r'^(sd[a-z]+$|nvme\d+n\d+$|md\d+$|dm-\d+$)', name):
|
|
||||||
continue
|
|
||||||
# Skip partitions
|
|
||||||
if re.match(r'^(sd[a-z]+\d+|nvme\d+n\d+p\d+)$', name):
|
|
||||||
continue
|
|
||||||
|
|
||||||
reads = int(parts[3])
|
|
||||||
read_sectors = int(parts[5])
|
|
||||||
writes = int(parts[7])
|
|
||||||
write_sectors = int(parts[9])
|
|
||||||
|
|
||||||
devices[name] = {
|
|
||||||
'reads': reads,
|
|
||||||
'writes': writes,
|
|
||||||
'read_bytes': read_sectors * 512,
|
|
||||||
'write_bytes': write_sectors * 512,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate I/O speed from history
|
|
||||||
now = time.time()
|
|
||||||
prev_disk = {}
|
|
||||||
if DISK_HISTORY:
|
|
||||||
prev_disk = DISK_HISTORY[-1]
|
|
||||||
|
|
||||||
for name, data in devices.items():
|
|
||||||
if name in prev_disk:
|
|
||||||
dt = now - prev_disk.get('_ts', now)
|
|
||||||
if dt > 0:
|
|
||||||
data['read_speed'] = round((data['read_bytes'] - prev_disk[name]['read_bytes']) / dt)
|
|
||||||
data['write_speed'] = round((data['write_bytes'] - prev_disk[name]['write_bytes']) / dt)
|
|
||||||
else:
|
|
||||||
data['read_speed'] = 0
|
|
||||||
data['write_speed'] = 0
|
|
||||||
else:
|
|
||||||
data['read_speed'] = 0
|
|
||||||
data['write_speed'] = 0
|
|
||||||
|
|
||||||
current_disk = {name: {'read_bytes': d['read_bytes'], 'write_bytes': d['write_bytes']}
|
|
||||||
for name, d in devices.items()}
|
|
||||||
current_disk['_ts'] = now
|
|
||||||
DISK_HISTORY.append(current_disk)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
stats['devices'] = devices
|
|
||||||
stats['drives'] = get_drive_health() # physical drive health
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def get_processes():
|
|
||||||
"""Process list sorted by CPU usage, with user and start time."""
|
|
||||||
procs = []
|
|
||||||
try:
|
|
||||||
result = os.popen(
|
|
||||||
"ps -eo pid,user,pcpu,pmem,start_time,comm --sort=-pcpu --no-headers 2>/dev/null"
|
|
||||||
).read()
|
|
||||||
for line in result.strip().split('\n'):
|
|
||||||
parts = line.strip().split(None, 5)
|
|
||||||
if len(parts) >= 6:
|
|
||||||
procs.append({
|
|
||||||
'pid': parts[0],
|
|
||||||
'user': parts[1],
|
|
||||||
'cpu': parts[2],
|
|
||||||
'mem': parts[3],
|
|
||||||
'start': parts[4],
|
|
||||||
'cmd': parts[5],
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return procs[:20] # top 20
|
|
||||||
|
|
||||||
|
|
||||||
EXPORT_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'export_config.json')
|
|
||||||
|
|
||||||
|
|
||||||
def read_export_config():
|
|
||||||
"""Read export path config."""
|
|
||||||
try:
|
|
||||||
with open(EXPORT_CONFIG_PATH) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
return {'path': '/var/exports'}
|
|
||||||
|
|
||||||
|
|
||||||
def write_export_config(config):
|
|
||||||
"""Write export path config."""
|
|
||||||
with open(EXPORT_CONFIG_PATH, 'w') as f:
|
|
||||||
json.dump(config, f)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return app.send_static_file('index.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/all')
|
|
||||||
def api_all():
|
|
||||||
"""All stats in one shot."""
|
|
||||||
return jsonify({
|
|
||||||
'cpu': get_cpu_stats(),
|
|
||||||
'mem': get_mem_stats(),
|
|
||||||
'net': get_net_stats(),
|
|
||||||
'disk': get_disk_stats(),
|
|
||||||
'traffic': get_traffic_stats(),
|
|
||||||
'procs': get_processes(),
|
|
||||||
'apps': get_combined_ranking('total', 12),
|
|
||||||
'ts': time.time(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/export/config', methods=['GET', 'POST'])
|
|
||||||
def export_config():
|
|
||||||
"""Get or set export path."""
|
|
||||||
if request.method == 'POST':
|
|
||||||
data = request.get_json() or {}
|
|
||||||
new_path = data.get('path', '/var/exports')
|
|
||||||
if not os.path.isabs(new_path):
|
|
||||||
return jsonify({'ok': False, 'error': 'Path must be absolute'}), 400
|
|
||||||
write_export_config({'path': new_path})
|
|
||||||
return jsonify({'ok': True, 'path': new_path})
|
|
||||||
config = read_export_config()
|
|
||||||
return jsonify({'ok': True, 'path': config['path']})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/export')
|
|
||||||
def api_export():
|
|
||||||
"""Export all history data to a file on the configured path."""
|
|
||||||
config = read_export_config()
|
|
||||||
base_dir = config['path']
|
|
||||||
|
|
||||||
# Ensure export dir exists
|
|
||||||
try:
|
|
||||||
os.makedirs(base_dir, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
return jsonify({'ok': False, 'error': f'Cannot create directory {base_dir}: {e}'}), 500
|
|
||||||
|
|
||||||
# Collect current snapshots
|
|
||||||
cpu = get_cpu_stats()
|
|
||||||
mem = get_mem_stats()
|
|
||||||
net = get_net_stats()
|
|
||||||
disk = get_disk_stats()
|
|
||||||
traffic = get_traffic_stats()
|
|
||||||
procs = get_processes()
|
|
||||||
apps = get_combined_ranking('total', 50)
|
|
||||||
|
|
||||||
# Collect history data from deques
|
|
||||||
net_history = list(NET_HISTORY)
|
|
||||||
disk_history = list(DISK_HISTORY)
|
|
||||||
cpu_history = {k: list(v) for k, v in CPU_HISTORY.items()}
|
|
||||||
|
|
||||||
# Collect app usage DB data
|
|
||||||
app_raw = []
|
|
||||||
app_daily = []
|
|
||||||
app_weekly = []
|
|
||||||
app_monthly = []
|
|
||||||
app_yearly = []
|
|
||||||
try:
|
|
||||||
usage_db = app_usage_tracker.DB_PATH
|
|
||||||
if os.path.exists(usage_db):
|
|
||||||
conn = sqlite3.connect(usage_db)
|
|
||||||
c = conn.cursor()
|
|
||||||
try:
|
|
||||||
c.execute('SELECT ts, app, cpu_pct, mem_mb, net_rx, net_tx FROM snapshots ORDER BY ts DESC LIMIT 10000')
|
|
||||||
app_raw = [{'ts': r[0], 'app': r[1], 'cpu_pct': r[2], 'mem_mb': r[3], 'net_rx': r[4], 'net_tx': r[5]} for r in c.fetchall()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for table, key_col in [('daily_rollup', 'day'), ('weekly_rollup', 'week'),
|
|
||||||
('monthly_rollup', 'month'), ('yearly_rollup', 'year')]:
|
|
||||||
try:
|
|
||||||
c.execute(f'SELECT {key_col}, app, cpu_avg, mem_avg, net_rx, net_tx FROM {table}')
|
|
||||||
rows = [{'period': r[0], 'app': r[1], 'cpu_avg': r[2], 'mem_avg': r[3], 'net_rx': r[4], 'net_tx': r[5]} for r in c.fetchall()]
|
|
||||||
if table == 'daily_rollup':
|
|
||||||
app_daily = rows
|
|
||||||
elif table == 'weekly_rollup':
|
|
||||||
app_weekly = rows
|
|
||||||
elif table == 'monthly_rollup':
|
|
||||||
app_monthly = rows
|
|
||||||
elif table == 'yearly_rollup':
|
|
||||||
app_yearly = rows
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Collect traffic DB data
|
|
||||||
traffic_raw = []
|
|
||||||
traffic_daily = []
|
|
||||||
traffic_monthly = []
|
|
||||||
try:
|
|
||||||
t_db = traffic_tracker.DB_PATH
|
|
||||||
if os.path.exists(t_db):
|
|
||||||
conn = sqlite3.connect(t_db)
|
|
||||||
c = conn.cursor()
|
|
||||||
try:
|
|
||||||
c.execute('SELECT id, rx_base, tx_base FROM traffic_base')
|
|
||||||
traffic_raw = [{'id': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
c.execute('SELECT date, rx_base, tx_base FROM traffic_daily')
|
|
||||||
traffic_daily = [{'date': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
c.execute('SELECT month, rx_base, tx_base FROM traffic_monthly')
|
|
||||||
traffic_monthly = [{'month': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
export_data = {
|
|
||||||
'export_time': time.time(),
|
|
||||||
'export_time_human': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
'machine': {
|
|
||||||
'hostname': os.uname().nodename,
|
|
||||||
'os': f"{os.uname().sysname} {os.uname().release}",
|
|
||||||
},
|
|
||||||
'current': {
|
|
||||||
'cpu': cpu,
|
|
||||||
'mem': mem,
|
|
||||||
'net': net,
|
|
||||||
'disk': disk,
|
|
||||||
},
|
|
||||||
'traffic': traffic,
|
|
||||||
'processes': procs,
|
|
||||||
'app_ranking': apps,
|
|
||||||
'history': {
|
|
||||||
'net': net_history,
|
|
||||||
'disk_io': disk_history,
|
|
||||||
'cpu_freq': cpu_history,
|
|
||||||
},
|
|
||||||
'app_usage_data': {
|
|
||||||
'raw_snapshots': app_raw,
|
|
||||||
'daily_rollup': app_daily,
|
|
||||||
'weekly_rollup': app_weekly,
|
|
||||||
'monthly_rollup': app_monthly,
|
|
||||||
'yearly_rollup': app_yearly,
|
|
||||||
},
|
|
||||||
'traffic_archive': {
|
|
||||||
'base': traffic_raw,
|
|
||||||
'daily': traffic_daily,
|
|
||||||
'monthly': traffic_monthly,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write to file
|
|
||||||
ts_human = datetime.now().strftime('%Y-%m-%d-%H%M%S')
|
|
||||||
filename = f'fnmon-export-{ts_human}.json'
|
|
||||||
filepath = os.path.join(base_dir, filename)
|
|
||||||
try:
|
|
||||||
with open(filepath, 'w') as f:
|
|
||||||
json.dump(export_data, f, ensure_ascii=False, indent=2)
|
|
||||||
return jsonify({
|
|
||||||
'ok': True,
|
|
||||||
'filepath': filepath,
|
|
||||||
'size_bytes': os.path.getsize(filepath),
|
|
||||||
'records': {
|
|
||||||
'app_raw': len(app_raw),
|
|
||||||
'net_history': len(net_history),
|
|
||||||
'disk_history': len(disk_history),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except OSError as e:
|
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/stream')
|
|
||||||
def stream():
|
|
||||||
"""SSE — pushes all stats every INTERVAL seconds."""
|
|
||||||
def generate():
|
|
||||||
while True:
|
|
||||||
collect_snapshot() # periodic snapshot
|
|
||||||
data = {
|
|
||||||
'cpu': get_cpu_stats(),
|
|
||||||
'mem': get_mem_stats(),
|
|
||||||
'net': get_net_stats(),
|
|
||||||
'disk': get_disk_stats(),
|
|
||||||
'traffic': get_traffic_stats(),
|
|
||||||
'procs': get_processes(),
|
|
||||||
'apps': get_combined_ranking('total', 12),
|
|
||||||
'ts': time.time(),
|
|
||||||
}
|
|
||||||
yield f'data: {json.dumps(data)}\n\n'
|
|
||||||
time.sleep(UPDATE_INTERVAL)
|
|
||||||
return Response(generate(), mimetype='text/event-stream',
|
|
||||||
headers={
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/apps/<period>')
|
|
||||||
def api_apps(period):
|
|
||||||
"""App ranking by period: total, day, week, month, year."""
|
|
||||||
return jsonify(get_combined_ranking(period, 12))
|
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def add_headers(response):
|
|
||||||
response.headers['X-Frame-Options'] = 'ALLOWALL'
|
|
||||||
response.headers['Content-Security-Policy'] = "frame-ancestors *"
|
|
||||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
|
||||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print('Starting fnOS System Monitor on http://0.0.0.0:5000')
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>飞牛资源管理器</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
html,body{height:100%;overflow:hidden}
|
|
||||||
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:#333;font-size:14px;display:flex;flex-direction:column}
|
|
||||||
.scrollarea{flex:1;overflow:auto;min-height:0}
|
|
||||||
.tabs{display:flex;border-bottom:1px solid #eee;padding:0 16px}
|
|
||||||
.tab{padding:12px 18px;cursor:pointer;color:#333;font-size:15px;position:relative}
|
|
||||||
.tab.active{color:#20a53a}
|
|
||||||
.tab.active:after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;background:#20a53a}
|
|
||||||
.card{border:1px solid #eee;border-radius:4px;margin:14px 16px;display:flex;text-align:center}
|
|
||||||
.stat{flex:1;padding:16px 8px}
|
|
||||||
.stat+.stat{border-left:1px solid #f2f2f2}
|
|
||||||
.stat .k{font-weight:700;margin-bottom:8px;font-size:14px}
|
|
||||||
.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}
|
|
||||||
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}
|
|
||||||
col.c-name{width:150px}col.c-num{width:90px}col.c-pid{width:80px}col.c-user{width:110px}col.c-st{width:60px}col.c-op{width:60px}
|
|
||||||
.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}
|
|
||||||
.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}
|
|
||||||
.diskrow{margin:14px 16px;border:1px solid #eee;border-radius:4px;padding:14px 16px}
|
|
||||||
.diskrow .t{font-weight:700;margin-bottom:6px}
|
|
||||||
.mut{color:#999;font-size:12px}
|
|
||||||
.toolbar{margin:0 16px 10px;color:#999;font-size:12px;display:flex;justify-content:space-between}
|
|
||||||
button.refresh{background:#20a53a;color:#fff;border:none;border-radius:3px;padding:5px 14px;cursor:pointer}
|
|
||||||
.cpuinfo{margin:0 16px 12px;border:1px solid #eee;border-radius:4px;padding:12px 16px;font-size:13px;line-height:1.9;color:#333}
|
|
||||||
.cpuinfo .model{font-weight:700;font-size:14px;margin-bottom:4px}
|
|
||||||
.cpuinfo .mut{color:#999;font-size:12px}
|
|
||||||
.corebar{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap}
|
|
||||||
.corecell{flex:1;min-width:44px;text-align:center}
|
|
||||||
.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}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="tabs">
|
|
||||||
<div class="tab active" data-t="cpu">CPU</div>
|
|
||||||
<div class="tab" data-t="mem">内存</div>
|
|
||||||
<div class="tab" data-t="disk">磁盘</div>
|
|
||||||
<div class="tab" data-t="net">网络</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" id="summary"></div>
|
|
||||||
<div class="cpuinfo" id="cpuinfo" style="display:none"></div>
|
|
||||||
<div class="cpuinfo" id="meminfo" style="display:none"></div>
|
|
||||||
<div class="cpuinfo" id="diskinfo" style="display:none"></div>
|
|
||||||
<div class="cpuinfo" id="netinfo" 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>
|
|
||||||
<script>
|
|
||||||
let cur='cpu';
|
|
||||||
let sortState={key:null,dir:-1};
|
|
||||||
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)});
|
|
||||||
function fmtB(b){if(b==null)return '-';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<4){b/=1024;i++}return b.toFixed(b>=100?0:1)+u[i]}
|
|
||||||
function fmtR(b){return fmtB(b)+'/s'}
|
|
||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</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 kill(pid,name){if(!confirm('结束进程 '+name+' (PID '+pid+') ?'))return;const r=await fetch('/api/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid})});const j=await r.json();alert(j.ok?'已发送结束信号':('失败: '+j.error));load(true)}
|
|
||||||
function cmpVal(p,key){
|
|
||||||
if(key==='name')return String(p.name||'');
|
|
||||||
if(key==='desc')return String(p.desc||'');
|
|
||||||
if(key==='user')return String(p.user||'');
|
|
||||||
if(key==='status')return String(p.status||'');
|
|
||||||
if(key==='cpu')return Number(p.cpu||0);
|
|
||||||
if(key==='threads')return Number(p.threads||0);
|
|
||||||
if(key==='mem')return Number(p.mem||0);
|
|
||||||
if(key==='rss')return Number(p.rss||0);
|
|
||||||
if(key==='uss')return Number(p.uss||0);
|
|
||||||
if(key==='swap')return Number(p.swap||0);
|
|
||||||
if(key==='pid')return Number(p.pid||0);
|
|
||||||
if(key==='io_r')return Number(p.io_r||0);
|
|
||||||
if(key==='io_w')return Number(p.io_w||0);
|
|
||||||
if(key==='conns')return Number(p.conns||0);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function renderBody(){
|
|
||||||
const B=document.getElementById('tbody');
|
|
||||||
if(!curData||!curData.items)return;
|
|
||||||
let items=curData.items.slice();
|
|
||||||
if(sortState.key){
|
|
||||||
items.sort((a,b)=>{
|
|
||||||
const va=cmpVal(a,sortState.key),vb=cmpVal(b,sortState.key);
|
|
||||||
if(typeof va==='string')return sortState.dir*va.localeCompare(vb);
|
|
||||||
return sortState.dir*(va-vb);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if(cur==='cpu') B.innerHTML=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') B.innerHTML=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('');
|
|
||||||
else if(cur==='disk') B.innerHTML=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('');
|
|
||||||
else if(cur==='net') B.innerHTML=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('');
|
|
||||||
}
|
|
||||||
function arrow(k){if(sortState.key!==k)return ' ▼';return sortState.dir>0?' ▲':' ▼'}
|
|
||||||
function renderHeader(){
|
|
||||||
const H=document.getElementById('thead');
|
|
||||||
if(cur==='cpu') H.innerHTML=`<tr><th onclick="sortBy('name')">进程名${arrow('name')}</th><th onclick="sortBy('cpu')">CPU使用率(%)${arrow('cpu')}</th><th onclick="sortBy('threads')">线程数${arrow('threads')}</th><th onclick="sortBy('pid')">PID${arrow('pid')}</th><th onclick="sortBy('desc')">描述${arrow('desc')}</th><th onclick="sortBy('user')">用户${arrow('user')}</th><th onclick="sortBy('status')">状态${arrow('status')}</th><th>操作</th></tr>`;
|
|
||||||
else if(cur==='mem') H.innerHTML=`<tr><th onclick="sortBy('name')">进程名${arrow('name')}</th><th onclick="sortBy('rss')">RSS${arrow('rss')}</th><th onclick="sortBy('uss')">USS${arrow('uss')}</th><th onclick="sortBy('swap')">SWAP${arrow('swap')}</th><th onclick="sortBy('pid')">PID${arrow('pid')}</th><th onclick="sortBy('desc')">描述${arrow('desc')}</th><th onclick="sortBy('user')">用户${arrow('user')}</th><th onclick="sortBy('status')">状态${arrow('status')}</th><th>操作</th></tr>`;
|
|
||||||
else if(cur==='disk') H.innerHTML=`<tr><th onclick="sortBy('name')">进程名${arrow('name')}</th><th onclick="sortBy('io_r')">读累计${arrow('io_r')}</th><th onclick="sortBy('io_w')">写累计${arrow('io_w')}</th><th onclick="sortBy('pid')">PID${arrow('pid')}</th><th onclick="sortBy('user')">用户${arrow('user')}</th><th onclick="sortBy('status')">状态${arrow('status')}</th><th>操作</th></tr>`;
|
|
||||||
else if(cur==='net') H.innerHTML=`<tr><th onclick="sortBy('name')">进程名${arrow('name')}</th><th onclick="sortBy('conns')">连接数${arrow('conns')}</th><th onclick="sortBy('pid')">PID${arrow('pid')}</th><th onclick="sortBy('desc')">描述${arrow('desc')}</th><th onclick="sortBy('user')">用户${arrow('user')}</th><th onclick="sortBy('status')">状态${arrow('status')}</th><th>操作</th></tr>`;
|
|
||||||
}
|
|
||||||
function sortBy(key){
|
|
||||||
if(sortState.key===key) sortState.dir*=-1; else {sortState.key=key;sortState.dir=-1;}
|
|
||||||
renderHeader(); renderBody();
|
|
||||||
}
|
|
||||||
async function load(manual){
|
|
||||||
document.getElementById('disks').innerHTML='';
|
|
||||||
document.getElementById('netextra').innerHTML='';
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/'+cur,{cache:'no-store'});const j=await r.json();
|
|
||||||
curData=j;
|
|
||||||
document.getElementById('ts').textContent='更新于 '+new Date().toLocaleTimeString();
|
|
||||||
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||[];
|
|
||||||
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('sub').textContent=`共 ${j.total} 个进程,显示占用最高的前 ${j.items.length} 个(进程CPU为单进程占用,单核满=100%,16核满=1600%,进程之和÷16≈整机占用)`;
|
|
||||||
renderHeader();
|
|
||||||
B.innerHTML=j.items.map(p=>`<tr><td><a class="proc">${esc(p.name)}</a></td><td class="num">${p.cpu}</td><td class="num">${p.threads}</td><td class="num">${p.pid}</td><td>${esc(p.desc)}</td><td>${esc(p.user)}</td><td class="${p.active?'st-run':'st-sleep'}">${p.status}</td><td><button class="kill" onclick="kill(${p.pid},'${esc(p.name)}')">结束</button></td></tr>`).join('');
|
|
||||||
}else if(cur==='mem'){
|
|
||||||
S.innerHTML=`<div class="stat"><div class="k">内存占用</div><div class="v green">${j.percent}%</div></div><div class="stat"><div class="k">已用 / 总共</div><div class="v num">${fmtB(j.used)} / ${fmtB(j.total)}</div></div><div class="stat"><div class="k">可用 / Swap</div><div class="v num">${fmtB(j.available)} / ${fmtB(j.swap_used)}/${fmtB(j.swap_total)} (Swap ${j.swap_percent||0}%)</div></div><div class="stat"><div class="k">操作</div><div class="v"><button class="kill" onclick="dropMem()">回收可回收内存</button></div></div>`;
|
|
||||||
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>`;
|
|
||||||
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('');
|
|
||||||
}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('');
|
|
||||||
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>`;
|
|
||||||
document.getElementById('diskinfo').style.display='';
|
|
||||||
document.getElementById('diskinfo').innerHTML=`<div class="model">共 ${DS.length} 块物理盘 · ${AR.length} 组阵列 <span class="mut">SMART 快照 ${esc(W.snapshot||'')}(温度/通电时间为快照值)</span></div>`
|
|
||||||
+(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>`:'');
|
|
||||||
}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('');
|
|
||||||
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>`;
|
|
||||||
const spd=n=>(n&&+n>0?(+n>=1000?((+n/1000)+' Gb/s'):(n+' Mb/s')):'-');
|
|
||||||
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>`:'');
|
|
||||||
}
|
|
||||||
}catch(e){if(manual)alert('加载失败: '+e)}
|
|
||||||
}
|
|
||||||
load(true);setInterval(()=>load(false),3000);
|
|
||||||
</script>
|
|
||||||
<footer style="text-align:center;padding:8px;font-size:12px;color:#999;border-top:1px solid #eee">
|
|
||||||
<a href="https://nav.88531.cn" target="_blank" rel="noopener">资享导航站</a> ·
|
|
||||||
<a href="http://192.168.68.19:3200/jinghui/fnmon" target="_blank" rel="noopener">开源地址 (Gitea)</a>
|
|
||||||
</footer>
|
|
||||||
</html>
|
|
||||||
@@ -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|
|
|
||||||
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After 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')
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
appname: fnos-resmon
|
|
||||||
version: 1.2.1
|
|
||||||
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 运行不修改系统文件
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Flask
|
|
||||||
psutil
|
|
||||||
@@ -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))
|
|
||||||