diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ee7a440..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM python:3.11-slim -WORKDIR /app -RUN pip install --no-cache-dir flask psutil -i https://mirrors.aliyun.com/pypi/simple/ -COPY app.py . -COPY hw_mem.json . -COPY hw_disk.json . -COPY hw_net.json . -COPY public ./public -ENV PORT=7031 -EXPOSE 7031 -CMD ["python", "app.py"] diff --git a/ICON.PNG b/ICON.PNG deleted file mode 100644 index c221ba6..0000000 Binary files a/ICON.PNG and /dev/null differ diff --git a/ICON_256.PNG b/ICON_256.PNG deleted file mode 100644 index d5c1bd4..0000000 Binary files a/ICON_256.PNG and /dev/null differ diff --git a/README-FORUM.md b/README-FORUM.md deleted file mode 100644 index 55b05b0..0000000 --- a/README-FORUM.md +++ /dev/null @@ -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| \ No newline at end of file diff --git a/app.py b/app.py deleted file mode 100644 index 908922f..0000000 --- a/app.py +++ /dev/null @@ -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/') -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) diff --git a/app/.DS_Store b/app/.DS_Store deleted file mode 100644 index 982a645..0000000 Binary files a/app/.DS_Store and /dev/null differ diff --git a/app/app.py b/app/app.py deleted file mode 100644 index 24fd3d8..0000000 --- a/app/app.py +++ /dev/null @@ -1,827 +0,0 @@ -from flask import Flask, jsonify, request, send_from_directory -import psutil, os, time, signal, threading, subprocess, platform, glob, json, struct -try: - psutil.PROCFS_PATH = os.environ.get("PROCFS_PATH", "/proc") -except Exception: - pass - -PROCFS = os.environ.get("PROCFS_PATH", "/host/proc") -if os.path.isdir(PROCFS): - psutil.PROCFS_PATH = PROCFS - -app = Flask(__name__, static_folder="public", static_url_path="") -PORT = int(os.environ.get("PORT", "7031")) - -# ---------- background sampler ---------- -cache = {"procs": [], "cpu": 0.0, "load": [0, 0, 0], "ts": 0} -net_prev = {} -net_rate = {} - -def fmt_status(s): - m = { - psutil.STATUS_RUNNING: ("活动", 1), - psutil.STATUS_SLEEPING: ("睡眠", 0), - psutil.STATUS_DISK_SLEEP: ("睡眠", 0), - psutil.STATUS_STOPPED: ("停止", 0), - psutil.STATUS_ZOMBIE: ("僵尸", 0), - } - return m.get(s, ("睡眠", 0)) - -_uid_cache = {"map": {}, "ts": 0} - -def uid_name(uid_str): - # 容器内 /etc/passwd 没有宿主用户,uid 会显示成数字,用宿主 passwd 还原 - if not uid_str or not uid_str.isdigit(): - return uid_str - now = time.time() - if now - _uid_cache["ts"] > 300: - m = {} - try: - with open("/host/etc/passwd") as f: - for line in f: - p = line.split(":") - if len(p) > 3: - try: - m[p[2]] = p[0] - except Exception: - pass - except Exception: - pass - _uid_cache["map"] = m - _uid_cache["ts"] = now - return _uid_cache["map"].get(uid_str, uid_str) - -def sample_procs(): - # init cpu percent calculation - for p in psutil.process_iter(): - try: - p.cpu_percent(None) - except Exception: - pass - time.sleep(1.0) - procs = [] - try: - cpu_total = psutil.cpu_percent(interval=None) - except Exception: - cpu_total = 0 - try: - load = list(os.getloadavg()) - except Exception: - load = [0, 0, 0] - for p in psutil.process_iter(["pid", "name", "username", "status", "num_threads", "cmdline"]): - try: - with p.oneshot(): - cpu = p.cpu_percent(None) - mem = p.memory_percent() - try: - rss = p.memory_info().rss - except Exception: - rss = 0 - uss = 0 - swap = 0 - try: - mfi = p.memory_full_info() - uss = getattr(mfi, "uss", 0) or 0 - swap = getattr(mfi, "swap", 0) or 0 - except Exception: - pass - try: - io = p.io_counters() - rd, wr = io.read_bytes, io.write_bytes - except Exception: - rd, wr = 0, 0 - try: - conns = len(p.net_connections()) - except Exception: - conns = 0 - info = p.info - name = info.get("name") or "" - cmd = " ".join(info.get("cmdline") or [])[:120] - desc = cmd if cmd else name - st, act = fmt_status(info.get("status")) - if (not act) and (cpu or 0) > 1.0: - st, act = "活动", 1 - procs.append({ - "name": name[:40], "pid": info["pid"], - "cpu": round(cpu or 0, 2), "mem": round(mem or 0, 2), - "rss": rss, "uss": uss, "swap": swap, "threads": info.get("num_threads") or 0, - "user": uid_name(str(info.get("username") or ""))[:20], - "status": st, "active": act, - "desc": desc[:80], "io_r": rd, "io_w": wr, "conns": conns, - }) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - except Exception: - continue - procs.sort(key=lambda x: x["cpu"], reverse=True) - cache["procs"] = procs - cache["cpu"] = round(cpu_total, 1) - cache["load"] = [round(x, 2) for x in load] - cache["ts"] = time.time() - -def sampler_loop(): - while True: - try: - sample_procs() - except Exception: - pass - time.sleep(2) - -threading.Thread(target=sampler_loop, daemon=True).start() -# warm up immediately in background; serve stale until ready - -@app.route("/") -def index(): - return send_from_directory("public", "index.html") - -@app.route("/api/cpu") -def api_cpu(): - active = sum(1 for p in cache["procs"] if p["active"]) - return jsonify({"cpu": cache["cpu"], "load": cache["load"], - "active": active, "total": len(cache["procs"]), - "info": get_cpu_info(), "percpu": get_percpu(), - "items": cache["procs"][:100]}) - -_cpu_static = {} - -def _parse_host_cpuinfo(): - info = {} - try: - with open("/host/proc/cpuinfo") as f: - txt = f.read() - except Exception: - try: - with open("/proc/cpuinfo") as f: - txt = f.read() - except Exception: - return info - # 取第一个 processor 段的 model name / cache size / cpu MHz - for line in txt.splitlines(): - if ":" not in line: - continue - k, v = line.split(":", 1) - k, v = k.strip(), v.strip() - if k == "model name" and "model" not in info: - info["model"] = v - elif k == "cache size" and "cache" not in info: - info["cache"] = v - return info - -def _parse_lscpu(): - out = {} - try: - txt = subprocess.run(["lscpu"], capture_output=True, text=True, - timeout=5).stdout - except Exception: - return out - for line in txt.splitlines(): - if ":" not in line and ":" not in line: - continue - line = line.replace(":", ":") - k, v = line.split(":", 1) - k, v = k.strip(), v.strip() - out[k] = v - return out - -def get_cpu_info(): - global _cpu_static - if not _cpu_static: - ci = _parse_host_cpuinfo() - lc = _parse_lscpu() - def pick(*keys): - for k in keys: - if k in lc and lc[k]: - return lc[k] - return "" - # L1d/L1i/L2/L3 键名中英文兼容 - l1d = pick("L1d 缓存", "L1d cache") - l1i = pick("L1i 缓存", "L1i cache") - l2 = pick("L2 缓存", "L2 cache") - l3 = pick("L3 缓存", "L3 cache") - _cpu_static = { - "model": ci.get("model") or pick("型号名称", "Model name"), - "arch": platform.machine(), - "sockets": pick("座", "Socket(s)"), - "cores_per_socket": pick("每个座的核数", "Core(s) per socket"), - "threads_per_core": pick("每个核的线程数", "Thread(s) per core"), - "l1d": l1d, "l1i": l1i, "l2": l2, "l3": l3, - "cache": ci.get("cache", ""), - } - try: - f = psutil.cpu_freq() - cur = round(f.current, 0) if f else 0 - fmin = round(f.min, 0) if f and f.min else 0 - fmax = round(f.max, 0) if f and f.max else 0 - except Exception: - cur = fmin = fmax = 0 - d = dict(_cpu_static) - d.update({ - "logical": psutil.cpu_count() or 0, - "physical": psutil.cpu_count(logical=False) or 0, - "freq_cur": cur, "freq_min": fmin, "freq_max": fmax, - }) - return d - -def get_percpu(): - try: - vals = psutil.cpu_percent(interval=None, percpu=True) - return [round(v, 1) for v in (vals or [])] - except Exception: - return [] - -@app.route("/api/mem") -def api_mem(): - vm = psutil.virtual_memory() - sm = psutil.swap_memory() - items = sorted(cache["procs"], key=lambda x: x["mem"], reverse=True)[:100] - mi = _read_meminfo_host() - dm = get_mem_hw() - return jsonify({ - "total": vm.total, "used": vm.used, "available": vm.available, - "percent": vm.percent, "cached": getattr(vm, "cached", 0), - "swap_total": sm.total, "swap_used": sm.used, "swap_percent": sm.percent, - "detail": mi, "hw": dm, - "items": items}) - -_mem_hw_static = {} - -def _read_meminfo_host(): - mi = {} - for p in ("/host/proc/meminfo", "/proc/meminfo"): - try: - with open(p) as f: - for line in f: - if ":" not in line: - continue - k, v = line.split(":", 1) - parts = v.strip().split() - if not parts: - continue - try: - num = int(parts[0]) - except ValueError: - continue - # 统一转 bytes - if len(parts) > 1 and parts[1] == "kB": - num *= 1024 - mi[k.strip()] = num - if mi: - break - except Exception: - continue - return mi - -def _walk_dmi(path): - """yield (type, raw_bytes, strings)""" - try: - with open(path, "rb") as f: - data = f.read() - except Exception: - return - off, n = 0, 0 - L = len(data) - while off + 4 <= L and n < 200: - typ, ln = data[off], data[off + 1] - if ln < 4 or off + ln > L: - break - if typ == 127: # end of table - break - s = off + ln - strs = [] - ok = True - while s < L: - try: - e = data.index(b"\x00", s) - except ValueError: - ok = False - break - if e == s: - s += 1 - break - strs.append(data[s:e].decode("utf-8", "replace").strip()) - s = e + 1 - if not ok: - break - yield typ, data[off:off + ln], strs - off = s - n += 1 - -_MEM_TYPE = {0x02: "Unknown", 0x03: "DRAM", 0x12: "DDR", 0x13: "DDR2", - 0x14: "DDR2 FB-DIMM", 0x18: "DDR3", 0x19: "FBD2", 0x1A: "DDR4", - 0x1B: "LPDDR", 0x1C: "LPDDR2", 0x1D: "LPDDR3", 0x1E: "LPDDR4", - 0x1F: "Logical NV", 0x20: "HBM", 0x21: "HBM2", 0x22: "DDR5", - 0x23: "LPDDR5", 0x24: "HBM3"} - -def _parse_dmi_mem(): - dimms, max_cap_kb = [], 0 - for dmi_path in ("/host/sys/firmware/dmi/tables/DMI", - "/sys/firmware/dmi/tables/DMI"): - found = False - for typ, raw, strs in _walk_dmi(dmi_path): - found = True - def S(i): - return strs[i - 1] if 1 <= i <= len(strs) else "" - if typ == 16 and len(raw) >= 11: - import struct - (cap,) = struct.unpack_from("= 28: - import struct - (size,) = struct.unpack_from("= 1024 else 1 - size_b = (size & 0x7FFF) * 1024 - else: - size_mb = size & 0x7FFF - size_b = size_mb * 1024 * 1024 - if size_mb == 0x7FFF: - continue - (speed,) = struct.unpack_from("= 30: - (cspeed,) = struct.unpack_from(" 18 else 0 - dimms.append({ - "locator": S(raw[16]) if len(raw) > 16 else "", - "bank": S(raw[17]) if len(raw) > 17 else "", - "size_b": size_b, "size_mb": size_mb, - "type": _MEM_TYPE.get(mtype, "DDR4" if mtype == 0 else hex(mtype)), - "speed": speed or 0, "cspeed": cspeed or 0, - "maker": S(raw[22]) if len(raw) > 22 else "", - "serial": S(raw[23]) if len(raw) > 23 else "", - "part": S(raw[25]) if len(raw) > 25 else "", - }) - if found: - break - return dimms, max_cap_kb - -def _get_vram(): - total = 0 - for base in ("/host/sys/class/drm", "/sys/class/drm"): - try: - for p in glob.glob(base + "/card*/device/mem_info_vram_total"): - try: - with open(p) as f: - total += int(f.read().strip()) - except Exception: - pass - except Exception: - pass - if total: - break - return total - -def get_mem_hw(): - global _mem_hw_static - if not _mem_hw_static: - dimms, max_cap_kb = [], 0 - # 优先读构建时从宿主 dmidecode 导出的静态槽位表 - for jp in ("hw_mem.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_mem.json")): - try: - with open(jp) as f: - hj = json.load(f) - dimms = hj.get("dimms", []) or [] - max_cap_kb = int((hj.get("max_cap_b", 0) or 0) // 1024) - if dimms: - break - except Exception: - continue - if not dimms: - dimms, max_cap_kb = _parse_dmi_mem() - installed = sum(d["size_b"] for d in dimms) - _mem_hw_static = {"dimms": dimms, "installed": installed, - "max_cap_kb": max_cap_kb} - d = {"dimms": _mem_hw_static["dimms"], - "installed": _mem_hw_static["installed"], - "max_cap_kb": _mem_hw_static["max_cap_kb"]} - try: - mem_total = psutil.virtual_memory().total - except Exception: - mem_total = 0 - d["vram"] = _get_vram() - d["system_total"] = mem_total - d["reserved"] = (d["installed"] - mem_total) if d["installed"] and mem_total else 0 - return d - -HOST_MNTS = [("/", "/host"), ("/vol1", "/host/vol1"), - ("/vol2", "/host/vol2"), ("/vol3", "/host/vol3")] - -def _df_host(): - """容器内 df 直读宿主路径(经 bind 进来的宿主目录),返回 {mnt: {...}}""" - try: - out = subprocess.run( - ["df", "-B1", "--output=source,target,fstype,size,used,avail", - "/host", "/host/vol1", "/host/vol2", "/host/vol3"], - capture_output=True, text=True, timeout=5).stdout.splitlines() - res = {} - for line in out[1:]: - p = line.split() - if len(p) < 6: - continue - dev, tgt, fst, size, used, avail = p[0], p[1], p[2], int(p[3]), int(p[4]), int(p[5]) - mnt = tgt.replace("/host", "", 1) or "/" - res[mnt] = {"device": dev, "mount": mnt, "fstype": fst, - "total": size, "used": used, "free": avail, - "percent": round(used / size * 100, 1) if size else 0} - return res - except Exception: - return {} - -@app.route("/api/mem/drop", methods=["POST"]) -def api_mem_drop(): - import subprocess - try: - subprocess.run(["sync"], check=False, timeout=10) - p = os.environ.get("PROCFS_PATH", "/proc") - with open(os.path.join(p, "sys", "vm", "drop_caches"), "w") as f: - f.write("3") - vm = psutil.virtual_memory() - return jsonify({"ok": True, "available": vm.available, "percent": vm.percent}) - except Exception as e: - return jsonify({"ok": False, "error": str(e)}), 500 -@app.route("/api/disk") -def api_disk(): - parts = [] - dfm = _df_host() - for mp, hp in HOST_MNTS: - if mp in dfm: - parts.append(dfm[mp]) - continue - try: - st = os.statvfs(hp) - total = st.f_blocks * st.f_frsize - free = st.f_bavail * st.f_frsize - used = total - free - parts.append({"device": "?", "mount": mp, "fstype": "?", - "total": total, "used": used, "free": free, - "percent": round(used / total * 100, 1) if total else 0}) - except Exception: - continue - try: - dio = psutil.disk_io_counters() - dio_d = {"read": dio.read_bytes, "write": dio.write_bytes} if dio else {} - except Exception: - dio_d = {} - # top io processes - items = sorted(cache["procs"], key=lambda x: x["io_r"] + x["io_w"], reverse=True)[:60] - return jsonify({"parts": parts, "io": dio_d, "hw": get_disk_hw(), - "items": items}) - -_disk_hw_static = {} - -def _read(p, default=""): - try: - with open(p) as f: - return f.read().strip() - except Exception: - return default - -def _sys_block_base(): - for base in ("/host/sys/block", "/sys/block"): - try: - if os.path.isdir(base): - return base - except Exception: - continue - return "/host/sys/block" - -def _live_sysfs(name): - """实时 sysfs: 容量/转速/型号(覆盖静态快照中的漂移项)""" - base = _sys_block_base() - d = os.path.join(base, name) - out = {} - try: - sectors = _read(os.path.join(d, "size")) - if sectors.isdigit(): - out["cap_b"] = int(sectors) * 512 - except Exception: - pass - rot = _read(os.path.join(d, "queue/rotational")) - if rot in ("0", "1"): - out["rotational"] = (rot == "1") - model = _read(os.path.join(d, "device/model")) - if model: - out["sys_model"] = model - vendor = _read(os.path.join(d, "device/vendor")) - if vendor: - out["sys_vendor"] = vendor - return out - -def _live_mdstat(): - """实时 mdstat: 每组阵列状态/同步进度,键为 md 名""" - st = {} - for p in ("/host/proc/mdstat", "/proc/mdstat"): - try: - with open(p) as f: - txt = f.read() - break - except Exception: - txt = "" - else: - return st - import re - cur = None - for line in txt.splitlines(): - m = re.match(r"^(md\d+)\s*:\s*(\w+)\s+(\w+)(.*)$", line) - if m: - cur = m.group(1) - st[cur] = {"status": m.group(2), "level": m.group(3), - "devs": m.group(4).strip(), "sync": ""} - continue - if cur and ("recovery" in line or "resync" in line or "blocks" in line): - s = line.strip() - if "recovery" in line or "resync" in line: - st[cur]["sync"] = s - elif "blocks" in line and not st[cur].get("blocks"): - st[cur]["blocks"] = s - return st - -def _live_perdisk_io(): - try: - d = psutil.disk_io_counters(perdisk=True) or {} - except Exception: - return {} - out = {} - for k, v in d.items(): - try: - out[k] = {"read": v.read_bytes, "write": v.write_bytes} - except Exception: - continue - return out - -def get_disk_hw(): - global _disk_hw_static - if not _disk_hw_static: - hj = {} - for jp in ("hw_disk.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_disk.json")): - try: - with open(jp) as f: - hj = json.load(f) - if hj.get("disks"): - break - except Exception: - continue - _disk_hw_static = hj or {"disks": [], "arrays": []} - disks = [dict(x) for x in _disk_hw_static.get("disks", [])] - arrays = [dict(x) for x in _disk_hw_static.get("arrays", [])] - # 合并实时数据 - pio = _live_perdisk_io() - for dk in disks: - nm = dk.get("name", "") - dk.update(_live_sysfs(nm)) - if nm in pio: - dk["io_read"] = pio[nm]["read"] - dk["io_write"] = pio[nm]["write"] - # 转速文字 - if dk.get("rotational") is True and not dk.get("rpm"): - dk["rpm"] = 5400 - md_live = _live_mdstat() - for a in arrays: - md = a.get("md", "") - lv = md_live.get(md, {}) - if lv: - a["live_status"] = lv.get("status", "") - a["live_devs"] = lv.get("devs", "") - a["live_sync"] = lv.get("sync", "") - if md in pio: - a["io_read"] = pio[md]["read"] - a["io_write"] = pio[md]["write"] - return {"disks": disks, "arrays": arrays, - "extra": _disk_hw_static.get("extra", {}), - "snapshot": _disk_hw_static.get("snapshot", ""), - "note": _disk_hw_static.get("note", "")} - -@app.route("/api/net") -def api_net(): - global net_prev, net_rate - now = time.time() - try: - counters = psutil.net_io_counters(pernic=True) - except Exception: - counters = {} - rates = [] - for nic, c in counters.items(): - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - prev = net_prev.get(nic) - if prev: - dt = max(now - prev["t"], 0.5) - up = (c.bytes_sent - prev["sent"]) / dt - down = (c.bytes_recv - prev["recv"]) / dt - else: - up = down = 0 - net_prev[nic] = {"sent": c.bytes_sent, "recv": c.bytes_recv, "t": now} - rates.append({"nic": nic, "up": max(up, 0), "down": max(down, 0), - "sent": c.bytes_sent, "recv": c.bytes_recv}) - rates.sort(key=lambda x: x["up"] + x["down"], reverse=True) - try: - conns = len(psutil.net_connections()) - except Exception: - conns = 0 - total_recv = 0 - total_sent = 0 - for nic, c in counters.items(): - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - total_recv += getattr(c, "bytes_recv", 0) or 0 - total_sent += getattr(c, "bytes_sent", 0) or 0 - items = sorted(cache["procs"], key=lambda x: x["conns"], reverse=True)[:60] - tcp_list = [] - listen_list = [] - try: - name_by_pid = {p["pid"]: p["name"] for p in cache.get("procs", [])} - for c in psutil.net_connections(kind="tcp"): - laddr = f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "" - raddr = f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "" - family = "ipv6" if ":" in (c.laddr.ip if c.laddr else "") and "." not in (c.laddr.ip if c.laddr else "") else "ipv4" - # better family detection - import socket - family = "ipv6" if (c.family == socket.AF_INET6) else "ipv4" - if c.status == "LISTEN": - listen_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": c.laddr.ip if c.laddr else "", "port": c.laddr.port if c.laddr else 0, "family": family, "pid": c.pid, "proto": "tcp"}) - else: - tcp_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": laddr, "raddr": raddr, "family": family, "pid": c.pid, "status": c.status}) - except Exception: - pass - tcp_list = tcp_list[:80] - listen_list = listen_list[:80] - return jsonify({"nics": rates, "conns": conns, "items": items, - "hw": get_net_hw(), - "total_recv": total_recv, "total_sent": total_sent, - "tcp": tcp_list, "listen": listen_list}) - -_net_hw_static = {} - -def _net_sysfs(name): - """实时 sysfs: 状态/MTU/速率/MAC,键名与 psutil 速率互补""" - out = {} - for base in ("/host/sys/class/net", "/sys/class/net"): - d = os.path.join(base, name) - try: - if not os.path.isdir(d): - continue - for k in ("address", "operstate", "mtu", "speed", "duplex", - "carrier"): - v = _read(os.path.join(d, k)) - if v: - out[k] = v - # 驱动名 - try: - drv = os.path.basename(os.readlink(os.path.join(d, "device/driver"))) - if drv: - out["live_driver"] = drv - except Exception: - pass - if out: - break - except Exception: - continue - return out - -def _net_addrs(): - try: - ad = psutil.net_if_addrs() or {} - except Exception: - return {} - out = {} - for nic, lst in ad.items(): - v4, v6, mac = [], [], "" - for a in lst: - try: - fam = a.family.name if hasattr(a.family, "name") else str(a.family) - except Exception: - fam = "" - addr = a.address or "" - if "PACKET" in fam: - if addr: - mac = mac or addr - elif "AF_INET6" in fam: - if addr and not addr.startswith("fe80"): - v6.append(addr.split("%")[0]) - elif addr: - v6.append(addr.split("%")[0] + "(链路)") - elif "AF_INET" in fam: - if addr and len(addr.split(".")) == 4: - v4.append(addr) - out[nic] = {"v4": v4, "v6": v6, "mac": mac} - return out - -def get_net_hw(): - global _net_hw_static - if not _net_hw_static: - hj = {} - for jp in ("hw_net.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_net.json")): - try: - with open(jp) as f: - hj = json.load(f) - if hj.get("nics"): - break - except Exception: - continue - _net_hw_static = hj or {"nics": []} - statics = {x.get("name"): dict(x) for x in _net_hw_static.get("nics", [])} - try: - st = psutil.net_if_stats() or {} - except Exception: - st = {} - addrs = _net_addrs() - # 展示的网卡: 静态档案 + 实际有流量的非虚拟口 - names = list(statics.keys()) - try: - counters = psutil.net_io_counters(pernic=True) or {} - except Exception: - counters = {} - for nic in counters: - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - if nic not in statics: - names.append(nic) - out = [] - for nic in names: - base = dict(statics.get(nic, {"name": nic})) - s = st.get(nic) - if s is not None: - try: - base["isup"] = bool(s.isup) - base["live_speed"] = s.speed or 0 - base["live_mtu"] = s.mtu or 0 - except Exception: - pass - base.update(_net_sysfs(nic)) - a = addrs.get(nic, {}) - if a: - base["ipv4"] = a.get("v4", []) - base["ipv6"] = a.get("v6", []) - if a.get("mac") and not base.get("mac"): - base["mac"] = a["mac"] - c = counters.get(nic) - if c is not None: - try: - base["io_sent"] = c.bytes_sent - base["io_recv"] = c.bytes_recv - except Exception: - pass - out.append(base) - # 默认网关/DNS 实时读 - gw, dns = _net_hw_static.get("gateway", ""), _net_hw_static.get("dns", "") - base_if = "" - try: - with open("/host/proc/net/route" if os.path.exists("/host/proc/net/route") else "/proc/net/route") as f: - for line in f.read().splitlines()[1:]: - f_ = line.split() - if len(f_) > 2 and f_[1] == "00000000" and f_[7] == "00000000": - import socket, struct - gw = socket.inet_ntoa(struct.pack(" - - - - -飞牛资源管理器 - - - -
-
CPU
-
内存
-
磁盘
-
网络
- -
-
- - - - -
-
- - -
-
- - - diff --git a/app/ui/.DS_Store b/app/ui/.DS_Store deleted file mode 100644 index 0b271e1..0000000 Binary files a/app/ui/.DS_Store and /dev/null differ diff --git a/app/ui/config b/app/ui/config deleted file mode 100644 index c83710c..0000000 --- a/app/ui/config +++ /dev/null @@ -1,13 +0,0 @@ -{ - ".url": { - "fnos-resmon.Application": { - "title": "飞牛资源管理器", - "icon": "images/icon_{0}.png", - "type": "url", - "protocol": "http", - "port": "7031", - "url": "/", - "allUsers": false - } - } -} diff --git a/app/ui/images/.DS_Store b/app/ui/images/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/app/ui/images/.DS_Store and /dev/null differ diff --git a/app/ui/images/icon_256.png b/app/ui/images/icon_256.png deleted file mode 100644 index d5c1bd4..0000000 Binary files a/app/ui/images/icon_256.png and /dev/null differ diff --git a/app/ui/images/icon_64.png b/app/ui/images/icon_64.png deleted file mode 100644 index c221ba6..0000000 Binary files a/app/ui/images/icon_64.png and /dev/null differ diff --git a/app_usage_tracker.py b/app_usage_tracker.py deleted file mode 100644 index bd2d6e5..0000000 --- a/app_usage_tracker.py +++ /dev/null @@ -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']}") diff --git a/bili.txt b/bili.txt new file mode 100644 index 0000000..b709b20 --- /dev/null +++ b/bili.txt @@ -0,0 +1,8 @@ +飞牛资源管理器:一眼看穿NAS谁在偷吃资源 +你的飞牛NAS卡顿、内存飙升,系统自带却只能看汇总数字,根本找不到元凶。飞牛资源管理器,一个Docker命令就能装好,进程级监控一目了然。 +CPU、内存、磁盘、网络四大面板实时刷新,每个进程的CPU和内存占用清清楚楚。 +内存面板还能看RSS、USS、SWAP明细,一键回收未使用的内存,释放空间立竿见影。 +磁盘面板直接显示SMART健康、温度、通电时间,硬盘故障提前预警。 +网络面板列出所有TCP连接和侦听端口,哪个进程在联网一目了然。 +表头点击排序,手机也能看,Docker运行不修改系统文件。开源免费,欢迎Star。 +收藏这个视频,下次NAS卡顿时就知道该查哪里。 \ No newline at end of file diff --git a/cmd/.DS_Store b/cmd/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/cmd/.DS_Store and /dev/null differ diff --git a/cmd/config_callback b/cmd/config_callback deleted file mode 100644 index f96cbfb..0000000 --- a/cmd/config_callback +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called after the user change environment variables in application setting page. - -exit 0 \ No newline at end of file diff --git a/cmd/config_init b/cmd/config_init deleted file mode 100644 index cacd475..0000000 --- a/cmd/config_init +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called before the user change environment variables in application setting page. - -exit 0 \ No newline at end of file diff --git a/cmd/install_callback b/cmd/install_callback deleted file mode 100644 index c7f9afa..0000000 --- a/cmd/install_callback +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called after the user installs the application. - -exit 0 \ No newline at end of file diff --git a/cmd/install_init b/cmd/install_init deleted file mode 100644 index 9ee0f11..0000000 --- a/cmd/install_init +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called before the user installs the application. - -exit 0 \ No newline at end of file diff --git a/cmd/main b/cmd/main deleted file mode 100644 index c6786a8..0000000 --- a/cmd/main +++ /dev/null @@ -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 \ No newline at end of file diff --git a/cmd/uninstall_callback b/cmd/uninstall_callback deleted file mode 100644 index 52f5bf0..0000000 --- a/cmd/uninstall_callback +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called after the user uninstalls the application. - -exit 0 \ No newline at end of file diff --git a/cmd/uninstall_init b/cmd/uninstall_init deleted file mode 100644 index 52f5bf0..0000000 --- a/cmd/uninstall_init +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called after the user uninstalls the application. - -exit 0 \ No newline at end of file diff --git a/cmd/upgrade_callback b/cmd/upgrade_callback deleted file mode 100644 index 80ae554..0000000 --- a/cmd/upgrade_callback +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called after the user upgrades the application. - -exit 0 \ No newline at end of file diff --git a/cmd/upgrade_init b/cmd/upgrade_init deleted file mode 100644 index 997e45d..0000000 --- a/cmd/upgrade_init +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -### This script is called before the user upgrades the application. - -exit 0 \ No newline at end of file diff --git a/config/.DS_Store b/config/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/config/.DS_Store and /dev/null differ diff --git a/config/privilege b/config/privilege deleted file mode 100644 index f266a0f..0000000 --- a/config/privilege +++ /dev/null @@ -1,5 +0,0 @@ -{ - "network": ["host"], - "pid": ["host"], - "volumes": ["/proc", "/sys", "/etc/passwd", "/"] -} diff --git a/config/resource b/config/resource deleted file mode 100644 index 0967ef4..0000000 --- a/config/resource +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/cpu-monitor-proxy.conf b/cpu-monitor-proxy.conf deleted file mode 100644 index b9349be..0000000 --- a/cpu-monitor-proxy.conf +++ /dev/null @@ -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| \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 040c3d4..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: '3.8' -services: - fnos-resmon: - image: jpccr.ccs.tencentyun.com/jinghui/fnos-resmon:latest - container_name: fnos-resmon - privileged: true - network_mode: host - pid: host - volumes: - - /proc:/host/proc:rw - - /sys:/host/sys:ro - environment: - - PROCFS_PATH=/host/proc - restart: unless-stopped \ No newline at end of file diff --git a/docs/cpu.png b/docs/cpu.png index c9787e1..72fa39a 100644 Binary files a/docs/cpu.png and b/docs/cpu.png differ diff --git a/docs/disk.png b/docs/disk.png index 67478db..811cfc6 100644 Binary files a/docs/disk.png and b/docs/disk.png differ diff --git a/docs/mem.png b/docs/mem.png index cbd8a1b..9bb9d19 100644 Binary files a/docs/mem.png and b/docs/mem.png differ diff --git a/docs/net.png b/docs/net.png index ef675dc..1a3bb46 100644 Binary files a/docs/net.png and b/docs/net.png differ diff --git a/fnos-resmon.fpk b/fnos-resmon.fpk deleted file mode 100644 index b9a0a61..0000000 Binary files a/fnos-resmon.fpk and /dev/null differ diff --git a/generate-fpk.py b/generate-fpk.py deleted file mode 100644 index daea628..0000000 --- a/generate-fpk.py +++ /dev/null @@ -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'') diff --git a/generate_icon.py b/generate_icon.py deleted file mode 100644 index 12b6a71..0000000 --- a/generate_icon.py +++ /dev/null @@ -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') diff --git a/install.sh b/install.sh deleted file mode 100644 index fe01463..0000000 --- a/install.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -PORT="${PORT:-7031}" -CONTAINER_NAME="${CONTAINER_NAME:-fnos-resmon}" -INSTALL_DIR="${INSTALL_DIR:-/vol1/1000/HD1/docker/fnos-resmon}" -REPO_URL="https://gitea.88531.cn/jinghui/fnos-resmon.git" - -if ! command -v docker >/dev/null 2>&1; then - echo "❌ 未检测到 Docker,请先安装 Docker"; exit 1 -fi -if ! docker compose version >/dev/null 2>&1; then - echo "❌ 未检测到 docker compose"; exit 1 -fi -if ! command -v git >/dev/null 2>&1; then - echo "❌ 未检测到 git"; exit 1 -fi - -# 自动探测 docker 根目录 -if [ -d /vol1/1000/HD1/docker ]; then - COMPOSE_ROOT=/vol1/1000/HD1/docker -elif [ -d "$HOME/docker" ]; then - COMPOSE_ROOT="$HOME/docker" -else - COMPOSE_ROOT="$(dirname "$INSTALL_DIR")" -fi - -if [ -d "$INSTALL_DIR/.git" ]; then - echo "🔄 检测到已有安装,执行更新..." - cd "$INSTALL_DIR" - git fetch origin main - git reset --hard origin/main -else - echo "📥 克隆项目..." - mkdir -p "$(dirname "$INSTALL_DIR")" - git clone "$REPO_URL" "$INSTALL_DIR" - cd "$INSTALL_DIR" -fi - -cat > "$INSTALL_DIR/.env" <:$PORT" - exit 0 - fi - sleep 2 -done -echo "⚠️ 服务可能未就绪,请检查 docker logs" -exit 1 diff --git a/manifest b/manifest deleted file mode 100644 index 05c46a0..0000000 --- a/manifest +++ /dev/null @@ -1,7 +0,0 @@ -appname: fnos-resmon -version: 1.2.6 -display_name: 飞牛资源管理器 -desc: 飞牛 NAS 资源监视器(CPU/内存/磁盘/网络四页签,读取宿主真实数据) -arch: x86_64 -source: docker -maintainer: jinghui diff --git a/promo.html b/promo.html new file mode 100644 index 0000000..52e24b8 --- /dev/null +++ b/promo.html @@ -0,0 +1,81 @@ +

飞牛NAS必装:进程级资源监控,比自带强10倍

+
+

飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。

+
+

你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。

+

飞牛资源管理器(fnos-resmon) 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,一条 docker 命令启动。

+

一句话说清优势

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
能力飞牛自带fnos-resmon
CPU/内存占用率✅ 有✅ 有
进程级 CPU/内存排行❌ 无✅ 进程名/PID/用户/状态/线程数
内存 RSS/USS/SWAP 明细❌ 无✅ 三列细分
一键内存回收❌ 无✅ 🧹 一键回收未使用的内存
一键结束异常进程❌ 无✅ 一键 kill
磁盘 SMART/温度/通电时间❌ 需命令行✅ Web 直观展示
TCP 连接明细/侦听端口❌ 无✅ 进程+地址+状态
网卡驱动/MTU/双工❌ 无✅ 有
+

CPU 面板:谁在偷吃一眼看清

+

CPU 型号、架构、物理/逻辑核数一目了然,进程级 CPU 排行实时刷新,点表头即排序。

+

CPU面板

+

内存面板:一键回收释放空间

+

每个进程 RSS/USS/SWAP 三列细分,🧹 一键回收未使用的内存,异常进程一键结束。

+

内存面板

+

磁盘面板:硬盘健康提前预警

+

SMART 健康、温度、通电时间 Web 直观展示,硬盘故障提前发现。

+

磁盘面板

+

网络面板:谁在联网一目了然

+

所有 TCP 连接和侦听端口明细,进程+地址+状态,网卡驱动/MTU/双工全展示。

+

网络面板

+

一条命令安装

+
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 运行不修改系统文件
  • +
\ No newline at end of file diff --git a/promo.md b/promo.md new file mode 100644 index 0000000..b117b55 --- /dev/null +++ b/promo.md @@ -0,0 +1,58 @@ +# 飞牛NAS必装:进程级资源监控,比自带强10倍 + +> 飞牛 NAS 专属的轻量级系统资源监控面板 —— 一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面。 + +你家飞牛 NAS 卡顿、内存飙升,却只能看到系统自带的一个百分比数字?哪个进程在吃 CPU?哪块盘快坏了?哪个端口被谁占用了?一键能回收内存吗?这些飞牛自带都做不到。 + +**飞牛资源管理器(fnos-resmon)** 正好补上这些短板:CPU / 内存 / 磁盘 / 网络四大面板,进程级明细,一条 docker 命令启动。 + +## 一句话说清优势 + +| 能力 | 飞牛自带 | fnos-resmon | +|------|---------|-------------| +| CPU/内存占用率 | ✅ 有 | ✅ 有 | +| 进程级 CPU/内存排行 | ❌ 无 | ✅ 进程名/PID/用户/状态/线程数 | +| 内存 RSS/USS/SWAP 明细 | ❌ 无 | ✅ 三列细分 | +| 一键内存回收 | ❌ 无 | ✅ 🧹 一键回收未使用的内存 | +| 一键结束异常进程 | ❌ 无 | ✅ 一键 kill | +| 磁盘 SMART/温度/通电时间 | ❌ 需命令行 | ✅ Web 直观展示 | +| TCP 连接明细/侦听端口 | ❌ 无 | ✅ 进程+地址+状态 | +| 网卡驱动/MTU/双工 | ❌ 无 | ✅ 有 | + +## CPU 面板:谁在偷吃一眼看清 + +CPU 型号、架构、物理/逻辑核数一目了然,进程级 CPU 排行实时刷新,点表头即排序。 + +![CPU面板](http://img.88531.cn/i/2026/11/07/6aee0007e9bf2.png) + +## 内存面板:一键回收释放空间 + +每个进程 RSS/USS/SWAP 三列细分,🧹 一键回收未使用的内存,异常进程一键结束。 + +![内存面板](http://img.88531.cn/i/2026/11/07/6aee000887b6a.png) + +## 磁盘面板:硬盘健康提前预警 + +SMART 健康、温度、通电时间 Web 直观展示,硬盘故障提前发现。 + +![磁盘面板](http://img.88531.cn/i/2026/11/07/6aee000936412.png) + +## 网络面板:谁在联网一目了然 + +所有 TCP 连接和侦听端口明细,进程+地址+状态,网卡驱动/MTU/双工全展示。 + +![网络面板](http://img.88531.cn/i/2026/11/07/6aee0009c4d5e.png) + +## 一条命令安装 + +```bash +docker run -d --name fnos-resmon --privileged --network host --pid host \ + -v /proc:/host/proc:rw -v /sys:/host/sys:ro \ + -e PORT=7031 -e PROCFS_PATH=/host/proc \ + 192.168.68.19:3200/jinghui/fnos-resmon:latest +``` + +浏览器打开 `http://NAS-IP:7031` 即可使用,手机也能看。 + +- 开源地址:https://gitea.88531.cn/jinghui/fnos-resmon +- 3 秒自动刷新,零侵入,Docker 运行不修改系统文件 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9cadc75..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask -psutil diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 24fd3d8..0000000 --- a/src/app.py +++ /dev/null @@ -1,827 +0,0 @@ -from flask import Flask, jsonify, request, send_from_directory -import psutil, os, time, signal, threading, subprocess, platform, glob, json, struct -try: - psutil.PROCFS_PATH = os.environ.get("PROCFS_PATH", "/proc") -except Exception: - pass - -PROCFS = os.environ.get("PROCFS_PATH", "/host/proc") -if os.path.isdir(PROCFS): - psutil.PROCFS_PATH = PROCFS - -app = Flask(__name__, static_folder="public", static_url_path="") -PORT = int(os.environ.get("PORT", "7031")) - -# ---------- background sampler ---------- -cache = {"procs": [], "cpu": 0.0, "load": [0, 0, 0], "ts": 0} -net_prev = {} -net_rate = {} - -def fmt_status(s): - m = { - psutil.STATUS_RUNNING: ("活动", 1), - psutil.STATUS_SLEEPING: ("睡眠", 0), - psutil.STATUS_DISK_SLEEP: ("睡眠", 0), - psutil.STATUS_STOPPED: ("停止", 0), - psutil.STATUS_ZOMBIE: ("僵尸", 0), - } - return m.get(s, ("睡眠", 0)) - -_uid_cache = {"map": {}, "ts": 0} - -def uid_name(uid_str): - # 容器内 /etc/passwd 没有宿主用户,uid 会显示成数字,用宿主 passwd 还原 - if not uid_str or not uid_str.isdigit(): - return uid_str - now = time.time() - if now - _uid_cache["ts"] > 300: - m = {} - try: - with open("/host/etc/passwd") as f: - for line in f: - p = line.split(":") - if len(p) > 3: - try: - m[p[2]] = p[0] - except Exception: - pass - except Exception: - pass - _uid_cache["map"] = m - _uid_cache["ts"] = now - return _uid_cache["map"].get(uid_str, uid_str) - -def sample_procs(): - # init cpu percent calculation - for p in psutil.process_iter(): - try: - p.cpu_percent(None) - except Exception: - pass - time.sleep(1.0) - procs = [] - try: - cpu_total = psutil.cpu_percent(interval=None) - except Exception: - cpu_total = 0 - try: - load = list(os.getloadavg()) - except Exception: - load = [0, 0, 0] - for p in psutil.process_iter(["pid", "name", "username", "status", "num_threads", "cmdline"]): - try: - with p.oneshot(): - cpu = p.cpu_percent(None) - mem = p.memory_percent() - try: - rss = p.memory_info().rss - except Exception: - rss = 0 - uss = 0 - swap = 0 - try: - mfi = p.memory_full_info() - uss = getattr(mfi, "uss", 0) or 0 - swap = getattr(mfi, "swap", 0) or 0 - except Exception: - pass - try: - io = p.io_counters() - rd, wr = io.read_bytes, io.write_bytes - except Exception: - rd, wr = 0, 0 - try: - conns = len(p.net_connections()) - except Exception: - conns = 0 - info = p.info - name = info.get("name") or "" - cmd = " ".join(info.get("cmdline") or [])[:120] - desc = cmd if cmd else name - st, act = fmt_status(info.get("status")) - if (not act) and (cpu or 0) > 1.0: - st, act = "活动", 1 - procs.append({ - "name": name[:40], "pid": info["pid"], - "cpu": round(cpu or 0, 2), "mem": round(mem or 0, 2), - "rss": rss, "uss": uss, "swap": swap, "threads": info.get("num_threads") or 0, - "user": uid_name(str(info.get("username") or ""))[:20], - "status": st, "active": act, - "desc": desc[:80], "io_r": rd, "io_w": wr, "conns": conns, - }) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - except Exception: - continue - procs.sort(key=lambda x: x["cpu"], reverse=True) - cache["procs"] = procs - cache["cpu"] = round(cpu_total, 1) - cache["load"] = [round(x, 2) for x in load] - cache["ts"] = time.time() - -def sampler_loop(): - while True: - try: - sample_procs() - except Exception: - pass - time.sleep(2) - -threading.Thread(target=sampler_loop, daemon=True).start() -# warm up immediately in background; serve stale until ready - -@app.route("/") -def index(): - return send_from_directory("public", "index.html") - -@app.route("/api/cpu") -def api_cpu(): - active = sum(1 for p in cache["procs"] if p["active"]) - return jsonify({"cpu": cache["cpu"], "load": cache["load"], - "active": active, "total": len(cache["procs"]), - "info": get_cpu_info(), "percpu": get_percpu(), - "items": cache["procs"][:100]}) - -_cpu_static = {} - -def _parse_host_cpuinfo(): - info = {} - try: - with open("/host/proc/cpuinfo") as f: - txt = f.read() - except Exception: - try: - with open("/proc/cpuinfo") as f: - txt = f.read() - except Exception: - return info - # 取第一个 processor 段的 model name / cache size / cpu MHz - for line in txt.splitlines(): - if ":" not in line: - continue - k, v = line.split(":", 1) - k, v = k.strip(), v.strip() - if k == "model name" and "model" not in info: - info["model"] = v - elif k == "cache size" and "cache" not in info: - info["cache"] = v - return info - -def _parse_lscpu(): - out = {} - try: - txt = subprocess.run(["lscpu"], capture_output=True, text=True, - timeout=5).stdout - except Exception: - return out - for line in txt.splitlines(): - if ":" not in line and ":" not in line: - continue - line = line.replace(":", ":") - k, v = line.split(":", 1) - k, v = k.strip(), v.strip() - out[k] = v - return out - -def get_cpu_info(): - global _cpu_static - if not _cpu_static: - ci = _parse_host_cpuinfo() - lc = _parse_lscpu() - def pick(*keys): - for k in keys: - if k in lc and lc[k]: - return lc[k] - return "" - # L1d/L1i/L2/L3 键名中英文兼容 - l1d = pick("L1d 缓存", "L1d cache") - l1i = pick("L1i 缓存", "L1i cache") - l2 = pick("L2 缓存", "L2 cache") - l3 = pick("L3 缓存", "L3 cache") - _cpu_static = { - "model": ci.get("model") or pick("型号名称", "Model name"), - "arch": platform.machine(), - "sockets": pick("座", "Socket(s)"), - "cores_per_socket": pick("每个座的核数", "Core(s) per socket"), - "threads_per_core": pick("每个核的线程数", "Thread(s) per core"), - "l1d": l1d, "l1i": l1i, "l2": l2, "l3": l3, - "cache": ci.get("cache", ""), - } - try: - f = psutil.cpu_freq() - cur = round(f.current, 0) if f else 0 - fmin = round(f.min, 0) if f and f.min else 0 - fmax = round(f.max, 0) if f and f.max else 0 - except Exception: - cur = fmin = fmax = 0 - d = dict(_cpu_static) - d.update({ - "logical": psutil.cpu_count() or 0, - "physical": psutil.cpu_count(logical=False) or 0, - "freq_cur": cur, "freq_min": fmin, "freq_max": fmax, - }) - return d - -def get_percpu(): - try: - vals = psutil.cpu_percent(interval=None, percpu=True) - return [round(v, 1) for v in (vals or [])] - except Exception: - return [] - -@app.route("/api/mem") -def api_mem(): - vm = psutil.virtual_memory() - sm = psutil.swap_memory() - items = sorted(cache["procs"], key=lambda x: x["mem"], reverse=True)[:100] - mi = _read_meminfo_host() - dm = get_mem_hw() - return jsonify({ - "total": vm.total, "used": vm.used, "available": vm.available, - "percent": vm.percent, "cached": getattr(vm, "cached", 0), - "swap_total": sm.total, "swap_used": sm.used, "swap_percent": sm.percent, - "detail": mi, "hw": dm, - "items": items}) - -_mem_hw_static = {} - -def _read_meminfo_host(): - mi = {} - for p in ("/host/proc/meminfo", "/proc/meminfo"): - try: - with open(p) as f: - for line in f: - if ":" not in line: - continue - k, v = line.split(":", 1) - parts = v.strip().split() - if not parts: - continue - try: - num = int(parts[0]) - except ValueError: - continue - # 统一转 bytes - if len(parts) > 1 and parts[1] == "kB": - num *= 1024 - mi[k.strip()] = num - if mi: - break - except Exception: - continue - return mi - -def _walk_dmi(path): - """yield (type, raw_bytes, strings)""" - try: - with open(path, "rb") as f: - data = f.read() - except Exception: - return - off, n = 0, 0 - L = len(data) - while off + 4 <= L and n < 200: - typ, ln = data[off], data[off + 1] - if ln < 4 or off + ln > L: - break - if typ == 127: # end of table - break - s = off + ln - strs = [] - ok = True - while s < L: - try: - e = data.index(b"\x00", s) - except ValueError: - ok = False - break - if e == s: - s += 1 - break - strs.append(data[s:e].decode("utf-8", "replace").strip()) - s = e + 1 - if not ok: - break - yield typ, data[off:off + ln], strs - off = s - n += 1 - -_MEM_TYPE = {0x02: "Unknown", 0x03: "DRAM", 0x12: "DDR", 0x13: "DDR2", - 0x14: "DDR2 FB-DIMM", 0x18: "DDR3", 0x19: "FBD2", 0x1A: "DDR4", - 0x1B: "LPDDR", 0x1C: "LPDDR2", 0x1D: "LPDDR3", 0x1E: "LPDDR4", - 0x1F: "Logical NV", 0x20: "HBM", 0x21: "HBM2", 0x22: "DDR5", - 0x23: "LPDDR5", 0x24: "HBM3"} - -def _parse_dmi_mem(): - dimms, max_cap_kb = [], 0 - for dmi_path in ("/host/sys/firmware/dmi/tables/DMI", - "/sys/firmware/dmi/tables/DMI"): - found = False - for typ, raw, strs in _walk_dmi(dmi_path): - found = True - def S(i): - return strs[i - 1] if 1 <= i <= len(strs) else "" - if typ == 16 and len(raw) >= 11: - import struct - (cap,) = struct.unpack_from("= 28: - import struct - (size,) = struct.unpack_from("= 1024 else 1 - size_b = (size & 0x7FFF) * 1024 - else: - size_mb = size & 0x7FFF - size_b = size_mb * 1024 * 1024 - if size_mb == 0x7FFF: - continue - (speed,) = struct.unpack_from("= 30: - (cspeed,) = struct.unpack_from(" 18 else 0 - dimms.append({ - "locator": S(raw[16]) if len(raw) > 16 else "", - "bank": S(raw[17]) if len(raw) > 17 else "", - "size_b": size_b, "size_mb": size_mb, - "type": _MEM_TYPE.get(mtype, "DDR4" if mtype == 0 else hex(mtype)), - "speed": speed or 0, "cspeed": cspeed or 0, - "maker": S(raw[22]) if len(raw) > 22 else "", - "serial": S(raw[23]) if len(raw) > 23 else "", - "part": S(raw[25]) if len(raw) > 25 else "", - }) - if found: - break - return dimms, max_cap_kb - -def _get_vram(): - total = 0 - for base in ("/host/sys/class/drm", "/sys/class/drm"): - try: - for p in glob.glob(base + "/card*/device/mem_info_vram_total"): - try: - with open(p) as f: - total += int(f.read().strip()) - except Exception: - pass - except Exception: - pass - if total: - break - return total - -def get_mem_hw(): - global _mem_hw_static - if not _mem_hw_static: - dimms, max_cap_kb = [], 0 - # 优先读构建时从宿主 dmidecode 导出的静态槽位表 - for jp in ("hw_mem.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_mem.json")): - try: - with open(jp) as f: - hj = json.load(f) - dimms = hj.get("dimms", []) or [] - max_cap_kb = int((hj.get("max_cap_b", 0) or 0) // 1024) - if dimms: - break - except Exception: - continue - if not dimms: - dimms, max_cap_kb = _parse_dmi_mem() - installed = sum(d["size_b"] for d in dimms) - _mem_hw_static = {"dimms": dimms, "installed": installed, - "max_cap_kb": max_cap_kb} - d = {"dimms": _mem_hw_static["dimms"], - "installed": _mem_hw_static["installed"], - "max_cap_kb": _mem_hw_static["max_cap_kb"]} - try: - mem_total = psutil.virtual_memory().total - except Exception: - mem_total = 0 - d["vram"] = _get_vram() - d["system_total"] = mem_total - d["reserved"] = (d["installed"] - mem_total) if d["installed"] and mem_total else 0 - return d - -HOST_MNTS = [("/", "/host"), ("/vol1", "/host/vol1"), - ("/vol2", "/host/vol2"), ("/vol3", "/host/vol3")] - -def _df_host(): - """容器内 df 直读宿主路径(经 bind 进来的宿主目录),返回 {mnt: {...}}""" - try: - out = subprocess.run( - ["df", "-B1", "--output=source,target,fstype,size,used,avail", - "/host", "/host/vol1", "/host/vol2", "/host/vol3"], - capture_output=True, text=True, timeout=5).stdout.splitlines() - res = {} - for line in out[1:]: - p = line.split() - if len(p) < 6: - continue - dev, tgt, fst, size, used, avail = p[0], p[1], p[2], int(p[3]), int(p[4]), int(p[5]) - mnt = tgt.replace("/host", "", 1) or "/" - res[mnt] = {"device": dev, "mount": mnt, "fstype": fst, - "total": size, "used": used, "free": avail, - "percent": round(used / size * 100, 1) if size else 0} - return res - except Exception: - return {} - -@app.route("/api/mem/drop", methods=["POST"]) -def api_mem_drop(): - import subprocess - try: - subprocess.run(["sync"], check=False, timeout=10) - p = os.environ.get("PROCFS_PATH", "/proc") - with open(os.path.join(p, "sys", "vm", "drop_caches"), "w") as f: - f.write("3") - vm = psutil.virtual_memory() - return jsonify({"ok": True, "available": vm.available, "percent": vm.percent}) - except Exception as e: - return jsonify({"ok": False, "error": str(e)}), 500 -@app.route("/api/disk") -def api_disk(): - parts = [] - dfm = _df_host() - for mp, hp in HOST_MNTS: - if mp in dfm: - parts.append(dfm[mp]) - continue - try: - st = os.statvfs(hp) - total = st.f_blocks * st.f_frsize - free = st.f_bavail * st.f_frsize - used = total - free - parts.append({"device": "?", "mount": mp, "fstype": "?", - "total": total, "used": used, "free": free, - "percent": round(used / total * 100, 1) if total else 0}) - except Exception: - continue - try: - dio = psutil.disk_io_counters() - dio_d = {"read": dio.read_bytes, "write": dio.write_bytes} if dio else {} - except Exception: - dio_d = {} - # top io processes - items = sorted(cache["procs"], key=lambda x: x["io_r"] + x["io_w"], reverse=True)[:60] - return jsonify({"parts": parts, "io": dio_d, "hw": get_disk_hw(), - "items": items}) - -_disk_hw_static = {} - -def _read(p, default=""): - try: - with open(p) as f: - return f.read().strip() - except Exception: - return default - -def _sys_block_base(): - for base in ("/host/sys/block", "/sys/block"): - try: - if os.path.isdir(base): - return base - except Exception: - continue - return "/host/sys/block" - -def _live_sysfs(name): - """实时 sysfs: 容量/转速/型号(覆盖静态快照中的漂移项)""" - base = _sys_block_base() - d = os.path.join(base, name) - out = {} - try: - sectors = _read(os.path.join(d, "size")) - if sectors.isdigit(): - out["cap_b"] = int(sectors) * 512 - except Exception: - pass - rot = _read(os.path.join(d, "queue/rotational")) - if rot in ("0", "1"): - out["rotational"] = (rot == "1") - model = _read(os.path.join(d, "device/model")) - if model: - out["sys_model"] = model - vendor = _read(os.path.join(d, "device/vendor")) - if vendor: - out["sys_vendor"] = vendor - return out - -def _live_mdstat(): - """实时 mdstat: 每组阵列状态/同步进度,键为 md 名""" - st = {} - for p in ("/host/proc/mdstat", "/proc/mdstat"): - try: - with open(p) as f: - txt = f.read() - break - except Exception: - txt = "" - else: - return st - import re - cur = None - for line in txt.splitlines(): - m = re.match(r"^(md\d+)\s*:\s*(\w+)\s+(\w+)(.*)$", line) - if m: - cur = m.group(1) - st[cur] = {"status": m.group(2), "level": m.group(3), - "devs": m.group(4).strip(), "sync": ""} - continue - if cur and ("recovery" in line or "resync" in line or "blocks" in line): - s = line.strip() - if "recovery" in line or "resync" in line: - st[cur]["sync"] = s - elif "blocks" in line and not st[cur].get("blocks"): - st[cur]["blocks"] = s - return st - -def _live_perdisk_io(): - try: - d = psutil.disk_io_counters(perdisk=True) or {} - except Exception: - return {} - out = {} - for k, v in d.items(): - try: - out[k] = {"read": v.read_bytes, "write": v.write_bytes} - except Exception: - continue - return out - -def get_disk_hw(): - global _disk_hw_static - if not _disk_hw_static: - hj = {} - for jp in ("hw_disk.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_disk.json")): - try: - with open(jp) as f: - hj = json.load(f) - if hj.get("disks"): - break - except Exception: - continue - _disk_hw_static = hj or {"disks": [], "arrays": []} - disks = [dict(x) for x in _disk_hw_static.get("disks", [])] - arrays = [dict(x) for x in _disk_hw_static.get("arrays", [])] - # 合并实时数据 - pio = _live_perdisk_io() - for dk in disks: - nm = dk.get("name", "") - dk.update(_live_sysfs(nm)) - if nm in pio: - dk["io_read"] = pio[nm]["read"] - dk["io_write"] = pio[nm]["write"] - # 转速文字 - if dk.get("rotational") is True and not dk.get("rpm"): - dk["rpm"] = 5400 - md_live = _live_mdstat() - for a in arrays: - md = a.get("md", "") - lv = md_live.get(md, {}) - if lv: - a["live_status"] = lv.get("status", "") - a["live_devs"] = lv.get("devs", "") - a["live_sync"] = lv.get("sync", "") - if md in pio: - a["io_read"] = pio[md]["read"] - a["io_write"] = pio[md]["write"] - return {"disks": disks, "arrays": arrays, - "extra": _disk_hw_static.get("extra", {}), - "snapshot": _disk_hw_static.get("snapshot", ""), - "note": _disk_hw_static.get("note", "")} - -@app.route("/api/net") -def api_net(): - global net_prev, net_rate - now = time.time() - try: - counters = psutil.net_io_counters(pernic=True) - except Exception: - counters = {} - rates = [] - for nic, c in counters.items(): - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - prev = net_prev.get(nic) - if prev: - dt = max(now - prev["t"], 0.5) - up = (c.bytes_sent - prev["sent"]) / dt - down = (c.bytes_recv - prev["recv"]) / dt - else: - up = down = 0 - net_prev[nic] = {"sent": c.bytes_sent, "recv": c.bytes_recv, "t": now} - rates.append({"nic": nic, "up": max(up, 0), "down": max(down, 0), - "sent": c.bytes_sent, "recv": c.bytes_recv}) - rates.sort(key=lambda x: x["up"] + x["down"], reverse=True) - try: - conns = len(psutil.net_connections()) - except Exception: - conns = 0 - total_recv = 0 - total_sent = 0 - for nic, c in counters.items(): - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - total_recv += getattr(c, "bytes_recv", 0) or 0 - total_sent += getattr(c, "bytes_sent", 0) or 0 - items = sorted(cache["procs"], key=lambda x: x["conns"], reverse=True)[:60] - tcp_list = [] - listen_list = [] - try: - name_by_pid = {p["pid"]: p["name"] for p in cache.get("procs", [])} - for c in psutil.net_connections(kind="tcp"): - laddr = f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "" - raddr = f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "" - family = "ipv6" if ":" in (c.laddr.ip if c.laddr else "") and "." not in (c.laddr.ip if c.laddr else "") else "ipv4" - # better family detection - import socket - family = "ipv6" if (c.family == socket.AF_INET6) else "ipv4" - if c.status == "LISTEN": - listen_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": c.laddr.ip if c.laddr else "", "port": c.laddr.port if c.laddr else 0, "family": family, "pid": c.pid, "proto": "tcp"}) - else: - tcp_list.append({"name": name_by_pid.get(c.pid, ""), "laddr": laddr, "raddr": raddr, "family": family, "pid": c.pid, "status": c.status}) - except Exception: - pass - tcp_list = tcp_list[:80] - listen_list = listen_list[:80] - return jsonify({"nics": rates, "conns": conns, "items": items, - "hw": get_net_hw(), - "total_recv": total_recv, "total_sent": total_sent, - "tcp": tcp_list, "listen": listen_list}) - -_net_hw_static = {} - -def _net_sysfs(name): - """实时 sysfs: 状态/MTU/速率/MAC,键名与 psutil 速率互补""" - out = {} - for base in ("/host/sys/class/net", "/sys/class/net"): - d = os.path.join(base, name) - try: - if not os.path.isdir(d): - continue - for k in ("address", "operstate", "mtu", "speed", "duplex", - "carrier"): - v = _read(os.path.join(d, k)) - if v: - out[k] = v - # 驱动名 - try: - drv = os.path.basename(os.readlink(os.path.join(d, "device/driver"))) - if drv: - out["live_driver"] = drv - except Exception: - pass - if out: - break - except Exception: - continue - return out - -def _net_addrs(): - try: - ad = psutil.net_if_addrs() or {} - except Exception: - return {} - out = {} - for nic, lst in ad.items(): - v4, v6, mac = [], [], "" - for a in lst: - try: - fam = a.family.name if hasattr(a.family, "name") else str(a.family) - except Exception: - fam = "" - addr = a.address or "" - if "PACKET" in fam: - if addr: - mac = mac or addr - elif "AF_INET6" in fam: - if addr and not addr.startswith("fe80"): - v6.append(addr.split("%")[0]) - elif addr: - v6.append(addr.split("%")[0] + "(链路)") - elif "AF_INET" in fam: - if addr and len(addr.split(".")) == 4: - v4.append(addr) - out[nic] = {"v4": v4, "v6": v6, "mac": mac} - return out - -def get_net_hw(): - global _net_hw_static - if not _net_hw_static: - hj = {} - for jp in ("hw_net.json", - os.path.join(os.path.dirname(os.path.abspath(__file__)), - "hw_net.json")): - try: - with open(jp) as f: - hj = json.load(f) - if hj.get("nics"): - break - except Exception: - continue - _net_hw_static = hj or {"nics": []} - statics = {x.get("name"): dict(x) for x in _net_hw_static.get("nics", [])} - try: - st = psutil.net_if_stats() or {} - except Exception: - st = {} - addrs = _net_addrs() - # 展示的网卡: 静态档案 + 实际有流量的非虚拟口 - names = list(statics.keys()) - try: - counters = psutil.net_io_counters(pernic=True) or {} - except Exception: - counters = {} - for nic in counters: - if nic.startswith(("lo", "docker", "br-", "veth")): - continue - if nic not in statics: - names.append(nic) - out = [] - for nic in names: - base = dict(statics.get(nic, {"name": nic})) - s = st.get(nic) - if s is not None: - try: - base["isup"] = bool(s.isup) - base["live_speed"] = s.speed or 0 - base["live_mtu"] = s.mtu or 0 - except Exception: - pass - base.update(_net_sysfs(nic)) - a = addrs.get(nic, {}) - if a: - base["ipv4"] = a.get("v4", []) - base["ipv6"] = a.get("v6", []) - if a.get("mac") and not base.get("mac"): - base["mac"] = a["mac"] - c = counters.get(nic) - if c is not None: - try: - base["io_sent"] = c.bytes_sent - base["io_recv"] = c.bytes_recv - except Exception: - pass - out.append(base) - # 默认网关/DNS 实时读 - gw, dns = _net_hw_static.get("gateway", ""), _net_hw_static.get("dns", "") - base_if = "" - try: - with open("/host/proc/net/route" if os.path.exists("/host/proc/net/route") else "/proc/net/route") as f: - for line in f.read().splitlines()[1:]: - f_ = line.split() - if len(f_) > 2 and f_[1] == "00000000" and f_[7] == "00000000": - import socket, struct - gw = socket.inet_ntoa(struct.pack(" - - - - -fnmon · fnOS - - - - - - - - -
- - -
-

⚡ CPU

- -
-
-
-
Governor
-
—
-
-
-
-
-
EPP
-
—
-
-
-
-
-
🌡 温度
-
—
-
-
-
-
-
频率范围
-
—
-
-
-
-
-
📊 负载
-
—
-
-
-
-
-
-
🌬 风扇
-
—
-
-
-
-
-
⚙️ 运行模式
-
—
-
-
-
-
-
📻 Boost
-
—
-
-
-
-
-
-
💠 核心
-
—
-
-
-
- -
- -
-
- 📈 频率历史 (60s) -
-
- -
- - -
-
🔝 进程 CPU 占用排行 (Top 20)
-
- - - - - - - - - - -
PID用户CPU%内存%启动时间进程
-
-
- - -
-
📊 性能评测
-
-
- - -
-
🆚 同级CPU对比 (PassMark多核)
-
-
-
- - -
-

🧠 内存

-
-
- -
-
—
-
已用
-
-
-
-
总计—
-
已用—
-
可用—
-
空闲—
-
缓存—
-
- 💱 交换分区— -
-
- 交换已用— -
-
-
-
-
-
-
- - -
-

🌐 网络

- -
-
-
-
📊 总流量
-
—
-
▼ — · ▲ —
-
-
-
-
-
📅 本月流量
-
—
-
▼ — · ▲ —
-
-
-
-
-
📆 今日流量
-
—
-
▼ — · ▲ —
-
-
-
- - -
-
🏆 应用流量排行 (Top 15)
-
加载中…
-
- - -
-
-
- 📈 流量趋势 (60s) -
- 下载 - 上传 -
-
- -
-
- - -
-

💾 磁盘

- -
- -
-
-
- 📈 磁盘 I/O (60s) -
- 读取 - 写入 -
-
- -
-
- - -
-

📱 应用用量排行

- -
- - - - - -
- -
-
🏆 综合排行 (CPU + 内存 + 网络 加权)
-
-
-
- -
- fnmon · 正在更新… -
-
- - - - diff --git a/traffic_tracker.py b/traffic_tracker.py deleted file mode 100644 index 8c78a52..0000000 --- a/traffic_tracker.py +++ /dev/null @@ -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))