# Simple statserver: CPU usage + CPU temp, chart on localhost:8080 # # Every sample is stored in SQLite (stats.db, next to this file). Rows older # than 7 days are purged, so the database always holds the last 7 days. A # background thread samples once per second, independent of web clients. import os import sqlite3 import threading import time from flask import Flask, request, jsonify, Response app = Flask(__name__) DB_PATH = os.path.join(os.path.dirname(__file__), "stats.db") SEVEN_DAYS = 7 * 24 * 3600 # name -> (span in seconds, bucket size in seconds); day/week rows are # averaged into buckets so the payload stays small. RANGES = { "minute": (60, 1), "hour": (3600, 10), "day": (86400, 60), "week": (SEVEN_DAYS, 600), } _prev_cpu = None _last = {"usage": 0.0, "temp": None} _db = sqlite3.connect(DB_PATH, check_same_thread=False) _db_lock = threading.Lock() _db.execute( "CREATE TABLE IF NOT EXISTS samples (" "ts REAL PRIMARY KEY, usage REAL, temp REAL)" ) _db.commit() def _read_temp(): """Read CPU temperature from sysfs, preferring the CPU package sensor. Returns degrees Celsius, or None if nothing readable is found. """ fallback = None base = "/sys/class/thermal" for zone in sorted(x for x in os.listdir(base) if x.startswith("thermal_zone")): try: with open(f"{base}/{zone}/type") as f: ztype = f.read().strip().lower() with open(f"{base}/{zone}/temp") as f: temp = int(f.read().strip()) except OSError: continue if ztype == "x86_pkg_temp": # package temp = best guess for CPU temp return temp / 1000 if "cpu" in ztype or "pkg" in ztype or "core" in ztype: fallback = fallback if fallback is not None else temp / 1000 return fallback def _read_usage(): """CPU usage since the previous call, from /proc/stat. Returns percent.""" with open("/proc/stat") as f: cpu_line = next(line for line in f if line.startswith("cpu ")) fields = [int(x) for x in cpu_line.split()[1:] if x.isdigit()] idle, total = fields[3], sum(fields) global _prev_cpu prev = _prev_cpu _prev_cpu = (idle, total) if prev is None: return None didle = idle - prev[0] dtotal = total - prev[1] return 100.0 * (1 - didle / dtotal) if dtotal > 0 else 0.0 def sample(): usage = _read_usage() if usage is not None: _last["usage"] = usage _last["temp"] = _read_temp() def _sampler(): """Sample once per second and store it; purge >7-day-old rows every minute.""" n = 0 while True: sample() with _db_lock: _db.execute( "INSERT OR REPLACE INTO samples VALUES (?, ?, ?)", (time.time(), _last["usage"], _last["temp"]), ) # commit each insert so other connections (and restarts) see the # data; a 1/sec commit is trivial load for SQLite n += 1 if n % 60 == 0: _db.execute("DELETE FROM samples WHERE ts < ?", (time.time() - SEVEN_DAYS,)) _db.commit() time.sleep(1) @app.get("/api/stats") def stats(): return jsonify(usage=_last["usage"], temp=_last["temp"]) @app.get("/api/history") def history(): rng = request.args.get("range", "minute") span, bucket = RANGES.get(rng, RANGES["minute"]) since = time.time() - span with _db_lock: rows = _db.execute( "SELECT CAST(ts / ? AS INTEGER) * ? AS t, AVG(usage), AVG(temp) " "FROM samples WHERE ts >= ? GROUP BY t ORDER BY t", (bucket, bucket, since), ).fetchall() return jsonify(points=[ {"t": round(r[0]), "u": r[1], "c": r[2]} for r in rows ]) @app.get("/") def index(): with open(os.path.join(os.path.dirname(__file__), "index.html"), "rb") as f: return Response(f.read(), mimetype="text/html") if __name__ == "__main__": threading.Thread(target=_sampler, daemon=True).start() app.run(host="0.0.0.0", port=8080, threaded=True, use_reloader=False)