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:
1 parent
94bda25516
commit
f16c8c73c6
13 files changed
+923
-35
No files matched your search
+177
-21
@@ -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)
|
||||
Reference in new issue
Block a user