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
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).
+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. Ä.).
- **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.
@@ -179,7 +185,7 @@ backend/
app/auth.py Konten, Anmeldung, Benutzerverwaltung
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
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/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
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/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, …
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)
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,
type 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
);
-- 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};
-- 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:
# Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")}
@@ -327,6 +351,7 @@ def init() -> None:
conn.executescript(SCHEMA)
_migrate(conn)
_migrate_travel(conn)
_migrate_monitor_token(conn)
_migrate_kanban_categories(conn)
_migrate_kanban_completed(conn)
_migrate_kanban(conn)
+12 -1
View File
@@ -3,7 +3,9 @@
Liefert die API unter /api, die hochgeladenen Bilder unter /uploads
und (falls gebaut) das React-Frontend aus frontend/dist.
"""
import asyncio
import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.gzip import GZipMiddleware
@@ -15,7 +17,16 @@ from .auth import current_user
from .config import FRONTEND_DIST
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)
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 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)
+3
View File
@@ -22,6 +22,9 @@ if [ "$(stat -c %u data)" != "1000" ]; then
sudo chown -R 1000:1000 data
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 image prune -f >/dev/null
+7
View File
@@ -18,3 +18,10 @@ services:
- ./data:/data
# Einstellungen (API-Keys, Wikipedia-Kontakt) – nur lesend
- ./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 { 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 TYPES = {
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.' },
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' });
@@ -15,6 +22,7 @@ export default function Monitoring() {
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(null); // null | {} (neu) | monitor
const [detail, setDetail] = useState(null); // geöffneter Server (Typ agent)
const dialog = useRef(null);
const check = useCallback(async () => {
@@ -103,22 +111,35 @@ export default function Monitoring() {
{monitors.map((m) => {
const s = status[m.id];
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 (
<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">
<h3>{m.name}</h3>
<span className={`badge ${state === 'ok' ? 'ok' : state === 'bad' ? 'bad' : 'muted'}`}>
{state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'}
</span>
</div>
<p className="monitor-target">{m.target}</p>
<dl className="monitor-facts">
<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>
<p className="monitor-target">{isAgent && s?.metrics ? `${s.metrics.host.hostname} · ${s.metrics.host.os}` : m.target}</p>
{isAgent && s?.metrics ? (
<AgentSummary metrics={s.metrics} />
) : (
<dl className="monitor-facts">
<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">
<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">
<button className="link-btn" type="button" onClick={() => openDialog(m)}>Bearbeiten</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} />
{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 }) {
// 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 [busy, setBusy] = useState(false);
const isNew = !monitor?.id;
@@ -150,8 +195,9 @@ function MonitorDialog({ ref, monitor, onSaved }) {
setBusy(true);
setError(null);
try {
if (isNew) await api('POST', '/api/monitors', form);
else await api('PUT', `/api/monitors/${monitor.id}`, form);
const body = { ...form, token: form.type === 'agent' ? form.token : undefined };
if (isNew) await api('POST', '/api/monitors', body);
else await api('PUT', `/api/monitors/${monitor.id}`, body);
ref.current.close();
await onSaved();
} catch (err) {
@@ -178,9 +224,22 @@ function MonitorDialog({ ref, monitor, onSaved }) {
</select>
</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 />
</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>
{error && <p className="error">{error}</p>}
<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;
--bad: #ff3d3d;
--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);
--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);
@@ -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-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 ---------- */
.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);
--bad: #ff3b30;
--bad-glow: rgb(255 59 48 / 0.55);
--series-1: #ff2d6f;
--series-2: #00a8b0;
--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);
--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);