P42 Dashboard: erster Stand

FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api.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.' },
};
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 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';
return (
<article className={`card monitor-card ${state}`} key={m.id}>
<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>
<div className="meta monitor-foot">
<span>{s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}</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} />
</>
);
}
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 [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 {
if (isNew) await api('POST', '/api/monitors', form);
else await api('PUT', `/api/monitors/${monitor.id}`, form);
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 === 'http' ? 'Adresse' : 'Host und Port'}
<input {...field('target')} placeholder={type.placeholder} autoCapitalize="none" spellCheck={false} required />
</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>
);
}