182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""Журнал снимков нагрузки железа (JSONL) для графиков в админке."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
_LOG = logging.getLogger(__name__)
|
||
_LOCK = threading.Lock()
|
||
_LAST_SAMPLE_MONO: Optional[float] = None
|
||
|
||
|
||
def _hardware_log_path(app) -> str:
|
||
raw = (app.config.get("WESP_ADMIN_HARDWARE_LOG_PATH") or "").strip()
|
||
return raw
|
||
|
||
|
||
def _line_unix_ts(obj: Dict[str, Any]) -> Optional[float]:
|
||
u = obj.get("u")
|
||
if isinstance(u, (int, float)):
|
||
return float(u)
|
||
return None
|
||
|
||
|
||
def maybe_append_hardware_sample(app, metrics: Dict[str, Any]) -> None:
|
||
"""
|
||
Дописывает одну строку JSON в лог, не чаще чем раз в WESP_ADMIN_HARDWARE_LOG_INTERVAL_SEC.
|
||
Удаляет из файла записи старше WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS (по полю u — unix time).
|
||
Ошибки записи логируются и не пробрасываются (ответ /system-metrics остаётся 200).
|
||
"""
|
||
try:
|
||
_maybe_append_hardware_sample_impl(app, metrics)
|
||
except Exception:
|
||
_LOG.exception(
|
||
"Сбой журнала нагрузки железа (WESP_ADMIN_HARDWARE_LOG_PATH). "
|
||
"Метрики дашборда не затронуты."
|
||
)
|
||
|
||
|
||
def _maybe_append_hardware_sample_impl(app, metrics: Dict[str, Any]) -> None:
|
||
global _LAST_SAMPLE_MONO
|
||
|
||
path_str = _hardware_log_path(app)
|
||
if not path_str:
|
||
return
|
||
|
||
interval = float(app.config.get("WESP_ADMIN_HARDWARE_LOG_INTERVAL_SEC", 60) or 60)
|
||
interval = max(5.0, min(interval, 3600.0))
|
||
|
||
now_mono = time.monotonic()
|
||
with _LOCK:
|
||
prev = _LAST_SAMPLE_MONO
|
||
if isinstance(prev, (int, float)) and (now_mono - float(prev)) < interval:
|
||
return
|
||
_LAST_SAMPLE_MONO = float(now_mono)
|
||
|
||
path = Path(path_str)
|
||
if not path.is_absolute():
|
||
base = app.config.get("BASE_DIR", ".")
|
||
path = Path(base) / path
|
||
|
||
cpu = metrics.get("cpu") if isinstance(metrics.get("cpu"), dict) else {}
|
||
mem = metrics.get("memory") if isinstance(metrics.get("memory"), dict) else {}
|
||
sw = metrics.get("swap") if isinstance(metrics.get("swap"), dict) else {}
|
||
disk = metrics.get("disk") if isinstance(metrics.get("disk"), dict) else {}
|
||
|
||
row = {
|
||
"u": int(time.time()),
|
||
"cpu_pct": float(cpu.get("percent") or 0),
|
||
"cpu_cores": int(cpu.get("cores") or 0),
|
||
"ram_pct": float(mem.get("percent") or 0),
|
||
"ram_used": int(mem.get("used") or 0),
|
||
"ram_total": int(mem.get("total") or 0),
|
||
"swap_pct": float(sw.get("percent") or 0) if int(sw.get("total") or 0) else 0.0,
|
||
"swap_used": int(sw.get("used") or 0),
|
||
"swap_total": int(sw.get("total") or 0),
|
||
"disk_pct": float(disk.get("percent") or 0) if disk else None,
|
||
"disk_used": int(disk.get("used") or 0) if disk else None,
|
||
"disk_total": int(disk.get("total") or 0) if disk else None,
|
||
}
|
||
line = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
cutoff = time.time() - float(
|
||
max(1, min(int(app.config.get("WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS", 3) or 3), 30)) * 86400
|
||
)
|
||
|
||
_rewrite_prune_and_append(path, cutoff, line)
|
||
|
||
|
||
def _rewrite_prune_and_append(path: Path, cutoff_ts: float, new_line: str) -> None:
|
||
"""Читает JSONL, оставляет строки с u >= cutoff_ts, дописывает new_line."""
|
||
lines_kept: List[str] = []
|
||
if path.is_file():
|
||
try:
|
||
with path.open("r", encoding="utf-8") as f:
|
||
for ln in f:
|
||
ln = ln.strip()
|
||
if not ln:
|
||
continue
|
||
try:
|
||
obj = json.loads(ln)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
ts = _line_unix_ts(obj)
|
||
if ts is not None and ts >= cutoff_ts:
|
||
lines_kept.append(ln + "\n")
|
||
except OSError:
|
||
lines_kept = []
|
||
|
||
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
||
try:
|
||
with tmp.open("w", encoding="utf-8") as out:
|
||
for ln in lines_kept:
|
||
out.write(ln)
|
||
out.write(new_line)
|
||
os.replace(str(tmp), str(path))
|
||
except OSError:
|
||
try:
|
||
if tmp.is_file():
|
||
tmp.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def read_hardware_history(app) -> Tuple[List[Dict[str, Any]], str]:
|
||
"""
|
||
Точки за окно хранения (по конфигу, по умолчанию 3 суток).
|
||
Возвращает (points, path_or_empty).
|
||
"""
|
||
path_str = _hardware_log_path(app)
|
||
if not path_str:
|
||
return [], ""
|
||
|
||
path = Path(path_str)
|
||
if not path.is_absolute():
|
||
base = app.config.get("BASE_DIR", ".")
|
||
path = Path(base) / path
|
||
|
||
if not path.is_file():
|
||
return [], str(path)
|
||
|
||
days = int(app.config.get("WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS", 3) or 3)
|
||
days = max(1, min(days, 30))
|
||
cutoff = time.time() - float(days * 86400)
|
||
|
||
out: List[Dict[str, Any]] = []
|
||
try:
|
||
with path.open("r", encoding="utf-8") as f:
|
||
for ln in f:
|
||
ln = ln.strip()
|
||
if not ln:
|
||
continue
|
||
try:
|
||
obj = json.loads(ln)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
ts = _line_unix_ts(obj)
|
||
if ts is None or ts < cutoff:
|
||
continue
|
||
out.append(
|
||
{
|
||
"t": int(ts),
|
||
"cpu": float(obj.get("cpu_pct") or 0),
|
||
"ram": float(obj.get("ram_pct") or 0),
|
||
"swap": float(obj.get("swap_pct") or 0),
|
||
"disk": float(obj.get("disk_pct") or 0)
|
||
if obj.get("disk_pct") is not None
|
||
else None,
|
||
}
|
||
)
|
||
except OSError:
|
||
return [], str(path)
|
||
|
||
out.sort(key=lambda x: x["t"])
|
||
return out, str(path)
|