Files
p42.f-w/frontend/src/pages/Monitoring.jsx
T
guyg1474andClaude Opus 5.5 f16c8c73c6 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>
2026-10-14 16:22:48 +02:00

253 lines
9.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
export default function Monitoring() {
const [monitors, setMonitors] = useState(null);
const [status, setStatus] = useState({});
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 () => {
setChecking(true);
try {
const results = await api('GET', '/api/monitors/status');
setStatus(Object.fromEntries(results.map((r) => [r.id, r])));
} catch (e) {
setError(e.message);
} finally {
setChecking(false);
}
}, []);
const load = useCallback(async () => {
try {
setMonitors(await api('GET', '/api/monitors'));
await check();
} catch (e) {
setError(e.message);
}
}, [check]);
useEffect(() => {
load();
const timer = setInterval(check, REFRESH_SECONDS * 1000);
return () => clearInterval(timer);
}, [load, check]);
// Erst rendern (Formular ist dann schon befüllt), dann öffnen
function openDialog(monitor) {
setEditing({ ...(monitor || {}), opened: Date.now() });
}
useEffect(() => {
if (editing) dialog.current?.showModal();
}, [editing]);
async function remove(m) {
if (!window.confirm(`„${m.name}“ aus dem Monitoring entfernen?`)) return;
try {
await api('DELETE', `/api/monitors/${m.id}`);
setMonitors((list) => list.filter((x) => x.id !== m.id));
} catch (e) {
setError(e.message);
}
}
const online = monitors ? monitors.filter((m) => status[m.id]?.ok).length : 0;
const lastCheck = Object.values(status).map((s) => s.checkedAt).sort().at(-1);
return (
<>
<div className="page-head">
<div>
<h1>Server-Monitoring</h1>
<p className="lead">
Deine Server – nur für dein Konto sichtbar. Wird alle {REFRESH_SECONDS} Sekunden geprüft.
</p>
</div>
<div className="page-actions">
{monitors?.length > 0 && (
<button className="btn ghost" type="button" onClick={check} disabled={checking}>
{checking ? 'Prüfe …' : 'Jetzt prüfen'}
</button>
)}
<button className="btn" type="button" onClick={() => openDialog()}>+ Server hinzufügen</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{!monitors && !error && <p className="hint">Lade …</p>}
{monitors?.length === 0 && (
<div className="notice">
Noch keine Server eingetragen. Über „+ Server hinzufügen“ legst du fest, welche Webseiten oder Ports geprüft werden.
</div>
)}
{monitors?.length > 0 && (
<>
<p className="monitor-summary">
<span className={`dot ${online === monitors.length ? 'ok' : online === 0 ? 'bad' : 'warn'}`} />
{online} von {monitors.length} online
{lastCheck && <span className="monitor-summary-time">Stand {fmtTime(lastCheck)}</span>}
</p>
<div className="grid">
{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} ${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">{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)}` : ''}{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>
</span>
</div>
</article>
);
})}
</div>
</>
)}
<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 || '', token: '' }));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const isNew = !monitor?.id;
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
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) {
setError(err.message);
} finally {
setBusy(false);
}
}
const type = TYPES[form.type];
return (
<dialog className="modal" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{isNew ? 'Server hinzufügen' : 'Server bearbeiten'}</h2>
<label>
Name
<input {...field('name')} maxLength={60} placeholder="z. B. Jellyfin" required />
</label>
<label>
Prüfung
<select {...field('type')}>
{Object.entries(TYPES).map(([k, t]) => <option key={k} value={k}>{t.label}</option>)}
</select>
</label>
<label>
{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">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>Speichern</button>
</div>
</form>
</dialog>
);
}