diff --git a/DEPLOY.md b/DEPLOY.md index e2a4ffd..af13fe2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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). diff --git a/README.md b/README.md index 6828157..09f21ba 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/app/db.py b/backend/app/db.py index a2079e7..4402034 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 153a815..2271986 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/monitoring.py b/backend/app/monitoring.py index 35a314e..9a86d4c 100644 --- a/backend/app/monitoring.py +++ b/backend/app/monitoring.py @@ -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[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):(?P\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) diff --git a/deploy/update-docker.sh b/deploy/update-docker.sh index 3f3a7c0..90da0d7 100755 --- a/deploy/update-docker.sh +++ b/deploy/update-docker.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 154c5d0..b52312f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/components/LineChart.jsx b/frontend/src/components/LineChart.jsx new file mode 100644 index 0000000..46fc52f --- /dev/null +++ b/frontend/src/components/LineChart.jsx @@ -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 ( +
+
+ {title} + {series.length > 1 && ( + + {series.map((s) => ( + + + {s.label} + + ))} + + )} +
+ +
+ {!points.length ? ( +

Noch keine Werte – der Verlauf füllt sich jede Minute.

+ ) : ( + pick(e.clientX)} + onPointerDown={(e) => pick(e.clientX)} + onPointerLeave={(e) => e.pointerType === 'mouse' && setHover(null)} + onKeyDown={onKey} + onBlur={() => setHover(null)} + > + {yTicks.map((v) => ( + + + {format(v, true)} + + ))} + {xTicks.map((t) => { + const d = new Date(t * 1000); + const label = d.getHours() === 0 ? dayFmt.format(d) : timeFmt.format(d); + return ( + {label} + ); + })} + {series.map((s, i) => ( + + ))} + {/* Letzter Wert als Punkt: zeigt, wo "jetzt" ist */} + {!hp && last && series.map((s) => last[s.key] != null && ( + + ))} + {hp && ( + + + {series.map((s) => hp[s.key] != null && ( + + ))} + + )} + + )} + {hp && ( +
+
{fullFmt.format(new Date(hp.t * 1000))}
+ {series.map((s) => ( +
+ + {hp[s.key] == null ? '—' : format(hp[s.key])} + {s.label} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ServerDetail.jsx b/frontend/src/components/ServerDetail.jsx new file mode 100644 index 0000000..8e25cd1 --- /dev/null +++ b/frontend/src/components/ServerDetail.jsx @@ -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 ( + e.target === dialog.current && dialog.current.close()} + > +
+
+
+

{monitor.name}

+ {m ? ( +

+ {m.host.hostname} · {m.host.os} · Kernel {m.host.kernel} · läuft seit {fmtUptime(m.host.uptimeSeconds)} +
+ {m.cpu.model} · {m.cpu.threads} Threads{m.cpu.mhz ? ` · ${fmtNum(m.cpu.mhz, 0)} MHz` : ''} +

+ ) : ( +

{monitor.target}

+ )} +
+
+ + {!live ? 'Lade …' : live.ok ? 'Online' : live.detail || 'Offline'} + + +
+
+ + {m && ( +
+ + + + + + +
+ )} + +
+ Verlauf + {RANGES.map((h) => ( + + ))} + {history && Mittelwerte je {Math.round(history.bucketSeconds / 60)} min} +
+ {error &&

{error}

} + +
+ + + + fmtNum(v)} {...chart} /> + {hasTemp && ( + fmtTemp(v)} {...chart} /> + )} + +
+ + {points.length > 0 && ( +
+ Verlauf als Tabelle +
+ + + + + + {[...points].reverse().map((p) => ( + + + + + + + + + + + + + ))} + +
ZeitCPURAMSwapLoadEmpfangenGesendetLesenSchreibenTemp.
{tableTime.format(new Date(p.t * 1000))}{fmtPct(p.cpu)}{fmtPct(p.mem)}{fmtPct(p.swap)}{fmtNum(p.load)}{fmtRate(p.rx)}{fmtRate(p.tx)}{fmtRate(p.read)}{fmtRate(p.write)}{fmtTemp(p.temp)}
+
+
+ )} + + {m && ( +
+
+

Platten

+ {m.disks.map((d) => ( + + ))} +
+ +
+

CPU-Threads

+
+ {m.cpu.perCorePercent.map((v, i) => ( + + ))} +
+
+ + {m.temperatures.length > 0 && ( +
+

Temperaturen

+
+ {m.temperatures.map((t) => ( +
+
{t.sensor} · {t.label}
+
+ {fmtTemp(t.celsius)} + {t.criticalCelsius ? / kritisch {fmtTemp(t.criticalCelsius)} : null} +
+
+ ))} +
+
+ )} + +
+

Netzwerk

+
+ {realNets.map((n) => ( +
+
{n.interface}
+
↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)} · gesamt ↓ {fmtBytes(n.rxBytesTotal)} ↑ {fmtBytes(n.txBytesTotal)}
+
+ ))} +
+ {virtualNets.length > 0 && ( +
+ {virtualNets.length} Docker-Netze +
+ {virtualNets.map((n) => ( +
+
{n.interface}
+
↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)}
+
+ ))} +
+
+ )} +

+ Prozesse: {m.processes.total} · Platten gerade ↓ {fmtRate(m.diskIo.readBytesPerSec)} ↑ {fmtRate(m.diskIo.writeBytesPerSec)} +

+
+
+ )} +
+
+ ); +} + +function Tile({ label, value, sub, pct }) { + const lv = level(pct); + return ( +
+ {label} + {value}{lv === 'bad' && kritisch} + {sub && {sub}} + {pct != null && } +
+ ); +} + +export function Meter({ label, pct, detail, title, compact }) { + const lv = level(pct); + return ( +
+
+ {label} + {fmtPct(pct)}{lv === 'bad' && !compact ? ' · kritisch' : ''} +
+ + {detail && {detail}} +
+ ); +} diff --git a/frontend/src/pages/Monitoring.jsx b/frontend/src/pages/Monitoring.jsx index 0f1886f..43225b9 100644 --- a/frontend/src/pages/Monitoring.jsx +++ b/frontend/src/pages/Monitoring.jsx @@ -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 ( -
+

{m.name}

{state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'}
-

{m.target}

-
-
Typ
{TYPES[m.type]?.label}
-
Antwortzeit
{s?.ms != null ? `${s.ms} ms` : '—'}
-
Ergebnis
{s?.detail || '—'}
-
+

{isAgent && s?.metrics ? `${s.metrics.host.hostname} · ${s.metrics.host.os}` : m.target}

+ {isAgent && s?.metrics ? ( + + ) : ( +
+
Typ
{TYPES[m.type]?.label}
+
Antwortzeit
{s?.ms != null ? `${s.ms} ms` : '—'}
+
Ergebnis
{s?.detail || '—'}
+
+ )}
- {s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''} + {s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}{isAgent && ' · Klicken für Details'} @@ -132,13 +153,37 @@ export default function Monitoring() { )} + {detail && ( + setDetail(null)} + /> + )} ); } +function AgentSummary({ metrics: x }) { + const disk = fullestDisk(x); + return ( +
+ + + {disk && } +
+
Load
{fmtNum(x.cpu.load['1m'])}
+
Temperatur
{fmtTemp(maxTemp(x))}
+
Läuft seit
{fmtUptime(x.host.uptimeSeconds)}
+
+
+ ); +} + 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 }) { + {form.type === 'agent' && ( + + )}

{type.hint}

{error &&

{error}

}
diff --git a/frontend/src/serverStats.js b/frontend/src/serverStats.js new file mode 100644 index 0000000..314a9c1 --- /dev/null +++ b/frontend/src/serverStats.js @@ -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; +}; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8b18b93..d3918ac 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/themes.css b/frontend/src/themes.css index 0972378..a9f5631 100644 --- a/frontend/src/themes.css +++ b/frontend/src/themes.css @@ -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);