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

+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>
);
}