Files
p42.f-w/backend/app/monitoring.py
T
guyg1474andClaude Opus 5.5 dd58b3d1f8 P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:08:11 +02:00

132 lines
5.4 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 + Erreichbarkeitsprüfung (HTTP oder TCP-Port)."""
import asyncio
import re
import time
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import now_iso, transaction
from .links import ValidationError, normalize_url
router = APIRouter()
TYPES = {"http", "tcp"}
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})")
def _monitor(row) -> dict:
return {"id": row["id"], "name": row["name"], "type": row["type"], "target": row["target"], "createdAt": row["created_at"]}
def _validate(data: dict) -> 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 ""
if kind == "http":
target = normalize_url(target, "die Adresse")
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}
# ---------- 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)
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()),
)
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)
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"]),
)
if not cur.rowcount:
raise HTTPException(404, "Nicht gefunden.")
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"}
@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
))
checked = now_iso()
return [{"id": r["id"], "checkedAt": checked, **res} for r, res in zip(rows, results)]