Files
Button/winDeployScripts/software/webconfig/system_info.py
T
2026-06-25 15:28:46 +03:00

316 lines
8.6 KiB
Python

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