- 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>
289 lines
13 KiB
Python
289 lines
13 KiB
Python
"""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)
|