Files
p42.f-w/backend/app/monitoring.py
T
guyg1474andClaude Opus 5.5 4aba48ce51 Backend: gemeinsame Helfer in common.py
- Eine ValidationError-Klasse statt zwei (links.py, projects.py), ein
  Fehler-Handler in main.py
- clean_text() ersetzt vier fast gleiche Text-Prüfungen (Kanban, Haushalt,
  Reisen, Artikel); Fehlermeldungen nennen jetzt überall die Höchstlänge
- Zeitstempel (now_iso, iso_ago, iso_after) und Kontenliste (user_list)
  an einer Stelle; ungenutzte Importe entfernt
- Tests für die Helfer und für einheitliche 400-Fehler aus allen Bereichen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:10:43 +02:00

289 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Server-Monitoring pro Konto: eigene Serverliste + Prüfung (HTTP, TCP-Port oder Auslastung über P42-S.M.)."""
import asyncio
import logging
import re
import time
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .common import ValidationError, now_iso
from .db import transaction
from .links import normalize_url
router = APIRouter()
log = logging.getLogger("p42.monitoring")
TYPES = {"http", "tcp", "agent"}
MAX_MONITORS = 50
TIMEOUT = 5
HOST_PORT = re.compile(r"(?P<host>[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):(?P<port>\d{1,5})")
TOKEN = re.compile(r"[A-Za-z0-9._~+/=-]{8,200}")
# Verlauf der Auslastung (Typ "agent"): jede Minute ein Mittelwert, 2 Tage aufbewahrt
SAMPLE_SECONDS = 60
KEEP_SECONDS = 2 * 24 * 3600
MAX_HISTORY_POINTS = 480 # so viele Punkte liefert /history höchstens; längere Zeiträume werden gemittelt
def _monitor(row) -> dict:
return {
"id": row["id"], "name": row["name"], "type": row["type"], "target": row["target"],
"hasToken": bool(row["token"]), "createdAt": row["created_at"],
}
def _validate(data: dict, is_new: bool) -> dict:
name = data.get("name").strip() if isinstance(data.get("name"), str) else ""
if not name or len(name) > 60:
raise ValidationError("Name fehlt oder ist zu lang (max. 60 Zeichen).")
kind = data.get("type")
if kind not in TYPES:
raise ValidationError("Ungültiger Prüf-Typ.")
target = data.get("target").strip() if isinstance(data.get("target"), str) else ""
token = None
if kind in ("http", "agent"):
target = normalize_url(target, "die Adresse")
if kind == "agent":
target = target.rstrip("/") # Pfade wie /api/metrics werden angehängt
if not target:
raise ValidationError("Adresse fehlt.")
else:
m = HOST_PORT.fullmatch(target)
if not m or not 0 < int(m["port"]) < 65536:
raise ValidationError("Bitte Host und Port angeben, z. B. 192.168.0.10:22")
if kind == "agent":
token = data.get("token").strip() if isinstance(data.get("token"), str) else ""
# Beim Bearbeiten heißt ein leeres Feld: Token bleibt wie er ist
if token and not TOKEN.fullmatch(token):
raise ValidationError("Der Token sieht ungültig aus.")
if not token and is_new:
raise ValidationError("Token fehlt (steht in der .env von P42-S.M.).")
return {"name": name, "type": kind, "target": target, "token": token or None}
def _get_own(conn, monitor_id: int, user_id: int):
row = conn.execute("SELECT * FROM monitors WHERE id = ? AND user_id = ?", (monitor_id, user_id)).fetchone()
if not row:
raise HTTPException(404, "Nicht gefunden.")
return row
# ---------- Verwaltung ----------
@router.get("/api/monitors")
def list_monitors(user: dict = Depends(current_user)):
with transaction() as conn:
rows = conn.execute("SELECT * FROM monitors WHERE user_id = ? ORDER BY id", (user["id"],)).fetchall()
return [_monitor(r) for r in rows]
@router.post("/api/monitors", status_code=201)
def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)):
values = _validate(data, is_new=True)
with transaction() as conn:
count = conn.execute("SELECT COUNT(*) FROM monitors WHERE user_id = ?", (user["id"],)).fetchone()[0]
if count >= MAX_MONITORS:
raise ValidationError(f"Maximal {MAX_MONITORS} Server pro Konto.")
cur = conn.execute(
"INSERT INTO monitors (user_id, name, type, target, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(user["id"], values["name"], values["type"], values["target"], values["token"], now_iso()),
)
return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (cur.lastrowid,)).fetchone())
@router.put("/api/monitors/{monitor_id}")
def update_monitor(monitor_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
values = _validate(data, is_new=False)
with transaction() as conn:
old = _get_own(conn, monitor_id, user["id"])
token = values["token"] or (old["token"] if values["type"] == "agent" else None)
if values["type"] == "agent" and not token:
raise ValidationError("Token fehlt (steht in der .env von P42-S.M.).")
conn.execute(
"UPDATE monitors SET name = ?, type = ?, target = ?, token = ? WHERE id = ?",
(values["name"], values["type"], values["target"], token, monitor_id),
)
# Anderer Server → alter Verlauf gehört nicht mehr dazu
if values["type"] != "agent" or values["target"] != old["target"]:
conn.execute("DELETE FROM monitor_samples WHERE monitor_id = ?", (monitor_id,))
return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)).fetchone())
@router.delete("/api/monitors/{monitor_id}")
def delete_monitor(monitor_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
cur = conn.execute("DELETE FROM monitors WHERE id = ? AND user_id = ?", (monitor_id, user["id"]))
if not cur.rowcount:
raise HTTPException(404, "Nicht gefunden.")
return {"ok": True}
# ---------- Prüfung ----------
async def _check_http(client: httpx.AsyncClient, url: str) -> dict:
start = time.perf_counter()
try:
res = await client.get(url)
except httpx.TimeoutException:
return {"ok": False, "detail": "Zeitüberschreitung"}
except httpx.ConnectError:
return {"ok": False, "detail": "Nicht erreichbar"}
except httpx.HTTPError as e:
return {"ok": False, "detail": type(e).__name__}
ms = round((time.perf_counter() - start) * 1000)
# 4xx heißt: Server antwortet (z. B. Anmeldung nötig) – also erreichbar
return {"ok": res.status_code < 500, "ms": ms, "detail": f"HTTP {res.status_code}"}
async def _check_tcp(target: str) -> dict:
m = HOST_PORT.fullmatch(target)
host = m["host"].strip("[]")
start = time.perf_counter()
try:
_, writer = await asyncio.wait_for(asyncio.open_connection(host, int(m["port"])), TIMEOUT)
except asyncio.TimeoutError:
return {"ok": False, "detail": "Zeitüberschreitung"}
except OSError as e:
return {"ok": False, "detail": "Verbindung abgelehnt" if isinstance(e, ConnectionRefusedError) else "Nicht erreichbar"}
ms = round((time.perf_counter() - start) * 1000)
writer.close()
return {"ok": True, "ms": ms, "detail": f"Port {m['port']} offen"}
async def _agent_get(client: httpx.AsyncClient, base: str, token: str, path: str) -> tuple[dict | None, dict]:
"""Abruf bei P42-S.M.: (Daten, Status) – Daten None bei Fehler."""
start = time.perf_counter()
try:
res = await client.get(f"{base}{path}", headers={"Authorization": f"Bearer {token}"})
except httpx.TimeoutException:
return None, {"ok": False, "detail": "Zeitüberschreitung"}
except httpx.ConnectError:
return None, {"ok": False, "detail": "Nicht erreichbar"}
except httpx.HTTPError as e:
return None, {"ok": False, "detail": type(e).__name__}
ms = round((time.perf_counter() - start) * 1000)
if res.status_code == 401:
return None, {"ok": False, "ms": ms, "detail": "Token falsch"}
if res.status_code == 503:
return None, {"ok": False, "ms": ms, "detail": "Agent startet gerade"}
try:
data = res.json() if res.status_code == 200 else None
except ValueError:
data = None
if not isinstance(data, dict):
return None, {"ok": False, "ms": ms, "detail": f"Keine P42-S.M.-Antwort (HTTP {res.status_code})"}
return data, {"ok": True, "ms": ms}
async def _check_agent(client: httpx.AsyncClient, base: str, token: str) -> dict:
metrics, status = await _agent_get(client, base, token, "/api/metrics")
if metrics is None:
return status
cpu = (metrics.get("cpu") or {}).get("percent")
mem = (metrics.get("memory") or {}).get("percent")
return {**status, "detail": f"CPU {cpu} % · RAM {mem} %", "metrics": metrics}
def _client() -> httpx.AsyncClient:
# Zertifikate werden nicht geprüft: im Heimnetz sind selbstsignierte Zertifikate üblich
return httpx.AsyncClient(timeout=TIMEOUT, verify=False, follow_redirects=True)
@router.get("/api/monitors/status")
async def monitor_status(user: dict = Depends(current_user)):
with transaction() as conn:
rows = conn.execute("SELECT id, type, target, token FROM monitors WHERE user_id = ?", (user["id"],)).fetchall()
async with _client() as client:
def check(r):
if r["type"] == "http":
return _check_http(client, r["target"])
if r["type"] == "agent":
return _check_agent(client, r["target"], r["token"])
return _check_tcp(r["target"])
results = await asyncio.gather(*(check(r) for r in rows))
checked = now_iso()
return [{"id": r["id"], "checkedAt": checked, **res} for r, res in zip(rows, results)]
@router.get("/api/monitors/{monitor_id}/metrics")
async def monitor_metrics(monitor_id: int, user: dict = Depends(current_user)):
"""Aktuelle Werte eines P42-S.M.-Servers (für die Detailansicht, wird dort öfter abgefragt)."""
with transaction() as conn:
row = _get_own(conn, monitor_id, user["id"])
if row["type"] != "agent":
raise HTTPException(400, "Dieser Eintrag liefert keine Auslastung.")
async with _client() as client:
result = await _check_agent(client, row["target"], row["token"])
return {"checkedAt": now_iso(), **result}
@router.get("/api/monitors/{monitor_id}/history")
def monitor_history(monitor_id: int, hours: int = 48, user: dict = Depends(current_user)):
hours = min(max(hours, 1), KEEP_SECONDS // 3600)
with transaction() as conn:
row = _get_own(conn, monitor_id, user["id"])
if row["type"] != "agent":
raise HTTPException(400, "Dieser Eintrag liefert keine Auslastung.")
# Auf höchstens MAX_HISTORY_POINTS Punkte mitteln (volle Minuten)
bucket = max(SAMPLE_SECONDS, -(-hours * 3600 // MAX_HISTORY_POINTS // SAMPLE_SECONDS) * SAMPLE_SECONDS)
since = int(time.time()) - hours * 3600
points = conn.execute(
"""SELECT (ts / :b) * :b AS t, AVG(cpu) AS cpu, AVG(mem) AS mem, AVG(swap) AS swap, AVG(load1) AS load,
AVG(rx) AS rx, AVG(tx) AS tx, AVG(rd) AS read, AVG(wr) AS write, MAX(temp) AS temp
FROM monitor_samples WHERE monitor_id = :id AND ts >= :since
GROUP BY t ORDER BY t""",
{"b": bucket, "id": monitor_id, "since": since},
).fetchall()
return {
"hours": hours,
"bucketSeconds": bucket,
"points": [{k: (round(p[k], 2) if isinstance(p[k], float) else p[k]) for k in p.keys()} for p in points],
}
# ---------- Verlauf sammeln (läuft im Hintergrund, unabhängig davon, ob jemand die Seite offen hat) ----------
def _avg(points: list, key: str):
values = [p[key] for p in points if isinstance(p.get(key), (int, float))]
return sum(values) / len(values) if values else None
async def _sample_once() -> None:
with transaction() as conn:
rows = conn.execute("SELECT id, target, token FROM monitors WHERE type = 'agent' AND token IS NOT NULL").fetchall()
if not rows:
return
async with _client() as client:
results = await asyncio.gather(*(_agent_get(client, r["target"], r["token"], "/api/history?minutes=1") for r in rows))
ts = int(time.time()) // SAMPLE_SECONDS * SAMPLE_SECONDS
with transaction() as conn:
for r, (data, _) in zip(rows, results):
points = (data or {}).get("points")
if not isinstance(points, list) or not points:
continue # Server nicht erreichbar → Lücke im Diagramm
points = [p for p in points if isinstance(p, dict)]
temps = [p["tempMaxCelsius"] for p in points if isinstance(p.get("tempMaxCelsius"), (int, float))]
conn.execute(
"INSERT OR REPLACE INTO monitor_samples VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
r["id"], ts, _avg(points, "cpuPercent"), _avg(points, "memoryPercent"), _avg(points, "swapPercent"),
_avg(points, "load1m"), _avg(points, "rxBytesPerSec"), _avg(points, "txBytesPerSec"),
_avg(points, "readBytesPerSec"), _avg(points, "writeBytesPerSec"), max(temps) if temps else None,
),
)
conn.execute("DELETE FROM monitor_samples WHERE ts < ?", (ts - KEEP_SECONDS,))
async def sample_loop() -> None:
while True:
try:
await _sample_once()
except Exception:
log.exception("Verlauf der Server-Auslastung konnte nicht gespeichert werden")
# Auf die nächste volle Minute warten
await asyncio.sleep(SAMPLE_SECONDS - time.time() % SAMPLE_SECONDS + 1)