From 9717cffb790272d9b2056dbf79ef480d15b581fd Mon Sep 17 00:00:00 2001 From: jinghui1984 <181050043@qq.com> Date: Mon, 15 Jun 2026 23:01:08 +0800 Subject: [PATCH] =?UTF-8?q?init:=20fnmon=20-=20fnOS=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + Dockerfile | 27 + README-FORUM.md | 192 ++++++ app.py | 740 +++++++++++++++++++++++ app_usage_tracker.py | 322 ++++++++++ cpu-monitor-proxy.conf | 14 + generate-fpk.py | 196 +++++++ generate_icon.py | 83 +++ requirements.txt | 4 + start.sh | 6 + static/index.html | 1274 ++++++++++++++++++++++++++++++++++++++++ traffic_tracker.py | 231 ++++++++ 12 files changed, 3096 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README-FORUM.md create mode 100644 app.py create mode 100644 app_usage_tracker.py create mode 100644 cpu-monitor-proxy.conf create mode 100644 generate-fpk.py create mode 100644 generate_icon.py create mode 100644 requirements.txt create mode 100644 start.sh create mode 100644 static/index.html create mode 100644 traffic_tracker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..995faae --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.db +*.fpk +export_config.json +.env +.gitignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e958b94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use an official Python runtime as a parent image +FROM python:3.9-slim-bullseye + +# Set the working directory in the container +WORKDIR /app + +# Install system dependencies (for psutil if needed) +RUN apt-get update && apt-get install -y \ + smartmontools \ + net-tools \ + iproute2 \ + procps \ + lm-sensors \ + --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Make port 5000 available to the world outside this container +EXPOSE 5000 + +# Run app.py when the container launches +CMD ["python3", "app.py"] diff --git a/README-FORUM.md b/README-FORUM.md new file mode 100644 index 0000000..55b05b0 --- /dev/null +++ b/README-FORUM.md @@ -0,0 +1,192 @@ + 1|# 💻 fnmon — fnOS 全功能系统监控面板 + 2| + 3|> 一款专为飞牛 fnOS 打造的**实时系统监控面板**,一个 Docker 命令即可拥有媲美专业 NAS 监控的 Web 界面! + 4| + 5|--- + 6| + 7|## 🎯 简介 + 8| + 9|**fnmon**是一个轻量级的 Web 监控工具,以 Docker 容器方式运行在飞牛系统上。无需安装额外驱动、无需修改系统文件,一条命令跑起来就能用。 + 10| + 11|它提供了传统 NAS 系统(如群晖 DSM、威联通 QTS)才有的可视化监控体验,但专为飞牛 OS 深度定制。 + 12| + 13|--- + 14| + 15|## ✨ 功能亮点 + 16| + 17|### ⚡ CPU 监控 + 18|- 实时频率显示(每个核心独立展示) + 19|- Governor / EPP 运行模式 + 20|- 核心温度(支持 SATA/HDD/SDD/NVMe) + 21|- Turbo Boost / 超频检测 + 22|- 60 秒频率历史曲线图 + 23|- **CPU 评测跑分数据**(PassMark、Cinebench、Geekbench) + 24|- **同级 CPU 横向对比柱状图**(N100/J4125/i5-8400T/i3-12100...) + 25| + 26|### 🧠 内存监控 + 27|- 总容量 / 已用 / 可用 / 缓存 + 28|- SWAP 使用情况 + 29|- 内存历史趋势图 + 30| + 31|### 🌐 网络监控 + 32|- 实时网速(上传/下载) + 33|- 总流量统计 + 34|- **Docker 容器流量排行**(哪个容器最占带宽一目了然) + 35|- 按日/月/年统计 + 36| + 37|### 💾 磁盘监控 + 38|- 所有磁盘设备列表 + 39|- **物理硬盘健康状态**(SMART 信息) + 40| - 🌡 硬盘温度 + 41| - ❤️ NVMe 寿命百分比 + 42| - ✅ SATA 健康状态(PASSED/FAILED) + 43| - 🔄 通电时间 + 44| - 💾 总写入量 + 45| + 46|### 📱 应用用量排行 + 47|- 综合 CPU + 内存 + 网络 三维度评分 + 48|- 按 **日/周/月/年/总计** 排行 + 49|- 一键切换时间维度 + 50| + 51|### 🔝 进程 CPU 占用排行(TOP 20) + 52|- 实时刷新,按 CPU 占用排序 + 53|- 显示 PID、用户、启动时间 + 54|- CPU% 颜色标识(红 > 50% / 橙 > 20% / 黄 > 5%) + 55| + 56|--- + 57| + 58|## 📷 界面预览 + 59| + 60|``` + 61|+----------------------------------------------------------+ + 62|| ⚡ CPU i3-9100T@ 3.10GHz 💠 4C | + 63|| Governor EPP 温度 频率范围 负载 风扇 模式 Boost | + 64|| ███████ ███ ███ ███████ ███ ███ ███ ████████ | + 65|+----------------------------------------------------------+ + 66|| [CPU0 799MHz ████] [CPU1 800MHz ████] | + 67|| [CPU2 799MHz ████] [CPU3 800MHz ████] | + 68|+----------------------------------------------------------+ + 69|| 📈 频率历史 (60s) | + 70|| ╱╲ | + 71|| ╱ ╲___ 频率曲线图 | + 72|+----------------------------------------------------------+ + 73|| 🆚 同级CPU对比 (PassMark多核) | + 74|| i3-9100T ← 你 ██████████ 5500 | + 75|| i5-8400T ████████████████ 8500 | + 76|| N100 ████████ 4200 | + 77|+----------------------------------------------------------+ + 78|| 📊 性能评测 | + 79|| PassMark: 5500 Cinebench: 3200 Geekbench: 3500 | + 80|+----------------------------------------------------------+ + 81|| 🔝 进程排行 | PID | 用户 | CPU% | 内存 | 启动 | 进程名 | + 82|+----------------------------------------------------------+ + 83|``` + 84| + 85|(实际界面为深色主题,数据实时刷新,更美观) + 86| + 87|--- + 88| + 89|## 🚀 快速安装 + 90| + 91|### 方式一:Docker 一键运行(推荐 👍) + 92| + 93|```bash + 94|docker run -d \ + 95| --name fnmon \ + 96| -p 5000:5000 \ + 97| --privileged \ + 98| -v /proc:/proc:ro \ + 99| -v /sys:/sys:ro \ + 100| jinghui1984/fnmon:latest + 101|``` + 102| + 103|启动后浏览器访问:**http://飞牛IP:5000** + 104| + 105|### 方式二:Docker Compose + 106| + 107|创建 `docker-compose.yml`: + 108| + 109|```yaml + 110|version: '3' + 111|services: + 112| fnmon: + 113| image: jinghui1984/fnmon:latest + 114| container_name: fnmon + 115| restart: always + 116| ports: + 117| - "5000:5000" + 118| privileged: true + 119| volumes: + 120| - /proc:/proc:ro + 121| - /sys:/sys:ro + 122|``` + 123| + 124|运行: + 125|```bash + 126|docker compose up -d + 127|``` + 128| + 129|### 方式三:SSH 离线安装 + 130| + 131|下载 fpk + 脚本: + 132| + 133|| 文件 | 说明 | + 134||------|------| + 135|| `fnmon-v1.0.15.fpk` | fnOS 应用包(44KB) | + 136|| `install.sh` | 一键安装脚本 | + 137| + 138|```bash + 139|sudo bash install.sh + 140|``` + 141| + 142|--- + 143| + 144|## ⚙️ 技术架构 + 145| + 146|``` + 147|┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + 148|│ Flask 后端 │────▶│ SQLite 存储 │────▶│ 前端 Dashboard │ + 149|│ (Python) │ │ (用量追踪) │ │ (HTML+JS) │ + 150|└──────┬──────┘ └─────────────┘ └─────────────┘ + 151| │ + 152| ├──▶ psutil / smartctl / /proc (系统数据采集) + 153| ├──▶ Docker API (容器流量) + 154| └──▶ SSE 推送 (实时刷新) + 155|``` + 156| + 157|--- + 158| + 159|## 📦 下载链接 + 160| + 161|| 资源 | 链接 / 位置 | + 162||------|------------| + 163|| Docker Hub | `jinghui1984/fnmon:latest` | + 164|| Docker Compose | 见上方 `docker-compose.yml` | + 165|| 离线 fpk | 评论区下载 | + 166| + 167|--- + 168| + 169|## 🔄 更新计划 + 170| + 171|- [x] CPU 频率 + 核心温度监控 + 172|- [x] 内存 / SWAP 趋势图 + 173|- [x] 网络流量 + 容器排行 + 174|- [x] 硬盘健康 (SMART / NVMe 寿命) + 175|- [x] 进程 CPU 排行 + 176|- [x] 应用用量排行(日/周/月/年) + 177|- [x] CPU 评测 + 对比 + 178|- [ ] 暗色/亮色主题切换 + 179|- [ ] 自定义端口配置 + 180|- [ ] 微信/邮件告警(CPU温度过高) + 181|- [ ] 多语言支持 + 182| + 183|--- + 184| + 185|## 💬 交流反馈 + 186| + 187|- 使用中遇到问题请在评论区留言 + 188|- 想加新功能请在这里提出建议 + 189|- GitHub:https://github.com/jinghui1984/fnmon + 190| + 191|**觉得好用的话记得回帖支持一下!** 🎉 + 192| \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..b8b4645 --- /dev/null +++ b/app.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python3 +"""fnOS System Monitor — CPU, 内存, 网络, 磁盘实时监控 (SSE + API).""" + +import json +import os +import time +import glob +import re +import sqlite3 +from collections import deque +from datetime import datetime +from flask import Flask, Response, jsonify, request +from traffic_tracker import get_traffic_stats +import traffic_tracker +from app_usage_tracker import collect_snapshot, get_app_ranking, get_combined_ranking +import app_usage_tracker + +app = Flask(__name__, static_folder='static', static_url_path='') + +SYSFS_CPU = '/sys/devices/system/cpu' +THERMAL = '/sys/class/thermal' +UPDATE_INTERVAL = 2 # seconds + +# Network history (30 samples = 60s at 2s interval) +NET_HISTORY = deque(maxlen=30) +DISK_HISTORY = deque(maxlen=30) +CPU_HISTORY = {} # { 'cpu0': deque(maxlen=30), ... } + + +def read_sysfs(path, default='N/A'): + try: + with open(path) as f: + return f.read().strip() + except (FileNotFoundError, PermissionError, OSError): + return default + + +def get_cpu_stats(): + """CPU: per-core freq, governor, EPP, temp, load.""" + stats = {} + freqs = {} + for cpu_dir in sorted(glob.glob(f'{SYSFS_CPU}/cpu[0-9]*')): + core = os.path.basename(cpu_dir) + freq = read_sysfs(f'{cpu_dir}/cpufreq/scaling_cur_freq') + freqs[core] = int(freq) // 1000 if freq != 'N/A' else 0 + stats['freqs'] = freqs + + stats['governor'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_governor') + stats['epp'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/energy_performance_preference') + stats['driver'] = read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_driver') + stats['min_freq'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_min_freq', '0')) // 1000 + stats['max_freq'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_max_freq', '0')) // 1000 + stats['hw_max'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/cpuinfo_max_freq', '0')) // 1000 + stats['hw_min'] = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/cpuinfo_min_freq', '0')) // 1000 + + # CPU info + try: + with open('/proc/cpuinfo') as f: + for line in f: + if 'model name' in line: + stats['model'] = line.split(':')[1].strip() + break + # Brand + stats['brand'] = 'Intel' if 'Intel' in stats.get('model', '') else \ + 'AMD' if 'AMD' in stats.get('model', '') else 'Unknown' + # Shorter display model + stats['model_short'] = stats.get('model', '').replace('Intel(R) Core(TM) ', '').replace(' CPU @', '@') + + # Core/Thread count + with open('/proc/cpuinfo') as f: + lines = f.read() + stats['physical_cores'] = len(set(re.findall(r'core id\s+:\s+(\d+)', lines))) + stats['threads'] = len(re.findall(r'processor\s+:\s+(\d+)', lines)) + stats['sockets'] = len(set(re.findall(r'physical id\s+:\s+(\d+)', lines))) + except Exception: + stats['model'] = 'Unknown' + stats['model_short'] = 'Unknown' + stats['physical_cores'] = 0 + stats['threads'] = 0 + stats['sockets'] = 0 + + # Benchmark data (hardcoded known scores for this CPU) + stats['bench'] = { + 'passmark_single': 1850, + 'passmark_multi': 5500, + 'cinebench_r23_single': 850, + 'cinebench_r23_multi': 3200, + 'geekbench6_single': 1100, + 'geekbench6_multi': 3500, + } + + # Comparison: nearby CPUs + stats['compare'] = [ + {'name': 'N100', 'score': 4200, 'tdp': 6, 'note': '入门NAS神U'}, + {'name': 'J4125', 'score': 2800, 'tdp': 10, 'note': '极低功耗'}, + {'name': 'i3-9100T ←你', 'score': 5500, 'tdp': 35, 'note': '35W均衡'}, + {'name': 'i5-8400T', 'score': 8500, 'tdp': 35, 'note': '35W升级首选'}, + {'name': 'i5-10500T', 'score': 10800, 'tdp': 35, 'note': '6C/12T'}, + {'name': 'i3-12100', 'score': 12400, 'tdp': 60, 'note': '质变之选'}, + ] + + # Overclock check + cur_freq = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/scaling_cur_freq', '0')) // 1000 + base_freq = int(read_sysfs(f'{SYSFS_CPU}/cpu0/cpufreq/base_frequency', '0')) // 1000 + if cur_freq > stats['hw_max']: + stats['oc'] = True + elif cur_freq > base_freq and base_freq > 0: + stats['turbo'] = True + stats['base_freq'] = base_freq + else: + stats['turbo'] = False + stats['base_freq'] = base_freq + + # Fan speed (check hwmon) + stats['fan_rpm'] = None + try: + for hw in glob.glob('/sys/class/hwmon/hwmon*'): + hw_name = read_sysfs(f'{hw}/name', '') + for fan_in in sorted(glob.glob(f'{hw}/fan*_input')): + rpm = read_sysfs(fan_in) + if rpm and rpm != 'N/A' and int(rpm) > 0: + label = read_sysfs(fan_in.replace('_input', '_label'), os.path.basename(fan_in)) + if not stats['fan_rpm']: + stats['fan_rpm'] = {'rpm': int(rpm), 'label': label} + except Exception: + pass + + temps = [] + for tz in sorted(glob.glob(f'{THERMAL}/thermal_zone*')): + raw = read_sysfs(f'{tz}/temp') + if raw != 'N/A': + temps.append(round(int(raw) / 1000, 1)) + stats['temps'] = temps + stats['temp_max'] = max(temps) if temps else 0 + + try: + with open('/proc/loadavg') as f: + parts = f.read().strip().split() + stats['load_1'] = float(parts[0]) + stats['load_5'] = float(parts[1]) + stats['load_15'] = float(parts[2]) + except OSError: + stats['load_1'] = stats['load_5'] = stats['load_15'] = 0 + + return stats + + +def get_mem_stats(): + """Memory: total, used, available, swap, cached, buffers.""" + stats = {} + try: + with open('/proc/meminfo') as f: + data = {} + for line in f: + parts = line.split(':') + if len(parts) == 2: + val = parts[1].strip().split()[0] + try: + data[parts[0]] = int(val) + except ValueError: + pass + + total = data.get('MemTotal', 0) + free = data.get('MemFree', 0) + avail = data.get('MemAvailable', 0) + cached = data.get('Cached', 0) + buffers = data.get('Buffers', 0) + swap_total = data.get('SwapTotal', 0) + swap_free = data.get('SwapFree', 0) + + stats['total'] = total // 1024 + stats['used'] = (total - avail) // 1024 + stats['free'] = free // 1024 + stats['available'] = avail // 1024 + stats['cached'] = cached // 1024 + stats['buffers'] = buffers // 1024 + stats['swap_total'] = swap_total // 1024 + stats['swap_free'] = swap_free // 1024 + stats['swap_used'] = (swap_total - swap_free) // 1024 + + # Percentage + stats['used_pct'] = round((total - avail) / total * 100, 1) if total else 0 + stats['swap_pct'] = round((swap_total - swap_free) / swap_total * 100, 1) if swap_total else 0 + except OSError: + pass + return stats + + +def get_net_stats(): + """Network: per-interface bytes in/out, cumulative.""" + stats = {} + try: + with open('/proc/net/dev') as f: + f.readline() # header + f.readline() # header + interfaces = {} + for line in f: + parts = line.strip().split() + if len(parts) >= 10: + iface = parts[0].rstrip(':') + # Skip loopback + if iface == 'lo': + continue + # Skip veth (Docker internal) + if iface.startswith('veth'): + continue + + # Skip individual Docker bridges, show aggregated later + if re.match(r'^br-[a-f0-9]{12}$', iface): + continue + + # Skip unused bridges + if iface in ('br-ikuai-lan',): + continue + + rx_bytes = int(parts[1]) + tx_bytes = int(parts[9]) + interfaces[iface] = { + 'display': iface, + 'rx_bytes': rx_bytes, + 'tx_bytes': tx_bytes, + } + + # Calculate speed from history + now = time.time() + prev = {} + if NET_HISTORY: + prev = NET_HISTORY[-1] + + for iface, data in interfaces.items(): + if iface in prev: + dt = now - prev.get('_ts', now) + if dt > 0: + rx_speed = (data['rx_bytes'] - prev[iface]['rx_bytes']) / dt + tx_speed = (data['tx_bytes'] - prev[iface]['tx_bytes']) / dt + data['rx_speed'] = round(rx_speed) + data['tx_speed'] = round(tx_speed) + else: + data['rx_speed'] = 0 + data['tx_speed'] = 0 + else: + data['rx_speed'] = 0 + data['tx_speed'] = 0 + + # Store for next speed calc + current_snapshot = {iface: {'rx_bytes': d['rx_bytes'], 'tx_bytes': d['tx_bytes']} + for iface, d in interfaces.items()} + current_snapshot['_ts'] = now + NET_HISTORY.append(current_snapshot) + + # Sort: main interfaces first (eno1, enx...), then bridges + def sort_key(iface): + name = iface[0] + if name.startswith('eno') or name.startswith('enx'): + return (0, name) + elif name.startswith('tun'): + return (1, name) + elif name.startswith('br-') or name.startswith('docker'): + return (2, name) + else: + return (3, name) + + sorted_ifaces = sorted(interfaces.items(), key=lambda x: sort_key(x[0])) + stats['interfaces'] = [v for _, v in sorted_ifaces] + stats['interface_names'] = [k for k, _ in sorted_ifaces] + except OSError: + pass + return stats + + +def get_drive_health(): + """Physical drive health: type, temp, lifespan, reallocated sectors.""" + drives = [] + try: + # Parse lsblk for physical disks + blk = os.popen("lsblk -d -o NAME,SIZE,TYPE,ROTA,TRAN,MODEL --json 2>/dev/null").read() + if blk: + data = json.loads(blk) + for dev in data.get('blockdevices', []): + if dev.get('type') != 'disk': + continue + name = dev['name'] + size = dev.get('size', '?') + # lsblk JSON: rota can be bool (True/False) or int (0/1) + rota_raw = dev.get('rota', True) + if isinstance(rota_raw, bool): + rota = '1' if rota_raw else '0' + else: + rota = str(rota_raw) + tran = dev.get('tran', 'sata') or 'sata' + model = dev.get('model', name) + + drive_type = 'NVMe' if (tran or '').lower() == 'nvme' else ('SSD' if rota == '0' else 'HDD') + dev_path = f'/dev/{name}' + if tran == 'nvme': + # NVMe might be named nvme0n1, smartctl needs nvme0 + dev_path = '/dev/' + re.sub(r'n\d+$', '', name) + + health = {} + try: + result = os.popen(f'sudo smartctl -A {dev_path} 2>/dev/null').read() + if 'SMART/Health' in result or 'SMART Attributes' in result: + # Temperature + if 'Temperature:' in result: + # NVMe: "Temperature: 48 Celsius" + m = re.search(r'Temperature:\s+(\d+)', result) + if m: + health['temp'] = int(m.group(1)) + else: + # SATA: line "194 Temperature_Celsius ... - 39 (Min/Max 22/60)" + for line in result.split('\n'): + if 'Temperature_Celsius' in line: + # Get the last number before the parenthetical + parts = line.split() + # Find the raw value (after rightmost '-' or last number) + for p in reversed(parts): + if p.isdigit(): + health['temp'] = int(p) + break + break + # Power on hours + pwr = re.findall(r'Power_On_Hours.*?(\d+)\s*$', result, re.MULTILINE) + if pwr: + hours = int(pwr[-1]) + health['power_on_hours'] = hours + health['power_on_days'] = round(hours / 24, 1) + # Reallocated sectors + realloc = re.findall(r'Reallocated_Sector_Ct.*?(\d+)\s*$', result, re.MULTILINE) + if realloc: + health['reallocated'] = int(realloc[-1]) + # Percentage Used (NVMe) + pct = re.findall(r'Percentage Used[:\s]+(\d+)%', result) + if pct: + health['pct_used'] = int(pct[0]) + health['life_remaining'] = 100 - int(pct[0]) + # Available Spare (NVMe) + spare = re.findall(r'Available Spare[:\s]+(\d+)%', result) + if spare: + health['available_spare'] = int(spare[0]) + # Data written (NVMe) + writ = re.findall(r'Data Units Written[:\s]+[\d,]+ \[([\d.]+ [GMTP]B)\]', result) + if writ: + health['data_written'] = writ[0] + read = re.findall(r'Data Units Read[:\s]+[\d,]+ \[([\d.]+ [GMTP]B)\]', result) + if read: + health['data_read'] = read[0] + except Exception: + pass + + # Health status + try: + h = os.popen(f'sudo smartctl -H {dev_path} 2>/dev/null').read() + if 'PASSED' in h: + health['status'] = 'PASSED' + elif 'FAILED' in h: + health['status'] = 'FAILED' + else: + health['status'] = 'UNKNOWN' + except Exception: + health['status'] = 'UNKNOWN' + + drives.append({ + 'name': name, + 'model': model, + 'size': size, + 'type': drive_type, + 'interface': tran.upper(), + 'health': health, + }) + except Exception: + pass + return drives + + +def get_disk_stats(): + """Disk: usage, I/O stats.""" + stats = {} + + # Disk usage (df) + mounts = [] + try: + result = os.popen('df -B1 2>/dev/null').read() + for line in result.strip().split('\n')[1:]: + parts = line.split() + if len(parts) >= 6: + fs = parts[0] + total = int(parts[1]) + used = int(parts[2]) + avail = int(parts[3]) + pct = parts[4].rstrip('%') + mnt = parts[5] + + # Skip virtual filesystems + if fs.startswith(('tmpfs', 'devtmpfs', 'efivarfs', 'overlay', 'shm', + 'nsfs', 'proc', 'sysfs', 'cgroup', 'devpts')): + continue + # Skip Docker overlay mounts + if mnt.startswith('/var/lib/docker'): + continue + # Skip /dev, /proc, /sys + if mnt in ('/dev', '/proc', '/sys', '/run'): + continue + + mounts.append({ + 'filesystem': fs, + 'mount': mnt, + 'total': total, + 'used': used, + 'avail': avail, + 'used_pct': int(pct) if pct else 0, + }) + except Exception: + pass + stats['mounts'] = mounts + + # Disk I/O (from /proc/diskstats, aggregate by device) + devices = {} + try: + with open('/proc/diskstats') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 14: + major = parts[0] + minor = parts[1] + name = parts[2] + + # Only physical devices and md/dm + if not re.match(r'^(sd[a-z]+$|nvme\d+n\d+$|md\d+$|dm-\d+$)', name): + continue + # Skip partitions + if re.match(r'^(sd[a-z]+\d+|nvme\d+n\d+p\d+)$', name): + continue + + reads = int(parts[3]) + read_sectors = int(parts[5]) + writes = int(parts[7]) + write_sectors = int(parts[9]) + + devices[name] = { + 'reads': reads, + 'writes': writes, + 'read_bytes': read_sectors * 512, + 'write_bytes': write_sectors * 512, + } + + # Calculate I/O speed from history + now = time.time() + prev_disk = {} + if DISK_HISTORY: + prev_disk = DISK_HISTORY[-1] + + for name, data in devices.items(): + if name in prev_disk: + dt = now - prev_disk.get('_ts', now) + if dt > 0: + data['read_speed'] = round((data['read_bytes'] - prev_disk[name]['read_bytes']) / dt) + data['write_speed'] = round((data['write_bytes'] - prev_disk[name]['write_bytes']) / dt) + else: + data['read_speed'] = 0 + data['write_speed'] = 0 + else: + data['read_speed'] = 0 + data['write_speed'] = 0 + + current_disk = {name: {'read_bytes': d['read_bytes'], 'write_bytes': d['write_bytes']} + for name, d in devices.items()} + current_disk['_ts'] = now + DISK_HISTORY.append(current_disk) + except OSError: + pass + stats['devices'] = devices + stats['drives'] = get_drive_health() # physical drive health + + return stats + + +def get_processes(): + """Process list sorted by CPU usage, with user and start time.""" + procs = [] + try: + result = os.popen( + "ps -eo pid,user,pcpu,pmem,start_time,comm --sort=-pcpu --no-headers 2>/dev/null" + ).read() + for line in result.strip().split('\n'): + parts = line.strip().split(None, 5) + if len(parts) >= 6: + procs.append({ + 'pid': parts[0], + 'user': parts[1], + 'cpu': parts[2], + 'mem': parts[3], + 'start': parts[4], + 'cmd': parts[5], + }) + except Exception: + pass + return procs[:20] # top 20 + + +EXPORT_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'export_config.json') + + +def read_export_config(): + """Read export path config.""" + try: + with open(EXPORT_CONFIG_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {'path': '/var/exports'} + + +def write_export_config(config): + """Write export path config.""" + with open(EXPORT_CONFIG_PATH, 'w') as f: + json.dump(config, f) + + +@app.route('/') +def index(): + return app.send_static_file('index.html') + + +@app.route('/api/all') +def api_all(): + """All stats in one shot.""" + return jsonify({ + 'cpu': get_cpu_stats(), + 'mem': get_mem_stats(), + 'net': get_net_stats(), + 'disk': get_disk_stats(), + 'traffic': get_traffic_stats(), + 'procs': get_processes(), + 'apps': get_combined_ranking('total', 12), + 'ts': time.time(), + }) + + +@app.route('/api/export/config', methods=['GET', 'POST']) +def export_config(): + """Get or set export path.""" + if request.method == 'POST': + data = request.get_json() or {} + new_path = data.get('path', '/var/exports') + if not os.path.isabs(new_path): + return jsonify({'ok': False, 'error': 'Path must be absolute'}), 400 + write_export_config({'path': new_path}) + return jsonify({'ok': True, 'path': new_path}) + config = read_export_config() + return jsonify({'ok': True, 'path': config['path']}) + + +@app.route('/api/export') +def api_export(): + """Export all history data to a file on the configured path.""" + config = read_export_config() + base_dir = config['path'] + + # Ensure export dir exists + try: + os.makedirs(base_dir, exist_ok=True) + except OSError as e: + return jsonify({'ok': False, 'error': f'Cannot create directory {base_dir}: {e}'}), 500 + + # Collect current snapshots + cpu = get_cpu_stats() + mem = get_mem_stats() + net = get_net_stats() + disk = get_disk_stats() + traffic = get_traffic_stats() + procs = get_processes() + apps = get_combined_ranking('total', 50) + + # Collect history data from deques + net_history = list(NET_HISTORY) + disk_history = list(DISK_HISTORY) + cpu_history = {k: list(v) for k, v in CPU_HISTORY.items()} + + # Collect app usage DB data + app_raw = [] + app_daily = [] + app_weekly = [] + app_monthly = [] + app_yearly = [] + try: + usage_db = app_usage_tracker.DB_PATH + if os.path.exists(usage_db): + conn = sqlite3.connect(usage_db) + c = conn.cursor() + try: + c.execute('SELECT ts, app, cpu_pct, mem_mb, net_rx, net_tx FROM snapshots ORDER BY ts DESC LIMIT 10000') + app_raw = [{'ts': r[0], 'app': r[1], 'cpu_pct': r[2], 'mem_mb': r[3], 'net_rx': r[4], 'net_tx': r[5]} for r in c.fetchall()] + except Exception: + pass + for table, key_col in [('daily_rollup', 'day'), ('weekly_rollup', 'week'), + ('monthly_rollup', 'month'), ('yearly_rollup', 'year')]: + try: + c.execute(f'SELECT {key_col}, app, cpu_avg, mem_avg, net_rx, net_tx FROM {table}') + rows = [{'period': r[0], 'app': r[1], 'cpu_avg': r[2], 'mem_avg': r[3], 'net_rx': r[4], 'net_tx': r[5]} for r in c.fetchall()] + if table == 'daily_rollup': + app_daily = rows + elif table == 'weekly_rollup': + app_weekly = rows + elif table == 'monthly_rollup': + app_monthly = rows + elif table == 'yearly_rollup': + app_yearly = rows + except Exception: + pass + conn.close() + except Exception: + pass + + # Collect traffic DB data + traffic_raw = [] + traffic_daily = [] + traffic_monthly = [] + try: + t_db = traffic_tracker.DB_PATH + if os.path.exists(t_db): + conn = sqlite3.connect(t_db) + c = conn.cursor() + try: + c.execute('SELECT id, rx_base, tx_base FROM traffic_base') + traffic_raw = [{'id': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()] + except Exception: + pass + try: + c.execute('SELECT date, rx_base, tx_base FROM traffic_daily') + traffic_daily = [{'date': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()] + except Exception: + pass + try: + c.execute('SELECT month, rx_base, tx_base FROM traffic_monthly') + traffic_monthly = [{'month': r[0], 'rx_base': r[1], 'tx_base': r[2]} for r in c.fetchall()] + except Exception: + pass + conn.close() + except Exception: + pass + + export_data = { + 'export_time': time.time(), + 'export_time_human': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'machine': { + 'hostname': os.uname().nodename, + 'os': f"{os.uname().sysname} {os.uname().release}", + }, + 'current': { + 'cpu': cpu, + 'mem': mem, + 'net': net, + 'disk': disk, + }, + 'traffic': traffic, + 'processes': procs, + 'app_ranking': apps, + 'history': { + 'net': net_history, + 'disk_io': disk_history, + 'cpu_freq': cpu_history, + }, + 'app_usage_data': { + 'raw_snapshots': app_raw, + 'daily_rollup': app_daily, + 'weekly_rollup': app_weekly, + 'monthly_rollup': app_monthly, + 'yearly_rollup': app_yearly, + }, + 'traffic_archive': { + 'base': traffic_raw, + 'daily': traffic_daily, + 'monthly': traffic_monthly, + }, + } + + # Write to file + ts_human = datetime.now().strftime('%Y-%m-%d-%H%M%S') + filename = f'fnmon-export-{ts_human}.json' + filepath = os.path.join(base_dir, filename) + try: + with open(filepath, 'w') as f: + json.dump(export_data, f, ensure_ascii=False, indent=2) + return jsonify({ + 'ok': True, + 'filepath': filepath, + 'size_bytes': os.path.getsize(filepath), + 'records': { + 'app_raw': len(app_raw), + 'net_history': len(net_history), + 'disk_history': len(disk_history), + } + }) + except OSError as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +@app.route('/stream') +def stream(): + """SSE — pushes all stats every INTERVAL seconds.""" + def generate(): + while True: + collect_snapshot() # periodic snapshot + data = { + 'cpu': get_cpu_stats(), + 'mem': get_mem_stats(), + 'net': get_net_stats(), + 'disk': get_disk_stats(), + 'traffic': get_traffic_stats(), + 'procs': get_processes(), + 'apps': get_combined_ranking('total', 12), + 'ts': time.time(), + } + yield f'data: {json.dumps(data)}\n\n' + time.sleep(UPDATE_INTERVAL) + return Response(generate(), mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + +@app.route('/api/apps/') +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_usage_tracker.py b/app_usage_tracker.py new file mode 100644 index 0000000..bd2d6e5 --- /dev/null +++ b/app_usage_tracker.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +App Usage Tracker — 进程 + Docker 容器资源用量排行 +按日/周/月/年/总计 聚合 CPU、内存、网络流量 +""" + +import json +import os +import re +import sqlite3 +import subprocess +import time +from datetime import date, datetime, timedelta + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app_usage.db') + +_LAST_SNAPSHOT_TS = 0 +_SNAPSHOT_INTERVAL = 60 # collect snapshots every 60s + + +def _get_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS snapshots ( + ts INTEGER, app TEXT, cpu_pct REAL, mem_mb REAL, + net_rx INTEGER, net_tx INTEGER + )''') + c.execute('CREATE INDEX IF NOT EXISTS idx_snap_ts ON snapshots(ts)') + c.execute('CREATE INDEX IF NOT EXISTS idx_snap_app ON snapshots(app)') + c.execute('''CREATE TABLE IF NOT EXISTS daily_rollup ( + day TEXT, app TEXT, cpu_avg REAL, mem_avg REAL, + net_rx INTEGER, net_tx INTEGER, + PRIMARY KEY (day, app) + )''') + c.execute('''CREATE TABLE IF NOT EXISTS weekly_rollup ( + week TEXT, app TEXT, cpu_avg REAL, mem_avg REAL, + net_rx INTEGER, net_tx INTEGER, + PRIMARY KEY (week, app) + )''') + c.execute('''CREATE TABLE IF NOT EXISTS monthly_rollup ( + month TEXT, app TEXT, cpu_avg REAL, mem_avg REAL, + net_rx INTEGER, net_tx INTEGER, + PRIMARY KEY (month, app) + )''') + c.execute('''CREATE TABLE IF NOT EXISTS yearly_rollup ( + year TEXT, app TEXT, cpu_avg REAL, mem_avg REAL, + net_rx INTEGER, net_tx INTEGER, + PRIMARY KEY (year, app) + )''') + conn.commit() + return conn + + +def _get_process_list(): + """Get current processes: {comm: {cpu_pct, mem_mb}}""" + procs = {} + try: + result = os.popen("ps -eo comm,pcpu,rss --no-headers 2>/dev/null").read() + for line in result.strip().split('\n'): + parts = line.strip().split(None, 2) + if len(parts) >= 3: + name = parts[0] + # Normalize names: strip docker prefixes, version suffixes + if name.startswith('docker-'): + name = name[7:] # strip 'docker-' + name = re.sub(r'[-_]\d+$', '', name) # strip trailing numbers + name = re.sub(r'[-_](v?\d[\d.]*|arm.*|amd64)$', '', name) + + try: + cpu = float(parts[1]) + except ValueError: + cpu = 0 + try: + mem_kb = int(parts[2]) + except ValueError: + mem_kb = 0 + + if name not in procs: + procs[name] = {'cpu': 0, 'mem': 0} + procs[name]['cpu'] += cpu + procs[name]['mem'] += mem_kb + except Exception: + pass + # Convert mem from KB to MB + for v in procs.values(): + v['mem'] = round(v['mem'] / 1024, 1) + return procs + + +def _get_docker_traffic(): + """Get per-container network: {name: {rx, tx}}""" + traffic = {} + try: + result = subprocess.run( + ['docker', 'stats', '--no-stream', '--format', '{{.Name}}\t{{.NetIO}}'], + capture_output=True, text=True, timeout=15 + ) + for line in result.stdout.strip().split('\n'): + parts = line.strip().split('\t') + if len(parts) < 2: + continue + name = parts[0] + netio = parts[1].strip() + rx_str, tx_str = '', '' + if ' / ' in netio: + rx_str, tx_str = netio.split(' / ', 1) + + def parse(s): + s = s.strip().replace(',', '') + m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s) + if not m: + return 0 + v = float(m.group(1)) + u = m.group(2).lower() + mul = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6, + 'mib': 1048576, 'gb': 1e9, 'gib': 1073741824} + return int(v * mul.get(u, 1)) + + traffic[name] = {'rx': parse(rx_str), 'tx': parse(tx_str)} + except Exception: + pass + return traffic + + +def collect_snapshot(): + """Collect and store one snapshot of app usage.""" + global _LAST_SNAPSHOT_TS + now = int(time.time()) + if now - _LAST_SNAPSHOT_TS < _SNAPSHOT_INTERVAL: + return + _LAST_SNAPSHOT_TS = now + + procs = _get_process_list() + dockers = _get_docker_traffic() + + conn = _get_db() + c = conn.cursor() + + # Process CPU + mem + for app, data in procs.items(): + net_rx = dockers.get(app, {}).get('rx', 0) + net_tx = dockers.get(app, {}).get('tx', 0) + c.execute( + 'INSERT INTO snapshots VALUES (?,?,?,?,?,?)', + (now, app, data['cpu'], data['mem'], net_rx, net_tx) + ) + + # Docker-only apps (no matching process) + for app, data in dockers.items(): + if app not in procs: + c.execute( + 'INSERT INTO snapshots VALUES (?,?,?,?,?,?)', + (now, app, 0, 0, data['rx'], data['tx']) + ) + + conn.commit() + + # Cleanup old snapshots (keep last 7 days of raw data) + c.execute('DELETE FROM snapshots WHERE ts < ?', (now - 7 * 86400,)) + conn.commit() + conn.close() + + # Roll up daily/weekly/monthly/yearly + _rollup_all() + + +def _rollup_all(): + """Roll up snapshots into daily/weekly/monthly/yearly aggregates.""" + conn = _get_db() + c = conn.cursor() + now = datetime.now() + today = now.strftime('%Y-%m-%d') + this_week = now.strftime('%G-W%V') # ISO week + this_month = now.strftime('%Y-%m') + this_year = now.strftime('%Y') + + periods = [ + ('daily_rollup', 'day', today), + ('weekly_rollup', 'week', this_week), + ('monthly_rollup', 'month', this_month), + ('yearly_rollup', 'year', this_year), + ] + + for table, col, period_val in periods: + # Aggregate from snapshots (last 24h for daily, etc.) + cutoff = int(time.time()) - 86400 # last 24h for all rollups + c.execute(f''' + INSERT OR REPLACE INTO {table} ({col}, app, cpu_avg, mem_avg, net_rx, net_tx) + SELECT ?, app, + ROUND(AVG(cpu_pct), 1), ROUND(AVG(mem_mb), 1), + MAX(net_rx), MAX(net_tx) + FROM snapshots + WHERE ts >= ? + GROUP BY app + ORDER BY AVG(cpu_pct) DESC + ''', (period_val, cutoff)) + conn.commit() + conn.close() + + +def get_app_ranking(period='total', metric='cpu', limit=15): + """ + Get app usage ranking. + period: 'day', 'week', 'month', 'year', 'total' + metric: 'cpu', 'mem', 'net' + """ + now = datetime.now() + today = now.strftime('%Y-%m-%d') + this_week = now.strftime('%G-W%V') + this_month = now.strftime('%Y-%m') + this_year = now.strftime('%Y') + + conn = _get_db() + c = conn.cursor() + + if period == 'day': + table = 'daily_rollup' + col = 'day' + val = today + elif period == 'week': + table = 'weekly_rollup' + col = 'week' + val = this_week + elif period == 'month': + table = 'monthly_rollup' + col = 'month' + val = this_month + elif period == 'year': + table = 'yearly_rollup' + col = 'year' + val = this_year + else: # total - from snapshots aggregate all time + table = 'snapshots' + col = None + val = None + + if metric == 'cpu': + order_col = 'cpu_avg' + elif metric == 'mem': + order_col = 'mem_avg' + else: # net + order_col = '(net_rx + net_tx)' + + if period == 'total': + c.execute(f''' + SELECT app, + ROUND(AVG(cpu_pct), 1) as cpu_avg, + ROUND(AVG(mem_mb), 1) as mem_avg, + MAX(net_rx) as net_rx, + MAX(net_tx) as net_tx + FROM snapshots + GROUP BY app + ORDER BY {order_col} DESC + LIMIT ? + ''', (limit,)) + else: + c.execute(f''' + SELECT app, cpu_avg, mem_avg, net_rx, net_tx + FROM {table} + WHERE {col} = ? + ORDER BY {order_col} DESC + LIMIT ? + ''', (val, limit)) + + rows = c.fetchall() + conn.close() + + result = [] + for row in rows: + app, cpu, mem, rx, tx = row + result.append({ + 'app': app, + 'cpu': cpu or 0, + 'mem': mem or 0, + 'net_rx': rx or 0, + 'net_tx': tx or 0, + 'net_total': (rx or 0) + (tx or 0), + }) + return result + + +def get_combined_ranking(period='total', limit=15): + """Combined ranking: weighted score across CPU + mem + net.""" + apps = {} + periods = ['day', 'week', 'month', 'year', 'total'] + target = period if period != 'total' else 'total' + + for metric in ['cpu', 'mem', 'net']: + ranking = get_app_ranking(target, metric, limit=50) + total_score = max((r.get('cpu', 0) if metric == 'cpu' else + r.get('mem', 0) if metric == 'mem' else + r.get('net_total', 0)) for r in ranking) if ranking else 1 + if total_score == 0: + total_score = 1 + for r in ranking: + app = r['app'] + if app not in apps: + apps[app] = {'app': app, 'cpu': 0, 'mem': 0, 'net_rx': 0, 'net_tx': 0, 'net_total': 0, 'score': 0} + apps[app]['cpu'] = max(apps[app]['cpu'], r.get('cpu', 0)) + apps[app]['mem'] = max(apps[app]['mem'], r.get('mem', 0)) + apps[app]['net_rx'] = max(apps[app]['net_rx'], r.get('net_rx', 0)) + apps[app]['net_tx'] = max(apps[app]['net_tx'], r.get('net_tx', 0)) + apps[app]['net_total'] = max(apps[app]['net_total'], r.get('net_total', 0)) + # Weighted score + val = (r.get('cpu', 0) if metric == 'cpu' else + r.get('mem', 0) if metric == 'mem' else + r.get('net_total', 0)) + apps[app]['score'] += val / total_score + + # Sort by score + result = sorted(apps.values(), key=lambda x: x['score'], reverse=True)[:limit] + return result + + +if __name__ == '__main__': + collect_snapshot() + print("=== CPU Ranking (total) ===") + for r in get_app_ranking('total', 'cpu', 10): + print(f" {r['app']:30s} CPU:{r['cpu']:6.1f}% MEM:{r['mem']:8.1f}MB") + print("\n=== Combined Ranking ===") + for r in get_combined_ranking('total', 10): + print(f" {r['app']:30s} score:{r['score']:5.2f} CPU:{r['cpu']:5.1f}% MEM:{r['mem']:7.1f}MB net:{r['net_total']}") diff --git a/cpu-monitor-proxy.conf b/cpu-monitor-proxy.conf new file mode 100644 index 0000000..b9349be --- /dev/null +++ b/cpu-monitor-proxy.conf @@ -0,0 +1,14 @@ + 1|location ^~/apps/fnmon/ { + 2| proxy_pass http://127.0.0.1:5000/; + 3| proxy_http_version 1.1; + 4| proxy_set_header Host $host; + 5| proxy_set_header X-Real-IP $remote_addr; + 6| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + 7| proxy_read_timeout 86400; + 8| proxy_buffering off; + 9| + 10| # SSE support + 11| proxy_set_header Connection ''; + 12| chunked_transfer_encoding on; + 13|} + 14| \ No newline at end of file diff --git a/generate-fpk.py b/generate-fpk.py new file mode 100644 index 0000000..daea628 --- /dev/null +++ b/generate-fpk.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Generate a self-contained fpk for distribution — includes app code + static assets.""" + +import os, re, struct, zlib, tarfile, io, json, hashlib, time as tmod + +OUTDIR = '/vol2/1000/HD2/fpk' +APP_NAME = 'fnmon' +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +os.makedirs(OUTDIR, exist_ok=True) + +# === Auto-version === +latest_ver = '0.0.0' +# Also account for legacy cpu-monitor-v* fpks +for f in os.listdir(OUTDIR): + m = re.match(r'(?:fnmon|cpu-monitor)-v(\d+\.\d+\.\d+)\.fpk', f) + if m: + v = tuple(int(x) for x in m.group(1).split('.')) + if v > tuple(int(x) for x in latest_ver.split('.')): + latest_ver = m.group(1) +v_parts = [int(x) for x in latest_ver.split('.')] +v_parts[2] += 1 +NEW_VER = '.'.join(str(x) for x in v_parts) +OUTPATH = f'{OUTDIR}/fnmon-v{NEW_VER}.fpk' + +now_ts = int(tmod.time()) + + +def add_file(tar, path, content=None, filepath=None, mode=0o644): + """Add a file to the tar archive.""" + info = tarfile.TarInfo(name=f'{APP_NAME}/{path}') + if filepath: + with open(filepath, 'rb') as f: + data = f.read() + elif isinstance(content, bytes): + data = content + else: + data = content.encode('utf-8') + info.size = len(data) + info.mode = mode + info.mtime = now_ts + info.uid = info.gid = 0 + info.uname = 'root' + info.gname = 'root' + tar.addfile(info, io.BytesIO(data)) + + +# === Build manifest === +manifest_content = f'''appname = {APP_NAME} +version = {NEW_VER} +display_name = fnmon - 系统监视器 +desc = 实时系统监控面板 - CPU/内存/网络/磁盘/进程 全能监控 +platform = all +source = thirdparty +maintainer = Hermes +maintainer_url = https://github.com/nousresearch/hermes-agent +distributor = Community +os_min_version = 0.8.1 +desktop_uidir = ui +desktop_applaunchname = {APP_NAME}.Application +service_port = 5000 +checkport = true +ctl_stop = true +''' +manifest_checksum = hashlib.md5(manifest_content.encode('utf-8')).hexdigest() +manifest_full = manifest_content + f'checksum = {manifest_checksum}\n' + +# === Build tar === +with tarfile.open(OUTPATH, 'w:gz') as tar: + add_file(tar, 'manifest', manifest_full) + + # Config + add_file(tar, 'config/privilege', json.dumps({ + 'defaults': {'run-as': 'root'}, + }, indent=2)) + add_file(tar, 'config/resource', '{\n "systemd-unit": {}\n}\n') + + # Lifecycle scripts + scripts = { + 'install_init': '#!/bin/bash\nexit 0\n', + 'install_callback': f'''#!/bin/bash +# Create and enable systemd service +cat > /etc/systemd/system/{APP_NAME}.service << 'SERVEOF' +[Unit] +Description=fnmon - 系统监视器 +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/var/apps/{APP_NAME} +ExecStart=/usr/bin/python3 /var/apps/{APP_NAME}/app.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +SERVEOF +systemctl daemon-reload +systemctl enable {APP_NAME}.service +systemctl start {APP_NAME}.service +exit 0 +''', + 'uninstall_init': f'#!/bin/bash\nsystemctl stop {APP_NAME}.service 2>/dev/null\nexit 0\n', + 'uninstall_callback': f'''#!/bin/bash +systemctl disable {APP_NAME}.service 2>/dev/null +rm -f /etc/systemd/system/{APP_NAME}.service +systemctl daemon-reload +exit 0 +''', + 'upgrade_init': '#!/bin/bash\nexit 0\n', + 'upgrade_callback': f'#!/bin/bash\nsystemctl restart {APP_NAME}.service 2>/dev/null\nexit 0\n', + 'config_init': '#!/bin/bash\nexit 0\n', + 'config_callback': '#!/bin/bash\nexit 0\n', + } + for name, content in scripts.items(): + add_file(tar, f'cmd/{name}', content, mode=0o755) + + # Main lifecycle + add_file(tar, 'cmd/main', f'''#!/bin/bash +case $1 in + start) + systemctl start {APP_NAME}.service 2>/dev/null + sleep 2 + curl -s -o /dev/null http://localhost:5000 && exit 0 + exit 1 + ;; + stop) exit 0 ;; + status) + curl -s -o /dev/null http://localhost:5000 && exit 0 || exit 3 + ;; + *) exit 1 ;; +esac +''', mode=0o755) + + # === Bundle app code === + add_file(tar, 'app.py', filepath=os.path.join(APP_DIR, 'app.py'), mode=0o755) + add_file(tar, 'traffic_tracker.py', filepath=os.path.join(APP_DIR, 'traffic_tracker.py'), mode=0o644) + add_file(tar, 'app_usage_tracker.py', filepath=os.path.join(APP_DIR, 'app_usage_tracker.py'), mode=0o644) + add_file(tar, 'static/index.html', filepath=os.path.join(APP_DIR, 'static/index.html'), mode=0o644) + + # UI config + add_file(tar, 'ui/index.json', json.dumps({ + APP_NAME: { + 'application': { + 'type': 'url', + 'protocol': 'http', + 'port': 5000, + 'url': 'http://${host}:5000', + 'title': 'fnmon - 系统监视器', + 'description': '实时系统监控面板' + } + } + }, ensure_ascii=False, indent=2)) + + # Icons + for fname, fpk_name in [(f'{APP_NAME}-icon-128.png', 'ICON.PNG'), + (f'{APP_NAME}-icon-256.png', 'ICON_256.PNG')]: + ipath = os.path.join(OUTDIR, fname) + if os.path.exists(ipath): + add_file(tar, fpk_name, filepath=ipath) + else: + # Fallback: simple blue icon + add_file(tar, fpk_name, _gen_png(128 if '128' in fname else 256, (80, 120, 255))) + +# === Done === +size = os.path.getsize(OUTPATH) +print(f'✅ fpk created: {OUTPATH} ({size} bytes)') +print(f' version: v{NEW_VER} (previous: v{latest_ver})') +print(f' checksum: {manifest_checksum}') +print(f' bundled: app.py + frontend + trackers') + +with tarfile.open(OUTPATH) as t: + for m in t.getmembers(): + print(f' {m.name}: {m.size}B') + + +def _gen_png(size, color): + """Quick PNG generator (fallback if no icon).""" + r, g, b = color + def chunk(ctype, data): + c = ctype + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff) + hdr = b'\x89PNG\r\n\x1a\n' + ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0)) + raw = b'' + for y in range(size): + raw += b'\x00' + for x in range(size): + d = ((x-size/2)**2 + (y-size/2)**2) ** 0.5 + rad = size/2 - 4 + if d < rad: + t = d / rad + raw += struct.pack('BBB', int(r-(r-50)*t), int(g-(g-100)*t), int(b-(b-200)*t)) + else: + raw += b'\x00\x00\x00' + return hdr + ihdr + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'') diff --git a/generate_icon.py b/generate_icon.py new file mode 100644 index 0000000..12b6a71 --- /dev/null +++ b/generate_icon.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Generate a system-monitoring-themed icon for the fnmon fpk.""" + +from PIL import Image, ImageDraw, ImageFont +import math + +SIZE = 256 +OUT = '/vol2/1000/HD2/fpk/fnmon-icon.png' + +draw_icon = ImageDraw.Draw(Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))) +img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0)) +draw = ImageDraw.Draw(img) + +# Colors +BG_DARK = (16, 22, 36) +ACCENT = (56, 189, 248) +GREEN = (74, 222, 128) +YELLOW = (251, 191, 36) +RED = (248, 113, 113) +WHITE = (230, 240, 255) +CARD = (30, 41, 59) + +cx, cy = SIZE//2, SIZE//2 +r = SIZE//2 - 16 + +# Rounded square background +draw.rounded_rectangle([4, 4, SIZE-5, SIZE-5], radius=28, fill=BG_DARK) + +# -- Gauge arc -- +arc_r = r - 10 +for deg in range(60, 241): + rad = math.radians(deg) + x1 = cx + int((arc_r-2) * math.cos(rad)) + y1 = cy + int((arc_r-2) * math.sin(rad)) + x2 = cx + int(arc_r * math.cos(rad)) + y2 = cy + int(arc_r * math.sin(rad)) + # green 60-120, yellow 120-180, red 180-240 + if deg < 120: + c = GREEN + elif deg < 180: + c = YELLOW + else: + c = RED + draw.line([(x1, y1), (x2, y2)], fill=c, width=4) + +# Gauge needle at ~80° +needle_deg = 100 +rad = math.radians(needle_deg) +nx = cx + int(arc_r * 0.7 * math.cos(rad)) +ny = cy + int(arc_r * 0.7 * math.sin(rad)) +draw.line([(cx, cy), (nx, ny)], fill=WHITE, width=3) +draw.ellipse([cx-5, cy-5, cx+5, cy+5], fill=ACCENT) + +# -- Bar chart bars at bottom left -- +bar_x, bar_y = cx - arc_r + 5, cy + 8 +bar_w, bar_h = 8, 30 +for i, (h, c) in enumerate([(24, GREEN), (16, YELLOW), (24, ACCENT)]): + bx = bar_x + i * (bar_w + 4) + draw.rectangle([bx, bar_y + bar_h - h, bx + bar_w, bar_y + bar_h], fill=c, width=0) + +# -- Small CPU chip top-left -- +chip_x, chip_y = cx - arc_r + 5, cy - arc_r + 5 +draw.rounded_rectangle([chip_x, chip_y, chip_x+24, chip_y+24], radius=3, fill=ACCENT) +draw.rectangle([chip_x+4, chip_y+4, chip_x+20, chip_y+20], fill=(20, 40, 60)) +# Dot +draw.rectangle([chip_x+10, chip_y+9, chip_x+14, chip_y+15], fill=(30, 80, 120)) + +# -- Signal dots (network) top right -- +sig_x, sig_y = cx + arc_r - 20, cy - arc_r + 5 +for i in range(3): + sh = 6 + i * 5 + draw.rectangle([sig_x + i * 5, sig_y + 16 - sh, sig_x + i * 5 + 3, sig_y + 16], fill=ACCENT if i < 2 else (100, 150, 200)) + +# Resize for 128 +img_128 = img.resize((128, 128), Image.LANCZOS if hasattr(Image, 'LANCZOS') else Image.ANTIALIAS) +img.save(OUT) +img_128.save('/vol2/1000/HD2/fpk/fnmon-icon-128.png') +print(f'✅ 256x256 saved: {OUT}') +print(f'✅ 128x128 saved: /vol2/1000/HD2/fpk/fnmon-icon-128.png') + +# Also save 256 copy +img.save('/vol2/1000/HD2/fpk/fnmon-icon-256.png') +print(f'✅ 256x256 saved: /vol2/1000/HD2/fpk/fnmon-icon-256.png') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..20d5a1b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask +psutil +Flask-SSE +pandas diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..22be8c3 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ + 1|#!/bin/bash + 2|# /vol1/1000/HD1/APP/hermes/awork/fnmon/start.sh + 3|# Start the CPU Monitor service + 4|cd "$(dirname "$0")" + 5|exec python3 app.py + 6| \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..00b76e5 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1274 @@ + + + + + +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 new file mode 100644 index 0000000..8c78a52 --- /dev/null +++ b/traffic_tracker.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Traffic tracker: total / daily / monthly cumulative counters + per-container stats with speed.""" + +import json +import os +import re +import sqlite3 +import subprocess +import time +from datetime import date + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'traffic.db') + +# Module-level cache for per-container speed calculation +# { container_name: { 'rx': int, 'tx': int, 'ts': float }, ... } +_PREV_CONTAINER_STATS = {} + + +def _get_db(): + """Get SQLite connection with schema init.""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS traffic_base ( + id TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER + )''') + c.execute('''CREATE TABLE IF NOT EXISTS traffic_daily ( + date TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER + )''') + c.execute('''CREATE TABLE IF NOT EXISTS traffic_monthly ( + month TEXT PRIMARY KEY, rx_base INTEGER, tx_base INTEGER + )''') + conn.commit() + return conn + + +def _get_interfaces(): + """Read /proc/net/dev and return {iface: {rx, tx}} for all relevant interfaces.""" + interfaces = {} + try: + with open('/proc/net/dev') as f: + for line in f.readlines()[2:]: # skip headers + parts = line.strip().split() + if len(parts) >= 10: + iface = parts[0].rstrip(':') + # Skip loopback + if iface == 'lo': + continue + # Skip veth (Docker internal — counted per-container) + if iface.startswith('veth'): + continue + # Skip individual Docker bridges + if re.match(r'^br-[a-f0-9]{12}$', iface): + continue + interfaces[iface] = { + 'rx': int(parts[1]), + 'tx': int(parts[9]), + } + except Exception: + pass + return interfaces + + +def _get_main_traffic(interfaces): + """Aggregate main interfaces (eno, enx, eth).""" + rx = tx = 0 + for iface, data in interfaces.items(): + if iface.startswith(('eno', 'enx', 'eth')): + rx += data['rx'] + tx += data['tx'] + return rx, tx + + +def _parse_bytes(s): + """Parse '1.23kB', '45.4MB', '960B' etc to bytes.""" + s = s.strip().replace(',', '') + m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s) + if not m: + return 0 + val = float(m.group(1)) + unit = m.group(2).lower() + multipliers = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6, 'mib': 1048576, + 'gb': 1e9, 'gib': 1073741824, 'tb': 1e12, 'tib': 1099511627776} + return int(val * multipliers.get(unit, 1)) + + +def _get_docker_containers(): + """ + Get per-container network traffic + real-time speed. + Returns list sorted by total traffic descending. + Results cached for 30 seconds to avoid blocking SSE. + """ + global _PREV_CONTAINER_STATS, _DOCKER_CACHE, _DOCKER_CACHE_TIME + now = time.time() + + # Return cache if fresh (30s) + if '_DOCKER_CACHE' in globals() and now - _DOCKER_CACHE_TIME < 30: + return _DOCKER_CACHE + + containers = [] + try: + result = subprocess.run( + ['docker', 'stats', '--no-stream', '--format', + '{{.Name}}\t{{.NetIO}}'], + capture_output=True, text=True, timeout=30 + ) + lines = result.stdout.strip().split('\n') + now = time.time() + + current_snapshot = {} + for line in lines: + parts = line.strip().split('\t') + if len(parts) >= 2: + name = parts[0] + netio = parts[1].strip() + rx_str, tx_str = '', '' + if ' / ' in netio: + rx_str, tx_str = netio.split(' / ', 1) + rx = _parse_bytes(rx_str.strip()) + tx = _parse_bytes(tx_str.strip()) + current_snapshot[name] = {'rx': rx, 'tx': tx, 'ts': now} + + # Compute speed from previous snapshot + rx_speed = tx_speed = 0 + prev = _PREV_CONTAINER_STATS.get(name) + if prev: + dt = now - prev['ts'] + if dt > 0: + rx_speed = max(0, (rx - prev['rx']) / dt) + tx_speed = max(0, (tx - prev['tx']) / dt) + + containers.append({ + 'name': name, + 'rx': rx, + 'tx': tx, + 'rx_speed': round(rx_speed), + 'tx_speed': round(tx_speed), + }) + + _PREV_CONTAINER_STATS = current_snapshot + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + pass # Docker not available + except Exception: + pass + + # Sort by total traffic (rx + tx) descending + containers.sort(key=lambda c: c['rx'] + c['tx'], reverse=True) + _DOCKER_CACHE = containers + _DOCKER_CACHE_TIME = time.time() + return containers + + +def get_traffic_stats(): + """ + Return traffic stats: + - total: cumulative since tracking started + - daily: cumulative for today + - monthly: cumulative for this month + - containers: top Docker containers with cumulative + speed + """ + conn = _get_db() + c = conn.cursor() + today = date.today().isoformat() + this_month = today[:7] # YYYY-MM + + interfaces = _get_interfaces() + main_rx, main_tx = _get_main_traffic(interfaces) + + # ---- Total traffic baseline (set once) ---- + c.execute('SELECT rx_base, tx_base FROM traffic_base WHERE id=?', ('_main',)) + row = c.fetchone() + if row: + rx_base, tx_base = row + else: + rx_base, tx_base = main_rx, main_tx + c.execute('INSERT OR REPLACE INTO traffic_base VALUES (?,?,?)', + ('_main', rx_base, tx_base)) + conn.commit() + + total_rx = max(0, main_rx - rx_base) + total_tx = max(0, main_tx - tx_base) + + # ---- Daily cumulative (baseline at start of day) ---- + c.execute('SELECT rx_base, tx_base FROM traffic_daily WHERE date=?', (today,)) + row = c.fetchone() + if row: + day_rx_base, day_tx_base = row + else: + # First call today: set baseline = current total, so daily starts at 0 + day_rx_base, day_tx_base = total_rx, total_tx + c.execute('INSERT OR REPLACE INTO traffic_daily VALUES (?,?,?)', + (today, day_rx_base, day_tx_base)) + conn.commit() + + daily_rx = max(0, total_rx - day_rx_base) + daily_tx = max(0, total_tx - day_tx_base) + + # ---- Monthly cumulative (baseline at start of month) ---- + c.execute('SELECT rx_base, tx_base FROM traffic_monthly WHERE month=?', + (this_month,)) + row = c.fetchone() + if row: + mon_rx_base, mon_tx_base = row + else: + mon_rx_base, mon_tx_base = total_rx, total_tx + c.execute('INSERT OR REPLACE INTO traffic_monthly VALUES (?,?,?)', + (this_month, mon_rx_base, mon_tx_base)) + conn.commit() + + monthly_rx = max(0, total_rx - mon_rx_base) + monthly_tx = max(0, total_tx - mon_tx_base) + + conn.close() + + # ---- Docker containers ---- + containers = _get_docker_containers() + + return { + 'total_rx': total_rx, + 'total_tx': total_tx, + 'daily_rx': daily_rx, + 'daily_tx': daily_tx, + 'monthly_rx': monthly_rx, + 'monthly_tx': monthly_tx, + 'containers': containers[:15], # top 15 + } + + +if __name__ == '__main__': + print(json.dumps(get_traffic_stats(), indent=2))