#!/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)