#!/usr/bin/env python3 """Host metrics, button action log, and clock control for the web configurator.""" import json import subprocess import threading import time from collections import deque from datetime import datetime from flask import Blueprint, abort, jsonify, request from auth import login_required from config_store import load_config, save_config from paths import run_dir, scripts_dir system_bp = Blueprint("system", __name__) _WEB_LOG = deque(maxlen=200) _LOCK = threading.Lock() _SKIP_PREFIXES = ("/static/", "/healthz", "/favicon.ico") _prev_cpu = None BUTTON_LOG = "button-log.jsonl" BUTTON_LOG_LIMIT = 200 def _read_meminfo(): info = {} try: with open("/proc/meminfo", encoding="utf-8") as f: for line in f: key, _, val = line.partition(":") info[key.strip()] = int(val.strip().split()[0]) except OSError: pass return info def _cpu_percent(): global _prev_cpu try: with open("/proc/stat", encoding="utf-8") as f: parts = [int(x) for x in f.readline().split()[1:]] except (OSError, ValueError, IndexError): return None idle = parts[3] total = sum(parts) if _prev_cpu is None: _prev_cpu = (idle, total) return None idle_d = idle - _prev_cpu[0] total_d = total - _prev_cpu[1] _prev_cpu = (idle, total) if total_d <= 0: return 0.0 return round(100.0 * (1.0 - idle_d / total_d), 1) def _format_uptime(seconds): s = int(seconds) days, s = divmod(s, 86400) hours, s = divmod(s, 3600) minutes, _ = divmod(s, 60) parts = [] if days: parts.append(f"{days}д") if hours: parts.append(f"{hours}ч") parts.append(f"{minutes}м") return " ".join(parts) def current_clock(): now = datetime.now() return { "datetime": now.strftime("%Y-%m-%d %H:%M:%S"), "iso": now.strftime("%Y-%m-%dT%H:%M"), } def collect_stats(): uptime_secs = 0.0 load = (0.0, 0.0, 0.0) try: with open("/proc/uptime", encoding="utf-8") as f: uptime_secs = float(f.read().split()[0]) except (OSError, ValueError, IndexError): pass try: with open("/proc/loadavg", encoding="utf-8") as f: load_parts = f.read().split()[:3] load = tuple(float(x) for x in load_parts) except (OSError, ValueError, IndexError): pass mem = _read_meminfo() total_kb = mem.get("MemTotal", 0) avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0)) used_kb = max(0, total_kb - avail_kb) clock = current_clock() return { "clock": clock["datetime"], "uptime_secs": round(uptime_secs, 1), "uptime": _format_uptime(uptime_secs), "cpu_percent": _cpu_percent(), "load": {"1m": load[0], "5m": load[1], "15m": load[2]}, "memory": { "total_kb": total_kb, "used_kb": used_kb, "available_kb": avail_kb, "used_percent": round(100.0 * used_kb / total_kb, 1) if total_kb else 0.0, }, "ts": time.time(), } def read_button_log(limit=BUTTON_LOG_LIMIT): path = run_dir() / BUTTON_LOG if not path.exists(): return [] try: lines = path.read_text(encoding="utf-8").splitlines() except OSError: return [] entries = [] for line in lines[-limit:]: line = line.strip() if not line: continue try: entries.append(json.loads(line)) except ValueError: continue entries.reverse() return entries def record_request(method, path, status, duration_ms, remote): if any(path.startswith(p) for p in _SKIP_PREFIXES): return entry = { "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "method": method, "path": path, "status": status, "ms": round(duration_ms, 1), "remote": remote, } with _LOCK: _WEB_LOG.appendleft(entry) def get_request_log(): with _LOCK: return list(_WEB_LOG) def apply_system_time(dt_str): import re if not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", dt_str): raise ValueError("invalid datetime") script = str(scripts_dir() / "bt-settime") proc = subprocess.run( ["sudo", "-n", script, dt_str], capture_output=True, text=True, timeout=15, check=False, ) if proc.returncode != 0: err = (proc.stderr or proc.stdout or "set time failed").strip() raise RuntimeError(err) return proc.stdout.strip() def _run_access(*args, timeout=20): script = str(scripts_dir() / "bt-access") proc = subprocess.run( ["sudo", "-n", script, *args], capture_output=True, text=True, timeout=timeout, check=False, ) for line in reversed((proc.stdout or "").splitlines()): line = line.strip() if line.startswith("{") and line.endswith("}"): return json.loads(line) if proc.returncode != 0: err = (proc.stderr or proc.stdout or "bt-access failed").strip() raise RuntimeError(err) raise RuntimeError((proc.stdout or "bt-access returned no status").strip()) def access_status(): cfg = load_config() web_port = int(cfg.get("settings", {}).get("webPort", 8080)) status = _run_access("status") status["webPort"] = web_port return status def set_web_port(port): try: port = int(port) except (TypeError, ValueError): raise ValueError("invalid web port") if port < 1 or port > 65535: raise ValueError("invalid web port") cfg = load_config() cfg.setdefault("settings", {})["webPort"] = port save_config(cfg) subprocess.Popen( ["sudo", "-n", str(scripts_dir() / "bt-service"), "restart"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return port @system_bp.route("/api/system/stats", methods=["GET"]) @login_required def api_system_stats(): return jsonify(collect_stats()) @system_bp.route("/api/system/access", methods=["GET"]) @login_required def api_access_get(): try: return jsonify(access_status()) except RuntimeError as e: return jsonify({"ok": False, "error": str(e)}), 500 @system_bp.route("/api/system/access", methods=["POST"]) @login_required def api_access_set(): data = request.get_json(force=True, silent=True) or {} action = (data.get("action") or "").strip() value = data.get("value") try: if action == "icmp": if value not in ("on", "off"): abort(400, "icmp value must be on/off") return jsonify(_run_access("icmp", value)) if action == "ssh": if value not in ("on", "off"): abort(400, "ssh value must be on/off") return jsonify(_run_access("ssh", value)) if action == "sshPort": return jsonify(_run_access("ssh-port", str(value))) if action == "webPort": port = set_web_port(value) return jsonify({"ok": True, "webPort": port}) abort(400, "unknown action") except ValueError as e: abort(400, str(e)) except RuntimeError as e: return jsonify({"ok": False, "error": str(e)}), 500 @system_bp.route("/api/system/time", methods=["GET"]) @login_required def api_system_time_get(): return jsonify(current_clock()) @system_bp.route("/api/system/time", methods=["POST"]) @login_required def api_system_time_set(): data = request.get_json(force=True, silent=True) or {} dt = (data.get("datetime") or "").strip() if not dt: abort(400, "datetime required (YYYY-MM-DD HH:MM:SS)") try: apply_system_time(dt) except ValueError as e: abort(400, str(e)) except RuntimeError as e: return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": True, **current_clock()}) def trigger_reboot(): script = str(scripts_dir() / "bt-reboot") subprocess.Popen( ["sudo", "-n", script], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) @system_bp.route("/api/system/reboot", methods=["POST"]) @login_required def api_system_reboot(): try: trigger_reboot() except FileNotFoundError as e: return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": True}) @system_bp.route("/api/system/button-log", methods=["GET"]) @login_required def api_system_button_log(): return jsonify({"entries": read_button_log()}) @system_bp.route("/api/system/log", methods=["GET"]) @login_required def api_system_log(): return jsonify({"entries": get_request_log()})