134 lines
4.1 KiB
Python
134 lines
4.1 KiB
Python
# 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)
|