Monitoring: Server-Auslastung über P42-S.M. mit Detailansicht und 2-Tage-Verlauf

- Neue Prüfart "Server-Auslastung (P42-S.M.)": Adresse + Token (Token bleibt
  im Backend, wird nie an den Browser gegeben)
- Karte zeigt CPU, RAM, vollste Platte, Load, Temperatur, Uptime
- Klick öffnet große Ansicht: Kacheln, Platten, Threads, Temperaturen,
  Netzwerk, Verlaufsdiagramme (6 h / 24 h / 2 Tage) mit Tooltip und Tabelle
- Hintergrund-Task speichert jede Minute einen Mittelwert (monitor_samples,
  2 Tage), unabhängig davon, ob die Seite offen ist
- Docker: zusätzlich im Netz p42, update-docker.sh legt es bei Bedarf an

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-14 16:22:48 +02:00
1 parent 94bda25516
commit f16c8c73c6
13 files changed
+923 -35

No files matched your search

+177 -21
View File
@@ -1,5 +1,6 @@
"""Server-Monitoring pro Konto: eigene Serverliste + Erreichbarkeitsprüfung (HTTP oder TCP-Port)."""
"""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
@@ -11,18 +12,28 @@ from .db import now_iso, transaction
from .links import ValidationError, normalize_url
router = APIRouter()
log = logging.getLogger("p42.monitoring")
TYPES = {"http", "tcp"}
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"], "createdAt": row["created_at"]}
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) -> dict:
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).")
@@ -30,15 +41,32 @@ def _validate(data: dict) -> dict:
if kind not in TYPES:
raise ValidationError("Ungültiger Prüf-Typ.")
target = data.get("target").strip() if isinstance(data.get("target"), str) else ""
if kind == "http":
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")
return {"name": name, "type": kind, "target": target}
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 ----------
@@ -52,28 +80,33 @@ def list_monitors(user: dict = Depends(current_user)):
@router.post("/api/monitors", status_code=201)
def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)):
values = _validate(data)
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, created_at) VALUES (?, ?, ?, ?, ?)",
(user["id"], values["name"], values["type"], values["target"], now_iso()),
"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)
values = _validate(data, is_new=False)
with transaction() as conn:
cur = conn.execute(
"UPDATE monitors SET name = ?, type = ?, target = ? WHERE id = ? AND user_id = ?",
(values["name"], values["type"], values["target"], monitor_id, user["id"]),
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),
)
if not cur.rowcount:
raise HTTPException(404, "Nicht gefunden.")
# 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())
@@ -118,14 +151,137 @@ async def _check_tcp(target: str) -> dict:
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 FROM monitors WHERE user_id = ?", (user["id"],)).fetchall()
# Zertifikate werden nicht geprüft: im Heimnetz sind selbstsignierte Zertifikate üblich
async with httpx.AsyncClient(timeout=TIMEOUT, verify=False, follow_redirects=True) as client:
results = await asyncio.gather(*(
_check_http(client, r["target"]) if r["type"] == "http" else _check_tcp(r["target"]) for r in rows
))
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)