- 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>
253 lines
9.7 KiB
React
253 lines
9.7 KiB
React
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>
|
||
);
|
||
}
|