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

+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">