init: fnmon - fnOS 系统监控面板

This commit is contained in:
jinghui committed 2026-06-15 23:01:08 +08:00
commit 9717cffb79
12 files changed
+3096

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.db
*.fpk
export_config.json
.env
.gitignore
+27
View File
@@ -0,0 +1,27 @@
# 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"]
+192
View File
@@ -0,0 +1,192 @@
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|
+740
View File
@@ -0,0 +1,740 @@
#!/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
# Benchmark data (hardcoded known scores for this CPU)
stats['bench'] = {
'passmark_single': 1850,
'passmark_multi': 5500,
'cinebench_r23_single': 850,
'cinebench_r23_multi': 3200,
'geekbench6_single': 1100,
'geekbench6_multi': 3500,
}
# Comparison: nearby CPUs
stats['compare'] = [
{'name': 'N100', 'score': 4200, 'tdp': 6, 'note': '入门NAS神U'},
{'name': 'J4125', 'score': 2800, 'tdp': 10, 'note': '极低功耗'},
{'name': 'i3-9100T ←你', 'score': 5500, 'tdp': 35, 'note': '35W均衡'},
{'name': 'i5-8400T', 'score': 8500, 'tdp': 35, 'note': '35W升级首选'},
{'name': 'i5-10500T', 'score': 10800, 'tdp': 35, 'note': '6C/12T'},
{'name': 'i3-12100', 'score': 12400, 'tdp': 60, 'note': '质变之选'},
]
# 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)
+322
View File
@@ -0,0 +1,322 @@
#!/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']}")
+14
View File
@@ -0,0 +1,14 @@
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|
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Generate a self-contained fpk for distribution — includes app code + static assets."""
import os, re, struct, zlib, tarfile, io, json, hashlib, time as tmod
OUTDIR = '/vol2/1000/HD2/fpk'
APP_NAME = 'fnmon'
APP_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(OUTDIR, exist_ok=True)
# === Auto-version ===
latest_ver = '0.0.0'
# Also account for legacy cpu-monitor-v* fpks
for f in os.listdir(OUTDIR):
m = re.match(r'(?:fnmon|cpu-monitor)-v(\d+\.\d+\.\d+)\.fpk', f)
if m:
v = tuple(int(x) for x in m.group(1).split('.'))
if v > tuple(int(x) for x in latest_ver.split('.')):
latest_ver = m.group(1)
v_parts = [int(x) for x in latest_ver.split('.')]
v_parts[2] += 1
NEW_VER = '.'.join(str(x) for x in v_parts)
OUTPATH = f'{OUTDIR}/fnmon-v{NEW_VER}.fpk'
now_ts = int(tmod.time())
def add_file(tar, path, content=None, filepath=None, mode=0o644):
"""Add a file to the tar archive."""
info = tarfile.TarInfo(name=f'{APP_NAME}/{path}')
if filepath:
with open(filepath, 'rb') as f:
data = f.read()
elif isinstance(content, bytes):
data = content
else:
data = content.encode('utf-8')
info.size = len(data)
info.mode = mode
info.mtime = now_ts
info.uid = info.gid = 0
info.uname = 'root'
info.gname = 'root'
tar.addfile(info, io.BytesIO(data))
# === Build manifest ===
manifest_content = f'''appname = {APP_NAME}
version = {NEW_VER}
display_name = fnmon - 系统监视器
desc = 实时系统监控面板 - CPU/内存/网络/磁盘/进程 全能监控
platform = all
source = thirdparty
maintainer = Hermes
maintainer_url = https://github.com/nousresearch/hermes-agent
distributor = Community
os_min_version = 0.8.1
desktop_uidir = ui
desktop_applaunchname = {APP_NAME}.Application
service_port = 5000
checkport = true
ctl_stop = true
'''
manifest_checksum = hashlib.md5(manifest_content.encode('utf-8')).hexdigest()
manifest_full = manifest_content + f'checksum = {manifest_checksum}\n'
# === Build tar ===
with tarfile.open(OUTPATH, 'w:gz') as tar:
add_file(tar, 'manifest', manifest_full)
# Config
add_file(tar, 'config/privilege', json.dumps({
'defaults': {'run-as': 'root'},
}, indent=2))
add_file(tar, 'config/resource', '{\n "systemd-unit": {}\n}\n')
# Lifecycle scripts
scripts = {
'install_init': '#!/bin/bash\nexit 0\n',
'install_callback': f'''#!/bin/bash
# Create and enable systemd service
cat > /etc/systemd/system/{APP_NAME}.service << 'SERVEOF'
[Unit]
Description=fnmon - 系统监视器
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/apps/{APP_NAME}
ExecStart=/usr/bin/python3 /var/apps/{APP_NAME}/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVEOF
systemctl daemon-reload
systemctl enable {APP_NAME}.service
systemctl start {APP_NAME}.service
exit 0
''',
'uninstall_init': f'#!/bin/bash\nsystemctl stop {APP_NAME}.service 2>/dev/null\nexit 0\n',
'uninstall_callback': f'''#!/bin/bash
systemctl disable {APP_NAME}.service 2>/dev/null
rm -f /etc/systemd/system/{APP_NAME}.service
systemctl daemon-reload
exit 0
''',
'upgrade_init': '#!/bin/bash\nexit 0\n',
'upgrade_callback': f'#!/bin/bash\nsystemctl restart {APP_NAME}.service 2>/dev/null\nexit 0\n',
'config_init': '#!/bin/bash\nexit 0\n',
'config_callback': '#!/bin/bash\nexit 0\n',
}
for name, content in scripts.items():
add_file(tar, f'cmd/{name}', content, mode=0o755)
# Main lifecycle
add_file(tar, 'cmd/main', f'''#!/bin/bash
case $1 in
start)
systemctl start {APP_NAME}.service 2>/dev/null
sleep 2
curl -s -o /dev/null http://localhost:5000 && exit 0
exit 1
;;
stop) exit 0 ;;
status)
curl -s -o /dev/null http://localhost:5000 && exit 0 || exit 3
;;
*) exit 1 ;;
esac
''', mode=0o755)
# === Bundle app code ===
add_file(tar, 'app.py', filepath=os.path.join(APP_DIR, 'app.py'), mode=0o755)
add_file(tar, 'traffic_tracker.py', filepath=os.path.join(APP_DIR, 'traffic_tracker.py'), mode=0o644)
add_file(tar, 'app_usage_tracker.py', filepath=os.path.join(APP_DIR, 'app_usage_tracker.py'), mode=0o644)
add_file(tar, 'static/index.html', filepath=os.path.join(APP_DIR, 'static/index.html'), mode=0o644)
# UI config
add_file(tar, 'ui/index.json', json.dumps({
APP_NAME: {
'application': {
'type': 'url',
'protocol': 'http',
'port': 5000,
'url': 'http://${host}:5000',
'title': 'fnmon - 系统监视器',
'description': '实时系统监控面板'
}
}
}, ensure_ascii=False, indent=2))
# Icons
for fname, fpk_name in [(f'{APP_NAME}-icon-128.png', 'ICON.PNG'),
(f'{APP_NAME}-icon-256.png', 'ICON_256.PNG')]:
ipath = os.path.join(OUTDIR, fname)
if os.path.exists(ipath):
add_file(tar, fpk_name, filepath=ipath)
else:
# Fallback: simple blue icon
add_file(tar, fpk_name, _gen_png(128 if '128' in fname else 256, (80, 120, 255)))
# === Done ===
size = os.path.getsize(OUTPATH)
print(f'✅ fpk created: {OUTPATH} ({size} bytes)')
print(f' version: v{NEW_VER} (previous: v{latest_ver})')
print(f' checksum: {manifest_checksum}')
print(f' bundled: app.py + frontend + trackers')
with tarfile.open(OUTPATH) as t:
for m in t.getmembers():
print(f' {m.name}: {m.size}B')
def _gen_png(size, color):
"""Quick PNG generator (fallback if no icon)."""
r, g, b = color
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
hdr = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0))
raw = b''
for y in range(size):
raw += b'\x00'
for x in range(size):
d = ((x-size/2)**2 + (y-size/2)**2) ** 0.5
rad = size/2 - 4
if d < rad:
t = d / rad
raw += struct.pack('BBB', int(r-(r-50)*t), int(g-(g-100)*t), int(b-(b-200)*t))
else:
raw += b'\x00\x00\x00'
return hdr + ihdr + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
+83
View File
@@ -0,0 +1,83 @@
#!/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')
+4
View File
@@ -0,0 +1,4 @@
Flask
psutil
Flask-SSE
pandas
+6
View File
@@ -0,0 +1,6 @@
1|#!/bin/bash
2|# /vol1/1000/HD1/APP/hermes/awork/fnmon/start.sh
3|# Start the CPU Monitor service
4|cd "$(dirname "$0")"
5|exec python3 app.py
6|
+1274
View File
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
#!/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))