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

+3
View File
@@ -106,4 +106,7 @@ docker compose exec p42 python -m app.reset_password BENUTZERNAME # Passwort z
- **Monitoring:** Die Prüfungen laufen jetzt aus dem Container. `localhost`/`127.0.0.1` meint dort den Container - **Monitoring:** Die Prüfungen laufen jetzt aus dem Container. `localhost`/`127.0.0.1` meint dort den Container
selbst, nicht den Server – für Dienste auf dem Server dessen LAN-IP eintragen (z. B. `192.168.0.10:22`). selbst, nicht den Server – für Dienste auf dem Server dessen LAN-IP eintragen (z. B. `192.168.0.10:22`).
- **Server-Auslastung:** Das Dashboard hängt zusätzlich im Docker-Netz `p42` (legt `update-docker.sh` an).
Läuft dort der Agent P42-S.M., im Monitoring „Server-Auslastung (P42-S.M.)“ mit der Adresse
`http://p42-sm:9142` und dessen Token eintragen.
- **Zeitzone** ist `Europe/Berlin` (für Uhrzeiten in Monitoring und Logs). - **Zeitzone** ist `Europe/Berlin` (für Uhrzeiten in Monitoring und Logs).
+8 -1
View File
@@ -86,6 +86,12 @@ Jedes Konto pflegt seine eigene Serverliste. Geprüft wird alle 30 Sekunden:
- **Webseite (HTTP):** online, wenn die Adresse antwortet (auch mit Anmeldeseite o. Ä.). - **Webseite (HTTP):** online, wenn die Adresse antwortet (auch mit Anmeldeseite o. Ä.).
- **Port (TCP):** online, wenn der Port Verbindungen annimmt, z. B. `192.168.0.10:22`. - **Port (TCP):** online, wenn der Port Verbindungen annimmt, z. B. `192.168.0.10:22`.
- **Server-Auslastung (P42-S.M.):** CPU, RAM, Platten, Load und Temperatur direkt auf der Karte.
Klick auf die Karte öffnet die große Ansicht mit allen Werten (alle 5 s aktualisiert) und dem Verlauf
der letzten 6 h, 24 h oder 2 Tage. Adresse `http://p42-sm:9142` (Agent auf demselben Server), dazu der
Token aus der `.env` von P42-S.M. Der Token wird nur im Backend gespeichert und nie an den Browser gegeben.
Für den Verlauf speichert das Dashboard jede Minute einen Mittelwert (Tabelle `monitor_samples`,
2 Tage lang) – auch wenn niemand die Seite offen hat.
Die Prüfung läuft vom Dashboard-Server aus – Adressen müssen also von dort erreichbar sein. Die Prüfung läuft vom Dashboard-Server aus – Adressen müssen also von dort erreichbar sein.
@@ -179,7 +185,7 @@ backend/
app/auth.py Konten, Anmeldung, Benutzerverwaltung app/auth.py Konten, Anmeldung, Benutzerverwaltung
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
app/links.py Schnellzugriff-Links pro Konto app/links.py Schnellzugriff-Links pro Konto
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung) app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf)
app/haushalt.py Vorrat & Einkauf, Produktkatalog, Einheiten app/haushalt.py Vorrat & Einkauf, Produktkatalog, Einheiten
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen) app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
@@ -195,6 +201,7 @@ frontend/
src/theme.js, motion.js Design-Auswahl, Animations-Schalter src/theme.js, motion.js Design-Auswahl, Animations-Schalter
src/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, … src/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, …
src/pages/ Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, … src/pages/ Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, …
src/components/ServerDetail.jsx, LineChart.jsx Große Server-Ansicht, Verlaufsdiagramme (SVG)
src/data/laender.json Länderdaten (aus world-countries, ODbL) src/data/laender.json Länderdaten (aus world-countries, ODbL)
public/maps/ Weltkarte (Natural Earth über world-atlas) public/maps/ Weltkarte (Natural Earth über world-atlas)
+25
View File
@@ -95,9 +95,26 @@ CREATE TABLE IF NOT EXISTS monitors (
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
token TEXT, -- nur Typ "agent" (P42-S.M.); wird nie an den Browser gegeben
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
-- Verlauf der Server-Auslastung (Typ "agent"): ein Mittelwert pro Minute, 2 Tage lang
CREATE TABLE IF NOT EXISTS monitor_samples (
monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
ts INTEGER NOT NULL, -- Unix-Zeit, auf die Minute gerundet
cpu REAL,
mem REAL,
swap REAL,
load1 REAL,
rx REAL,
tx REAL,
rd REAL,
wr REAL,
temp REAL,
PRIMARY KEY (monitor_id, ts)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE};
-- Produktkatalog für die Vorschläge -- Produktkatalog für die Vorschläge
@@ -290,6 +307,13 @@ def _migrate_kanban_priority(conn) -> None:
) )
def _migrate_monitor_token(conn) -> None:
# Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen
columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")}
if "token" not in columns:
conn.execute("ALTER TABLE monitors ADD COLUMN token TEXT")
def _migrate_kanban_completed(conn) -> None: def _migrate_kanban_completed(conn) -> None:
# Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt # Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")}
@@ -327,6 +351,7 @@ def init() -> None:
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
_migrate(conn) _migrate(conn)
_migrate_travel(conn) _migrate_travel(conn)
_migrate_monitor_token(conn)
_migrate_kanban_categories(conn) _migrate_kanban_categories(conn)
_migrate_kanban_completed(conn) _migrate_kanban_completed(conn)
_migrate_kanban(conn) _migrate_kanban(conn)
+12 -1
View File
@@ -3,7 +3,9 @@
Liefert die API unter /api, die hochgeladenen Bilder unter /uploads Liefert die API unter /api, die hochgeladenen Bilder unter /uploads
und (falls gebaut) das React-Frontend aus frontend/dist. und (falls gebaut) das React-Frontend aus frontend/dist.
""" """
import asyncio
import re import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
@@ -15,7 +17,16 @@ from .auth import current_user
from .config import FRONTEND_DIST from .config import FRONTEND_DIST
from .tracking import track from .tracking import track
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json")
@asynccontextmanager
async def lifespan(_: FastAPI):
# Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln
sampler = asyncio.create_task(monitoring.sample_loop())
yield
sampler.cancel()
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
# Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB) # Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB)
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
+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 asyncio
import logging
import re import re
import time import time
@@ -11,18 +12,28 @@ from .db import now_iso, transaction
from .links import ValidationError, normalize_url from .links import ValidationError, normalize_url
router = APIRouter() router = APIRouter()
log = logging.getLogger("p42.monitoring")
TYPES = {"http", "tcp"} TYPES = {"http", "tcp", "agent"}
MAX_MONITORS = 50 MAX_MONITORS = 50
TIMEOUT = 5 TIMEOUT = 5
HOST_PORT = re.compile(r"(?P<host>[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):(?P<port>\d{1,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: 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 "" name = data.get("name").strip() if isinstance(data.get("name"), str) else ""
if not name or len(name) > 60: if not name or len(name) > 60:
raise ValidationError("Name fehlt oder ist zu lang (max. 60 Zeichen).") 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: if kind not in TYPES:
raise ValidationError("Ungültiger Prüf-Typ.") raise ValidationError("Ungültiger Prüf-Typ.")
target = data.get("target").strip() if isinstance(data.get("target"), str) else "" 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") target = normalize_url(target, "die Adresse")
if kind == "agent":
target = target.rstrip("/") # Pfade wie /api/metrics werden angehängt
if not target: if not target:
raise ValidationError("Adresse fehlt.") raise ValidationError("Adresse fehlt.")
else: else:
m = HOST_PORT.fullmatch(target) m = HOST_PORT.fullmatch(target)
if not m or not 0 < int(m["port"]) < 65536: if not m or not 0 < int(m["port"]) < 65536:
raise ValidationError("Bitte Host und Port angeben, z. B. 192.168.0.10:22") 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 ---------- # ---------- Verwaltung ----------
@@ -52,28 +80,33 @@ def list_monitors(user: dict = Depends(current_user)):
@router.post("/api/monitors", status_code=201) @router.post("/api/monitors", status_code=201)
def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)): def create_monitor(data: dict = Body(...), user: dict = Depends(current_user)):
values = _validate(data) values = _validate(data, is_new=True)
with transaction() as conn: with transaction() as conn:
count = conn.execute("SELECT COUNT(*) FROM monitors WHERE user_id = ?", (user["id"],)).fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM monitors WHERE user_id = ?", (user["id"],)).fetchone()[0]
if count >= MAX_MONITORS: if count >= MAX_MONITORS:
raise ValidationError(f"Maximal {MAX_MONITORS} Server pro Konto.") raise ValidationError(f"Maximal {MAX_MONITORS} Server pro Konto.")
cur = conn.execute( cur = conn.execute(
"INSERT INTO monitors (user_id, name, type, target, created_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO monitors (user_id, name, type, target, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(user["id"], values["name"], values["type"], values["target"], now_iso()), (user["id"], values["name"], values["type"], values["target"], values["token"], now_iso()),
) )
return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (cur.lastrowid,)).fetchone()) return _monitor(conn.execute("SELECT * FROM monitors WHERE id = ?", (cur.lastrowid,)).fetchone())
@router.put("/api/monitors/{monitor_id}") @router.put("/api/monitors/{monitor_id}")
def update_monitor(monitor_id: int, data: dict = Body(...), user: dict = Depends(current_user)): 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: with transaction() as conn:
cur = conn.execute( old = _get_own(conn, monitor_id, user["id"])
"UPDATE monitors SET name = ?, type = ?, target = ? WHERE id = ? AND user_id = ?", token = values["token"] or (old["token"] if values["type"] == "agent" else None)
(values["name"], values["type"], values["target"], monitor_id, user["id"]), 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: # Anderer Server → alter Verlauf gehört nicht mehr dazu
raise HTTPException(404, "Nicht gefunden.") 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()) 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"} 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") @router.get("/api/monitors/status")
async def monitor_status(user: dict = Depends(current_user)): async def monitor_status(user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
rows = conn.execute("SELECT id, type, target FROM monitors WHERE user_id = ?", (user["id"],)).fetchall() rows = conn.execute("SELECT id, type, target, token 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: async with _client() as client:
results = await asyncio.gather(*( def check(r):
_check_http(client, r["target"]) if r["type"] == "http" else _check_tcp(r["target"]) for r in rows 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() checked = now_iso()
return [{"id": r["id"], "checkedAt": checked, **res} for r, res in zip(rows, results)] 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)
+3
View File
@@ -22,6 +22,9 @@ if [ "$(stat -c %u data)" != "1000" ]; then
sudo chown -R 1000:1000 data sudo chown -R 1000:1000 data
fi fi
# Gemeinsames Netz mit P42-S.M. (Server-Auslastung) – wer zuerst startet, legt es an
docker network inspect p42 >/dev/null 2>&1 || docker network create p42 >/dev/null
docker compose up -d --build docker compose up -d --build
docker image prune -f >/dev/null docker image prune -f >/dev/null
+7
View File
@@ -18,3 +18,10 @@ services:
- ./data:/data - ./data:/data
# Einstellungen (API-Keys, Wikipedia-Kontakt) – nur lesend # Einstellungen (API-Keys, Wikipedia-Kontakt) – nur lesend
- ./config.json:/app/config.json:ro - ./config.json:/app/config.json:ro
# "p42": gemeinsames Netz mit P42-S.M. – dort ist der Agent unter http://p42-sm:9142 erreichbar
networks: [default, p42]
networks:
p42:
name: p42
external: true # legt deploy/update-docker.sh bei Bedarf an
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useRef, useState } from 'react';
// Schlankes Zeitreihen-Diagramm (SVG): eine y-Achse, dünne Linien, Lücken bei fehlenden Messungen,
// Fadenkreuz + Tooltip mit allen Reihen (Maus, Touch und Pfeiltasten).
const HEIGHT = 190;
const M = { top: 10, right: 14, bottom: 24, left: 56 };
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
const dayFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
const fullFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
// "Runde" Obergrenze für die y-Achse: 1, 2, 2.5, 5 × 10^n – bei Bytes je Einheit (500 KB statt 488 KB)
function niceMax(v, binary) {
if (!(v > 0)) return 1;
const unit = binary ? 1024 ** Math.max(0, Math.floor(Math.log(v) / Math.log(1024))) : 1;
const m = v / unit;
const p = 10 ** Math.floor(Math.log10(m));
// Bei Bytes nur Stufen, deren Viertel sich glatt beschriften lassen (8 MB → 2, 4, 6, 8 MB)
const steps = binary ? [1, 2, 4, 8, 10] : [1, 2, 2.5, 5, 10];
return steps.map((f) => f * p).find((n) => n >= m) * unit;
}
function tickStep(hours) {
if (hours <= 6) return 3600;
if (hours <= 24) return 4 * 3600;
return 6 * 3600;
}
export default function LineChart({ title, points, series, from, to, bucket, yMax, binary, format, loading }) {
const wrap = useRef(null);
const [width, setWidth] = useState(600);
const [hover, setHover] = useState(null); // Index in points
useEffect(() => {
const el = wrap.current;
if (!el) return undefined;
const ro = new ResizeObserver(([entry]) => setWidth(Math.max(260, entry.contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
const innerW = width - M.left - M.right;
const innerH = HEIGHT - M.top - M.bottom;
const x = (t) => M.left + ((t - from) / (to - from)) * innerW;
const top = useMemo(() => {
if (yMax) return yMax;
const max = Math.max(0, ...points.flatMap((p) => series.map((s) => p[s.key] ?? 0)));
return niceMax(max * 1.1, binary);
}, [points, series, yMax, binary]);
const y = (v) => M.top + innerH - (v / top) * innerH;
// Pfade: bei Lücken (Server weg, Dashboard aus) neu ansetzen statt quer durchzuziehen
const paths = useMemo(() => series.map((s) => {
let d = '';
let prev = null;
for (const p of points) {
const v = p[s.key];
if (v == null) { prev = null; continue; }
const gap = prev && p.t - prev.t > bucket * 2.5;
d += `${!prev || gap ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(v).toFixed(1)}`;
prev = p;
}
return d;
}), [points, series, bucket, top, width, from, to]);
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * top);
const xTicks = [];
const step = tickStep((to - from) / 3600);
// Ticks auf volle Stunden in lokaler Zeit
const offset = new Date(from * 1000).getTimezoneOffset() * 60;
for (let t = Math.ceil((from - offset) / step) * step + offset; t <= to; t += step) xTicks.push(t);
function pick(clientX) {
if (!points.length) return;
const rect = wrap.current.getBoundingClientRect();
const t = from + ((clientX - rect.left - M.left) / innerW) * (to - from);
let best = 0;
for (let i = 1; i < points.length; i += 1) {
if (Math.abs(points[i].t - t) < Math.abs(points[best].t - t)) best = i;
}
setHover(best);
}
function onKey(e) {
if (!points.length) return;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const dir = e.key === 'ArrowLeft' ? -1 : 1;
setHover((h) => Math.min(points.length - 1, Math.max(0, (h ?? points.length - 1) + (h == null ? 0 : dir))));
} else if (e.key === 'Escape') {
setHover(null);
}
}
const hp = hover != null ? points[hover] : null;
const last = points.at(-1);
const tipLeft = hp ? Math.min(Math.max(x(hp.t), 90), width - 90) : 0;
return (
<figure className={`chart ${loading ? 'is-loading' : ''}`}>
<figcaption className="chart-head">
<span className="chart-title">{title}</span>
{series.length > 1 && (
<span className="chart-legend">
{series.map((s) => (
<span key={s.key} className="chart-key">
<i style={{ background: s.color }} />
{s.label}
</span>
))}
</span>
)}
</figcaption>
<div className="chart-plot" ref={wrap}>
{!points.length ? (
<p className="chart-empty">Noch keine Werte – der Verlauf füllt sich jede Minute.</p>
) : (
<svg
width={width}
height={HEIGHT}
role="img"
aria-label={`${title}: Verlauf. Mit Pfeiltasten durch die Werte gehen.`}
tabIndex={0}
onPointerMove={(e) => pick(e.clientX)}
onPointerDown={(e) => pick(e.clientX)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setHover(null)}
onKeyDown={onKey}
onBlur={() => setHover(null)}
>
{yTicks.map((v) => (
<g key={v}>
<line className="chart-grid" x1={M.left} x2={width - M.right} y1={y(v)} y2={y(v)} />
<text className="chart-axis" x={M.left - 8} y={y(v)} dy="0.32em" textAnchor="end">{format(v, true)}</text>
</g>
))}
{xTicks.map((t) => {
const d = new Date(t * 1000);
const label = d.getHours() === 0 ? dayFmt.format(d) : timeFmt.format(d);
return (
<text key={t} className="chart-axis" x={x(t)} y={HEIGHT - 6} textAnchor="middle">{label}</text>
);
})}
{series.map((s, i) => (
<path key={s.key} d={paths[i]} className="chart-line" style={{ stroke: s.color }} />
))}
{/* Letzter Wert als Punkt: zeigt, wo "jetzt" ist */}
{!hp && last && series.map((s) => last[s.key] != null && (
<circle key={s.key} className="chart-dot" cx={x(last.t)} cy={y(last[s.key])} r={4} style={{ fill: s.color }} />
))}
{hp && (
<g>
<line className="chart-cross" x1={x(hp.t)} x2={x(hp.t)} y1={M.top} y2={M.top + innerH} />
{series.map((s) => hp[s.key] != null && (
<circle key={s.key} className="chart-dot" cx={x(hp.t)} cy={y(hp[s.key])} r={4.5} style={{ fill: s.color }} />
))}
</g>
)}
</svg>
)}
{hp && (
<div className="chart-tip" style={{ left: tipLeft }} role="status">
<div className="chart-tip-time">{fullFmt.format(new Date(hp.t * 1000))}</div>
{series.map((s) => (
<div key={s.key} className="chart-tip-row">
<i style={{ background: s.color }} />
<strong>{hp[s.key] == null ? '—' : format(hp[s.key])}</strong>
<span>{s.label}</span>
</div>
))}
</div>
)}
</div>
</figure>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import {
fmtBytes, fmtNum, fmtPct, fmtRate, fmtTemp, fmtUptime, level, maxTemp,
} from '../serverStats.js';
import LineChart from './LineChart.jsx';
// Große Ansicht eines P42-S.M.-Servers: aktuelle Werte (alle 5 s) + Verlauf bis 2 Tage (jede Minute neu)
const LIVE_SECONDS = 5;
const RANGES = [6, 24, 48];
const S1 = 'var(--series-1)';
const S2 = 'var(--series-2)';
const pct = (v, axis) => (axis ? `${Math.round(v)} %` : fmtPct(v));
const rate = (v, axis) => (axis ? fmtBytes(v, 1).replace(',0 ', ' ') : fmtRate(v));
const tableTime = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
export default function ServerDetail({ monitor, initial, onClose }) {
const dialog = useRef(null);
const [live, setLive] = useState(initial || null);
const [hours, setHours] = useState(48);
const [history, setHistory] = useState(null);
const [loadingHistory, setLoadingHistory] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
dialog.current?.showModal();
}, []);
const loadLive = useCallback(async () => {
try {
setLive(await api('GET', `/api/monitors/${monitor.id}/metrics`));
} catch (e) {
setLive({ ok: false, detail: e.message });
}
}, [monitor.id]);
const loadHistory = useCallback(async () => {
setLoadingHistory(true);
try {
setHistory(await api('GET', `/api/monitors/${monitor.id}/history?hours=${hours}`));
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoadingHistory(false);
}
}, [monitor.id, hours]);
useEffect(() => {
loadLive();
const timer = setInterval(loadLive, LIVE_SECONDS * 1000);
return () => clearInterval(timer);
}, [loadLive]);
useEffect(() => {
loadHistory();
const timer = setInterval(loadHistory, 60 * 1000);
return () => clearInterval(timer);
}, [loadHistory]);
const m = live?.ok ? live.metrics : null;
const to = Math.floor(Date.now() / 1000);
const from = to - hours * 3600;
const points = history?.points || [];
const chart = { points, from, to, bucket: history?.bucketSeconds || 60, loading: loadingHistory };
const hasTemp = points.some((p) => p.temp != null);
const realNets = (m?.network?.interfaces || []).filter((i) => !i.virtual);
const virtualNets = (m?.network?.interfaces || []).filter((i) => i.virtual);
return (
<dialog
className="modal server-modal"
ref={dialog}
onClose={onClose}
onClick={(e) => e.target === dialog.current && dialog.current.close()}
>
<div className="server-detail">
<header className="server-detail-head">
<div>
<h2>{monitor.name}</h2>
{m ? (
<p className="server-detail-sub">
{m.host.hostname} · {m.host.os} · Kernel {m.host.kernel} · läuft seit {fmtUptime(m.host.uptimeSeconds)}
<br />
{m.cpu.model} · {m.cpu.threads} Threads{m.cpu.mhz ? ` · ${fmtNum(m.cpu.mhz, 0)} MHz` : ''}
</p>
) : (
<p className="server-detail-sub">{monitor.target}</p>
)}
</div>
<div className="server-detail-actions">
<span className={`badge ${!live ? 'muted' : live.ok ? 'ok' : 'bad'}`}>
{!live ? 'Lade …' : live.ok ? 'Online' : live.detail || 'Offline'}
</span>
<button className="btn ghost" type="button" onClick={() => dialog.current.close()} aria-label="Schließen">✕</button>
</div>
</header>
{m && (
<div className="server-tiles">
<Tile label="CPU" value={fmtPct(m.cpu.percent)} pct={m.cpu.percent} sub={`iowait ${fmtPct(m.cpu.iowaitPercent)}`} />
<Tile
label="RAM"
value={fmtPct(m.memory.percent)}
pct={m.memory.percent}
sub={`${fmtBytes(m.memory.usedBytes)} von ${fmtBytes(m.memory.totalBytes)}`}
/>
<Tile
label="Swap"
value={m.swap.totalBytes ? fmtPct(m.swap.percent) : 'aus'}
pct={m.swap.totalBytes ? m.swap.percent : null}
sub={m.swap.totalBytes ? `${fmtBytes(m.swap.usedBytes)} von ${fmtBytes(m.swap.totalBytes)}` : ''}
/>
<Tile label="Load" value={fmtNum(m.cpu.load['1m'])} sub={`5 min ${fmtNum(m.cpu.load['5m'])} · 15 min ${fmtNum(m.cpu.load['15m'])}`} />
<Tile label="Temperatur" value={fmtTemp(maxTemp(m))} sub="höchster Sensor" />
<Tile label="Netzwerk" value={`↓ ${fmtRate(m.network.rxBytesPerSec)}`} sub={`↑ ${fmtRate(m.network.txBytesPerSec)}`} />
</div>
)}
<div className="server-range" role="group" aria-label="Zeitraum">
<span className="eyebrow">Verlauf</span>
{RANGES.map((h) => (
<button
key={h}
type="button"
className={`chip ${hours === h ? 'active' : ''}`}
aria-pressed={hours === h}
onClick={() => setHours(h)}
>
{h === 48 ? '2 Tage' : `${h} h`}
</button>
))}
{history && <span className="hint">Mittelwerte je {Math.round(history.bucketSeconds / 60)} min</span>}
</div>
{error && <p className="error">{error}</p>}
<div className="server-charts">
<LineChart
title="Auslastung"
series={[{ key: 'cpu', label: 'CPU', color: S1 }, { key: 'mem', label: 'RAM', color: S2 }]}
yMax={100}
format={pct}
{...chart}
/>
<LineChart
title="Netzwerk"
series={[{ key: 'rx', label: 'Empfangen', color: S1 }, { key: 'tx', label: 'Gesendet', color: S2 }]}
format={rate}
binary
{...chart}
/>
<LineChart
title="Platten-Aktivität"
series={[{ key: 'read', label: 'Lesen', color: S1 }, { key: 'write', label: 'Schreiben', color: S2 }]}
format={rate}
binary
{...chart}
/>
<LineChart title="Load (1 min)" series={[{ key: 'load', label: 'Load', color: S1 }]} format={(v) => fmtNum(v)} {...chart} />
{hasTemp && (
<LineChart title="Temperatur (höchster Sensor)" series={[{ key: 'temp', label: 'Temperatur', color: S1 }]} format={(v) => fmtTemp(v)} {...chart} />
)}
<LineChart title="Swap" series={[{ key: 'swap', label: 'Swap', color: S1 }]} yMax={100} format={pct} {...chart} />
</div>
{points.length > 0 && (
<details className="server-table">
<summary>Verlauf als Tabelle</summary>
<div className="server-table-scroll">
<table>
<thead>
<tr><th>Zeit</th><th>CPU</th><th>RAM</th><th>Swap</th><th>Load</th><th>Empfangen</th><th>Gesendet</th><th>Lesen</th><th>Schreiben</th><th>Temp.</th></tr>
</thead>
<tbody>
{[...points].reverse().map((p) => (
<tr key={p.t}>
<td>{tableTime.format(new Date(p.t * 1000))}</td>
<td>{fmtPct(p.cpu)}</td>
<td>{fmtPct(p.mem)}</td>
<td>{fmtPct(p.swap)}</td>
<td>{fmtNum(p.load)}</td>
<td>{fmtRate(p.rx)}</td>
<td>{fmtRate(p.tx)}</td>
<td>{fmtRate(p.read)}</td>
<td>{fmtRate(p.write)}</td>
<td>{fmtTemp(p.temp)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
{m && (
<div className="server-sections">
<section>
<h3>Platten</h3>
{m.disks.map((d) => (
<Meter
key={d.device}
label={d.mount}
pct={d.percent}
detail={`${fmtBytes(d.usedBytes)} von ${fmtBytes(d.totalBytes)} · ${fmtBytes(d.freeBytes)} frei`}
title={`${d.device} (${d.fs})`}
/>
))}
</section>
<section>
<h3>CPU-Threads</h3>
<div className="server-cores">
{m.cpu.perCorePercent.map((v, i) => (
<Meter key={i} label={`#${i}`} pct={v} compact />
))}
</div>
</section>
{m.temperatures.length > 0 && (
<section>
<h3>Temperaturen</h3>
<dl className="server-list">
{m.temperatures.map((t) => (
<div key={`${t.sensor}-${t.label}`}>
<dt>{t.sensor} · {t.label}</dt>
<dd>
{fmtTemp(t.celsius)}
{t.criticalCelsius ? <small> / kritisch {fmtTemp(t.criticalCelsius)}</small> : null}
</dd>
</div>
))}
</dl>
</section>
)}
<section>
<h3>Netzwerk</h3>
<dl className="server-list">
{realNets.map((n) => (
<div key={n.interface}>
<dt>{n.interface}</dt>
<dd>↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)}<small> · gesamt ↓ {fmtBytes(n.rxBytesTotal)} ↑ {fmtBytes(n.txBytesTotal)}</small></dd>
</div>
))}
</dl>
{virtualNets.length > 0 && (
<details>
<summary className="hint">{virtualNets.length} Docker-Netze</summary>
<dl className="server-list">
{virtualNets.map((n) => (
<div key={n.interface}>
<dt>{n.interface}</dt>
<dd>↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)}</dd>
</div>
))}
</dl>
</details>
)}
<p className="hint">
Prozesse: {m.processes.total} · Platten gerade ↓ {fmtRate(m.diskIo.readBytesPerSec)} ↑ {fmtRate(m.diskIo.writeBytesPerSec)}
</p>
</section>
</div>
)}
</div>
</dialog>
);
}
function Tile({ label, value, sub, pct }) {
const lv = level(pct);
return (
<div className={`server-tile ${lv}`}>
<span className="eyebrow">{label}</span>
<strong>{value}{lv === 'bad' && <span className="sr-flag"> kritisch</span>}</strong>
{sub && <small>{sub}</small>}
{pct != null && <span className="meter-bar"><i style={{ width: `${Math.min(100, pct)}%` }} /></span>}
</div>
);
}
export function Meter({ label, pct, detail, title, compact }) {
const lv = level(pct);
return (
<div className={`meter ${lv} ${compact ? 'compact' : ''}`} title={title}>
<div className="meter-label">
<span>{label}</span>
<strong>{fmtPct(pct)}{lv === 'bad' && !compact ? ' · kritisch' : ''}</strong>
</div>
<span className="meter-bar"><i style={{ width: `${Math.min(100, pct || 0)}%` }} /></span>
{detail && <small>{detail}</small>}
</div>
);
}
+71 -12
View File
@@ -1,10 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import ServerDetail, { Meter } from '../components/ServerDetail.jsx';
import { fmtNum, fmtTemp, fmtUptime, fullestDisk, maxTemp } from '../serverStats.js';
const REFRESH_SECONDS = 30; const REFRESH_SECONDS = 30;
const TYPES = { const TYPES = {
http: { label: 'Webseite (HTTP)', placeholder: 'https://jellyfin.example.de', hint: 'Online, wenn die Seite antwortet.' }, http: { label: 'Webseite (HTTP)', placeholder: 'https://jellyfin.example.de', hint: 'Online, wenn die Seite antwortet.' },
tcp: { label: 'Port (TCP)', placeholder: '192.168.0.10:22', hint: 'Online, wenn der Port Verbindungen annimmt.' }, tcp: { label: 'Port (TCP)', placeholder: '192.168.0.10:22', hint: 'Online, wenn der Port Verbindungen annimmt.' },
agent: {
label: 'Server-Auslastung (P42-S.M.)',
placeholder: 'http://p42-sm:9142',
hint: 'CPU, RAM, Platten, Netzwerk und Verlauf über den P42-S.M.-Agenten. Läuft er auf demselben Server, lautet die Adresse http://p42-sm:9142.',
},
}; };
const fmtTime = (iso) => new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const fmtTime = (iso) => new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -15,6 +22,7 @@ export default function Monitoring() {
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editing, setEditing] = useState(null); // null | {} (neu) | monitor const [editing, setEditing] = useState(null); // null | {} (neu) | monitor
const [detail, setDetail] = useState(null); // geöffneter Server (Typ agent)
const dialog = useRef(null); const dialog = useRef(null);
const check = useCallback(async () => { const check = useCallback(async () => {
@@ -103,22 +111,35 @@ export default function Monitoring() {
{monitors.map((m) => { {monitors.map((m) => {
const s = status[m.id]; const s = status[m.id];
const state = !s ? 'unknown' : s.ok ? 'ok' : 'bad'; const state = !s ? 'unknown' : s.ok ? 'ok' : 'bad';
const isAgent = m.type === 'agent';
// Server-Karten öffnen per Klick die große Ansicht
const open = isAgent ? {
role: 'button',
tabIndex: 0,
'aria-label': `${m.name}: Details und Verlauf öffnen`,
onClick: (e) => !e.target.closest('button') && setDetail(m),
onKeyDown: (e) => (e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget && (e.preventDefault(), setDetail(m)),
} : {};
return ( return (
<article className={`card monitor-card ${state}`} key={m.id}> <article className={`card monitor-card ${state} ${isAgent ? 'clickable' : ''}`} key={m.id} {...open}>
<div className="monitor-head"> <div className="monitor-head">
<h3>{m.name}</h3> <h3>{m.name}</h3>
<span className={`badge ${state === 'ok' ? 'ok' : state === 'bad' ? 'bad' : 'muted'}`}> <span className={`badge ${state === 'ok' ? 'ok' : state === 'bad' ? 'bad' : 'muted'}`}>
{state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'} {state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'}
</span> </span>
</div> </div>
<p className="monitor-target">{m.target}</p> <p className="monitor-target">{isAgent && s?.metrics ? `${s.metrics.host.hostname} · ${s.metrics.host.os}` : m.target}</p>
<dl className="monitor-facts"> {isAgent && s?.metrics ? (
<div><dt>Typ</dt><dd>{TYPES[m.type]?.label}</dd></div> <AgentSummary metrics={s.metrics} />
<div><dt>Antwortzeit</dt><dd>{s?.ms != null ? `${s.ms} ms` : '—'}</dd></div> ) : (
<div><dt>Ergebnis</dt><dd>{s?.detail || '—'}</dd></div> <dl className="monitor-facts">
</dl> <div><dt>Typ</dt><dd>{TYPES[m.type]?.label}</dd></div>
<div><dt>Antwortzeit</dt><dd>{s?.ms != null ? `${s.ms} ms` : '—'}</dd></div>
<div><dt>Ergebnis</dt><dd>{s?.detail || '—'}</dd></div>
</dl>
)}
<div className="meta monitor-foot"> <div className="meta monitor-foot">
<span>{s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}</span> <span>{s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}{isAgent && ' · Klicken für Details'}</span>
<span className="monitor-actions"> <span className="monitor-actions">
<button className="link-btn" type="button" onClick={() => openDialog(m)}>Bearbeiten</button> <button className="link-btn" type="button" onClick={() => openDialog(m)}>Bearbeiten</button>
<button className="link-btn danger" type="button" onClick={() => remove(m)}>Entfernen</button> <button className="link-btn danger" type="button" onClick={() => remove(m)}>Entfernen</button>
@@ -132,13 +153,37 @@ export default function Monitoring() {
)} )}
<MonitorDialog key={editing?.opened ?? 'zu'} ref={dialog} monitor={editing} onSaved={load} /> <MonitorDialog key={editing?.opened ?? 'zu'} ref={dialog} monitor={editing} onSaved={load} />
{detail && (
<ServerDetail
key={detail.id}
monitor={detail}
initial={status[detail.id]}
onClose={() => setDetail(null)}
/>
)}
</> </>
); );
} }
function AgentSummary({ metrics: x }) {
const disk = fullestDisk(x);
return (
<div className="agent-summary">
<Meter label="CPU" pct={x.cpu.percent} />
<Meter label="RAM" pct={x.memory.percent} />
{disk && <Meter label={`Platte ${disk.mount}`} pct={disk.percent} />}
<dl className="monitor-facts">
<div><dt>Load</dt><dd>{fmtNum(x.cpu.load['1m'])}</dd></div>
<div><dt>Temperatur</dt><dd>{fmtTemp(maxTemp(x))}</dd></div>
<div><dt>Läuft seit</dt><dd>{fmtUptime(x.host.uptimeSeconds)}</dd></div>
</dl>
</div>
);
}
function MonitorDialog({ ref, monitor, onSaved }) { function MonitorDialog({ ref, monitor, onSaved }) {
// Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt vorbelegen // Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt vorbelegen
const [form, setForm] = useState(() => ({ name: monitor?.name || '', type: monitor?.type || 'http', target: monitor?.target || '' })); const [form, setForm] = useState(() => ({ name: monitor?.name || '', type: monitor?.type || 'http', target: monitor?.target || '', token: '' }));
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const isNew = !monitor?.id; const isNew = !monitor?.id;
@@ -150,8 +195,9 @@ function MonitorDialog({ ref, monitor, onSaved }) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
if (isNew) await api('POST', '/api/monitors', form); const body = { ...form, token: form.type === 'agent' ? form.token : undefined };
else await api('PUT', `/api/monitors/${monitor.id}`, form); if (isNew) await api('POST', '/api/monitors', body);
else await api('PUT', `/api/monitors/${monitor.id}`, body);
ref.current.close(); ref.current.close();
await onSaved(); await onSaved();
} catch (err) { } catch (err) {
@@ -178,9 +224,22 @@ function MonitorDialog({ ref, monitor, onSaved }) {
</select> </select>
</label> </label>
<label> <label>
{form.type === 'http' ? 'Adresse' : 'Host und Port'} {form.type === 'tcp' ? 'Host und Port' : 'Adresse'}
<input {...field('target')} placeholder={type.placeholder} autoCapitalize="none" spellCheck={false} required /> <input {...field('target')} placeholder={type.placeholder} autoCapitalize="none" spellCheck={false} required />
</label> </label>
{form.type === 'agent' && (
<label>
Token
<input
{...field('token')}
type="password"
autoComplete="off"
spellCheck={false}
placeholder={!isNew && monitor.hasToken ? 'leer lassen = unverändert' : 'SM_TOKEN aus der .env von P42-S.M.'}
required={isNew || !monitor.hasToken}
/>
</label>
)}
<p className="hint">{type.hint}</p> <p className="hint">{type.hint}</p>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<div className="form-actions"> <div className="form-actions">
+41
View File
@@ -0,0 +1,41 @@
// Formatierung der Auslastungswerte von P42-S.M.
const nf = (digits) => new Intl.NumberFormat('de-DE', { maximumFractionDigits: digits, minimumFractionDigits: digits });
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
export function fmtBytes(bytes, digits = 1) {
if (bytes == null) return '—';
let i = 0;
let v = bytes;
while (v >= 1024 && i < UNITS.length - 1) {
v /= 1024;
i += 1;
}
return `${nf(i === 0 ? 0 : digits).format(v)} ${UNITS[i]}`;
}
export const fmtRate = (bytesPerSec) => (bytesPerSec == null ? '—' : `${fmtBytes(bytesPerSec)}/s`);
export const fmtPct = (v) => (v == null ? '—' : `${nf(v < 10 ? 1 : 0).format(v)} %`);
export const fmtNum = (v, digits = 2) => (v == null ? '—' : nf(digits).format(v));
export const fmtTemp = (v) => (v == null ? '—' : `${nf(0).format(v)} °C`);
export function fmtUptime(seconds) {
if (seconds == null) return '—';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d) return `${d} T ${h} h`;
if (h) return `${h} h ${m} min`;
return `${m} min`;
}
// Ab hier wird eine Auslastung als bedenklich bzw. kritisch markiert
export const level = (pct) => (pct == null ? '' : pct >= 90 ? 'bad' : pct >= 75 ? 'warn' : '');
export const fullestDisk = (metrics) =>
(metrics?.disks || []).reduce((max, d) => (!max || d.percent > max.percent ? d : max), null);
export const maxTemp = (metrics) => {
const temps = (metrics?.temperatures || []).map((t) => t.celsius);
return temps.length ? Math.max(...temps) : null;
};
+101
View File
@@ -24,6 +24,9 @@
--warn: #ffa66e; --warn: #ffa66e;
--bad: #ff3d3d; --bad: #ff3d3d;
--bad-glow: rgb(255 61 61 / 0.6); --bad-glow: rgb(255 61 61 / 0.6);
/* Diagramm-Reihen (geprüft auf Farbenblindheit vor --surface): 1 = Akzent, 2 = Gegenfarbe */
--series-1: #ff4646;
--series-2: #9b7fe0;
--topbar-bg: rgb(8 2 3 / 0.76); --topbar-bg: rgb(8 2 3 / 0.76);
--glow: 0 0 6px rgb(var(--accent-rgb) / 0.55), 0 0 20px rgb(var(--accent-rgb) / 0.2); --glow: 0 0 6px rgb(var(--accent-rgb) / 0.55), 0 0 20px rgb(var(--accent-rgb) / 0.2);
--heading-glow: 0 0 6px rgb(var(--accent-rgb) / 0.55), 0 0 26px rgb(var(--accent-rgb) / 0.25); --heading-glow: 0 0 6px rgb(var(--accent-rgb) / 0.55), 0 0 26px rgb(var(--accent-rgb) / 0.25);
@@ -640,6 +643,104 @@ h3 { font-size: var(--h3-size); line-height: 1.3; }
.monitor-foot { justify-content: space-between; margin-top: auto; padding-top: 16px; flex-wrap: wrap; } .monitor-foot { justify-content: space-between; margin-top: auto; padding-top: 16px; flex-wrap: wrap; }
.monitor-actions { display: flex; gap: 14px; } .monitor-actions { display: flex; gap: 14px; }
/* Server-Auslastung (P42-S.M.): Karte → große Ansicht */
.monitor-card.clickable { cursor: pointer; transition: transform var(--dur) var(--ease-out), border-color var(--dur); }
.monitor-card.clickable:hover { transform: translateY(var(--lift)); border-color: var(--line-strong); }
.monitor-card.clickable:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.monitor-card.clickable .monitor-target { word-break: normal; overflow-wrap: anywhere; }
.agent-summary { display: grid; gap: 10px; margin-top: 14px; }
.agent-summary .monitor-facts { margin-top: 4px; }
.meter { display: grid; gap: 4px; min-width: 0; }
.meter-label { display: flex; justify-content: space-between; gap: 8px; font-size: 0.82rem; color: var(--text); }
.meter-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meter-label strong { color: var(--bright); font-weight: 600; white-space: nowrap; }
.meter small { color: var(--muted); font-size: 0.76rem; }
.meter-bar { display: block; height: 6px; border-radius: var(--radius-pill); background: var(--accent-soft); overflow: hidden; }
.meter-bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width 0.6s var(--ease-out); }
.meter.warn .meter-bar i, .server-tile.warn .meter-bar i { background: var(--warn); }
.meter.bad .meter-bar i, .server-tile.bad .meter-bar i { background: var(--bad); }
.meter.bad .meter-label strong { color: var(--bad); }
.meter.compact .meter-label { font-size: 0.74rem; }
.server-modal { width: min(1280px, calc(100% - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: clamp(16px, 2.4vw, 30px); }
.server-detail { display: grid; gap: 18px; }
.server-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.server-detail-head h2 { margin: 0; }
.server-detail-sub { margin: 6px 0 0; color: var(--muted); font-size: 0.84rem; line-height: 1.6; }
.server-detail-actions { display: flex; align-items: center; gap: 10px; }
.server-detail-actions .btn { padding: 0 14px; }
.server-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.server-tile { display: grid; gap: 4px; align-content: start; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-m); background: var(--card-inner); }
.server-tile strong { font-family: var(--tile-font); font-size: var(--tile-size); font-weight: 400; color: var(--bright); line-height: 1.1; }
.server-tile small { color: var(--muted); font-size: 0.76rem; }
.server-tile .meter-bar { margin-top: 4px; }
.server-tile.bad strong { color: var(--bad); }
.sr-flag { font-family: var(--font-label); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.server-range { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.server-range .eyebrow { margin-right: 4px; }
.server-range .hint { margin: 0 0 0 auto; font-size: 0.8rem; }
.server-charts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
@media (min-width: 900px) { .server-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.chart { margin: 0; padding: 12px 14px 8px; border: 1px solid var(--border); border-radius: var(--radius-m); background: var(--card-inner); min-width: 0; transition: opacity var(--dur); }
.chart.is-loading { opacity: 0.6; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 6px; }
.chart-title { font-family: var(--font-label); font-size: var(--label-size); letter-spacing: var(--label-tracking); text-transform: uppercase; color: var(--text); }
.chart-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 0.78rem; color: var(--text); }
.chart-key { display: inline-flex; align-items: center; gap: 6px; }
.chart-key i, .chart-tip-row i { width: 14px; height: 2px; border-radius: 1px; display: inline-block; flex: none; }
.chart-plot { position: relative; width: 100%; }
.chart-plot svg { display: block; touch-action: pan-y; outline: none; }
.chart-plot svg:focus-visible { box-shadow: var(--focus-ring); }
.chart-grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }
.chart-axis { fill: var(--muted); font-size: 10.5px; font-family: var(--font); }
.chart-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-dot { stroke: var(--card-inner); stroke-width: 2; }
.chart-cross { stroke: var(--line-strong); stroke-width: 1; }
.chart-empty { margin: 0; height: 190px; display: grid; place-items: center; color: var(--muted); font-size: 0.85rem; text-align: center; }
.chart-tip {
position: absolute;
top: 4px;
transform: translateX(-50%);
min-width: 160px;
padding: 8px 10px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-s);
background: var(--panel);
box-shadow: var(--shadow-2);
pointer-events: none;
font-size: 0.78rem;
z-index: 2;
}
.chart-tip-time { color: var(--muted); margin-bottom: 4px; }
.chart-tip-row { display: flex; align-items: center; gap: 8px; }
.chart-tip-row strong { color: var(--bright); font-weight: 600; }
.chart-tip-row span { color: var(--muted); }
.server-table summary, .server-sections details summary { cursor: pointer; }
.server-table summary { color: var(--muted); font-size: 0.85rem; }
.server-table-scroll { max-height: 320px; overflow: auto; margin-top: 8px; border: 1px solid var(--border); }
.server-table table { width: 100%; border-collapse: collapse; font-size: 0.78rem; white-space: nowrap; }
.server-table th, .server-table td { padding: 5px 10px; text-align: right; border-bottom: 1px solid var(--border); }
.server-table th:first-child, .server-table td:first-child { text-align: left; }
.server-table th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-weight: 600; }
.server-sections { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
@media (min-width: 900px) { .server-sections { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.server-sections section { display: grid; gap: 10px; align-content: start; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius-m); background: var(--card-inner); min-width: 0; }
.server-sections h3 { margin: 0; }
.server-sections .hint { margin: 0; font-size: 0.8rem; }
.server-cores { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 8px 12px; }
.server-list { display: grid; gap: 6px; margin: 0; }
.server-list div { display: flex; justify-content: space-between; gap: 12px; font-size: 0.84rem; border-bottom: 1px solid var(--border); padding-bottom: 5px; }
.server-list dt { color: var(--muted); overflow-wrap: anywhere; }
.server-list dd { margin: 0; color: var(--bright); text-align: right; }
.server-list small { color: var(--muted); }
/* ---------- Vorrat & Einkauf ---------- */ /* ---------- Vorrat & Einkauf ---------- */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+2
View File
@@ -76,6 +76,8 @@ html[data-theme='blackwall'] {
--warn: var(--bw-yellow); --warn: var(--bw-yellow);
--bad: #ff3b30; --bad: #ff3b30;
--bad-glow: rgb(255 59 48 / 0.55); --bad-glow: rgb(255 59 48 / 0.55);
--series-1: #ff2d6f;
--series-2: #00a8b0;
--topbar-bg: rgb(6 1 4 / 0.72); --topbar-bg: rgb(6 1 4 / 0.72);
--glow: 0 0 8px rgb(255 45 111 / 0.55), 0 0 26px rgb(255 45 111 / 0.2); --glow: 0 0 8px rgb(255 45 111 / 0.55), 0 0 26px rgb(255 45 111 / 0.2);
--heading-glow: -2px 0 0 rgb(63 246 255 / 0.55), 2px 0 0 rgb(255 45 111 / 0.75), 0 0 22px rgb(255 45 111 / 0.35); --heading-glow: -2px 0 0 rgb(63 246 255 / 0.55), 2px 0 0 rgb(255 45 111 / 0.75), 0 0 22px rgb(255 45 111 / 0.35);