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>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
"""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)]