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