Oeffentliche Statusseite nach Statuspage-Vorbild
Neue Seite /status auf dem Hub: Gesamtlage als Banner, darunter die
freigegebenen Dienste mit 90-Tage-Balken, die Game-Server, und die
Stoerungs-Historie der letzten 90 Tage.
Die Adressliste ist von einer kommagetrennten Einstellung in eine Tabelle
gewandert. Jeder Dienst hat jetzt Namen, Gruppe und einen Schalter
"oeffentlich" — sonst stuende auf einer oeffentlichen Seite die nackte URL,
auch von internen Diensten. Die alte Liste wird beim ersten Start uebernommen,
bewusst als nicht oeffentlich: was dort auftaucht, soll eine Entscheidung sein
und nicht durch eine Migration passieren.
/api/status liefert nur freigegebene Dienste und dabei keine Adressen, nur
Namen. Auch die Stoerungs-Historie ist gefiltert — sonst verriete sie, was es
sonst noch gibt. Im Browser gegengeprueft: auf der ganzen Seite steht kein
einziges https://.
Stoerungen werden jetzt aufgezeichnet, nicht nur gemeldet: pro Adresse ein
offener Eintrag, bis es wieder laeuft. Beim Wiederkommen wird er geschlossen,
bevor die DM rausgeht — sonst bliebe er bei einem Sendefehler ewig offen.
Aufbewahrung von 7 auf 90 Tage. Der Balken kann jetzt zwei Aufloesungen:
168 Stunden fuers Panel ("was war letzte Nacht?") und 90 Tage fuer die
Statusseite ("wie zuverlaessig?"). Stuendlich ueber 90 Tage waeren ueber 2000
Striche und unlesbar.
Im Panel sind aus der reinen Anzeige Verwaltung geworden: anlegen, bearbeiten,
entfernen, freigeben — dazu die Stoerungsliste.
Beim Bauen gefunden: die Uebernahme der alten Liste stand mitten in db.js und
rief getSetting(), dessen prepared statement erst 300 Zeilen weiter unten
entsteht. Der Bot starb beim Start mit "Cannot access getSettingStmt before
initialization" — nachgewiesen, dann ans Dateiende verschoben.
Geprueft: 182 SQL-Abfragen gegen das Schema, alle schreibenden Routen bewacht,
Stoerungs-Logik durchgespielt (doppeltes Eroeffnen wird abgelehnt, Schliessen
ohne offene gibt null), und beide Seiten im Browser mit drei Diensten in allen
drei Zustaenden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import Commits from './pages/Commits.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import Profil from './pages/Profil.jsx';
|
||||
import Server from './pages/Server.jsx';
|
||||
import Status from './pages/Status.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||
import Impressum from './pages/Impressum.jsx';
|
||||
@@ -22,6 +23,7 @@ import { useT, LangSwitch } from './i18n.jsx';
|
||||
// Beschriftung erst beim Rendern uebersetzen — der Pfad bleibt deutsch,
|
||||
// damit geteilte Links weiter funktionieren.
|
||||
const COMMUNITY_LINKS = [
|
||||
['/status', 'nav.status'],
|
||||
['/galerie', 'nav.gallery'],
|
||||
['/level', 'nav.level'],
|
||||
['/events', 'nav.events'],
|
||||
@@ -216,6 +218,7 @@ export default function App() {
|
||||
<Route path="/devlogs/:id" element={<DevlogDetail />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/server" element={<Server />} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
<Route path="/level" element={<Level />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
// Erreichbarkeits-Balken: ein Strich pro Stunde über 7 Tage.
|
||||
// Erreichbarkeits-Balken: ein Strich je Zeitabschnitt.
|
||||
//
|
||||
// Wird von der öffentlichen Server-Seite und der Watchdog-Übersicht im Panel
|
||||
// benutzt — beide bekommen dieselben Stundeneimer vom Server geliefert
|
||||
// ({ stunde, proben, an }), deshalb liegt das hier und nicht in einer Seite.
|
||||
// Zwei Auflösungen, weil zwei Seiten unterschiedliche Fragen stellen:
|
||||
// stunden — 168 Striche über 7 Tage, fürs Panel („was war letzte Nacht?")
|
||||
// tage — 90 Striche über 90 Tage, für die Statusseite („wie zuverlässig?")
|
||||
//
|
||||
// Stündlich über 90 Tage wären über 2000 Striche und damit unlesbar; Statuspage
|
||||
// löst es genauso. Der Server liefert die passenden Eimer, hier wird nur
|
||||
// gezeichnet und die Lücken ergänzt.
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const STUNDEN = 168;
|
||||
const MODI = {
|
||||
stunden: { anzahl: 168, ms: 3600000, schluessel: (d) => d.toISOString().slice(0, 13), feld: 'stunde' },
|
||||
tage: { anzahl: 90, ms: 86400000, schluessel: (d) => d.toISOString().slice(0, 10), feld: 'tag' },
|
||||
};
|
||||
|
||||
export default function UptimeTrack({ buckets }) {
|
||||
export default function UptimeTrack({ buckets, modus = 'stunden' }) {
|
||||
const { t } = useT();
|
||||
const { tag, datum } = useFmt();
|
||||
if (!buckets?.length) return null;
|
||||
|
||||
const proStunde = new Map(buckets.map((b) => [b.stunde, b]));
|
||||
const jetzt = new Date();
|
||||
jetzt.setMinutes(0, 0, 0);
|
||||
const m = MODI[modus] ?? MODI.stunden;
|
||||
const proAbschnitt = new Map(buckets.map((b) => [b[m.feld], b]));
|
||||
|
||||
const start = new Date();
|
||||
if (modus === 'tage') start.setHours(0, 0, 0, 0);
|
||||
else start.setMinutes(0, 0, 0);
|
||||
|
||||
const striche = [];
|
||||
let gemessen = 0;
|
||||
let erreichbar = 0;
|
||||
for (let i = STUNDEN - 1; i >= 0; i--) {
|
||||
const d = new Date(jetzt.getTime() - i * 3600000);
|
||||
// Schlüssel wie strftime('%Y-%m-%dT%H') in SQLite — das rechnet in UTC,
|
||||
// hier also bewusst auch. Angezeigt wird gleich die Ortszeit, sonst
|
||||
// stünde im Tooltip eine Stunde, die niemand wiedererkennt.
|
||||
const key = d.toISOString().slice(0, 13);
|
||||
const b = proStunde.get(key);
|
||||
for (let i = m.anzahl - 1; i >= 0; i--) {
|
||||
const d = new Date(start.getTime() - i * m.ms);
|
||||
// Schlüssel wie strftime in SQLite — das rechnet in UTC, hier also
|
||||
// bewusst auch. Angezeigt wird gleich die Ortszeit, sonst stünde im
|
||||
// Tooltip eine Stunde, die niemand wiedererkennt.
|
||||
const key = m.schluessel(d);
|
||||
const b = proAbschnitt.get(key);
|
||||
let art = 'leer';
|
||||
if (b) {
|
||||
gemessen += b.proben;
|
||||
@@ -37,7 +47,9 @@ export default function UptimeTrack({ buckets }) {
|
||||
|
||||
const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null;
|
||||
const titel = (s) => {
|
||||
const wann = datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
const wann = modus === 'tage'
|
||||
? datum(s.zeit, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: datum(s.zeit, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
if (!s.b) return `${wann} — ${t('server.noData')}`;
|
||||
const anteil = Math.round((s.b.an / s.b.proben) * 100);
|
||||
// Antwortzeit nur, wo sie erhoben wird (Watchdog) und etwas lief
|
||||
@@ -54,7 +66,7 @@ export default function UptimeTrack({ buckets }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="up-track-foot">
|
||||
<span>{t('server.days7')}</span>
|
||||
<span>{modus === 'tage' ? t('status.days90') : t('server.days7')}</span>
|
||||
{quote != null && (
|
||||
<span className={quote >= 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
|
||||
{quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
|
||||
|
||||
@@ -62,6 +62,24 @@ export default {
|
||||
'home.tile.level': 'XP-Bestenliste — wer redet am meisten mit?',
|
||||
'home.tile.events': 'Playtests, Streams und was sonst ansteht',
|
||||
|
||||
'nav.status': 'Status',
|
||||
'status.tag': '// Betriebslage',
|
||||
'status.subtitle': 'Erreichbarkeit der Dienste und Game-Server — alle 60 Sekunden aktualisiert.',
|
||||
'status.error': 'Der Status konnte nicht geladen werden.',
|
||||
'status.allOk': 'Alle Systeme betriebsbereit',
|
||||
'status.partial': 'Teilweise gestört',
|
||||
'status.majorOutage': 'Größere Störung',
|
||||
'status.waiting': 'Wird geprüft',
|
||||
'status.nothing': 'Nichts eingerichtet',
|
||||
'status.services': '// Dienste',
|
||||
'status.gameservers': '// Game-Server',
|
||||
'status.incidents': '// Störungen der letzten 90 Tage',
|
||||
'status.noIncidents': 'Keine Störungen aufgezeichnet.',
|
||||
'status.ok': 'erreichbar',
|
||||
'status.down': 'nicht erreichbar',
|
||||
'status.ongoing': 'läuft noch',
|
||||
'status.minutes': '%s min',
|
||||
'status.days90': 'vor 90 Tagen',
|
||||
// ── Devlogs ───────────────────────────────────────
|
||||
'devlogs.tag': '// Archiv',
|
||||
'devlogs.subtitle': 'Entwicklungs-Updates, frisch aus dem Editor — automatisch archiviert.',
|
||||
|
||||
@@ -61,6 +61,24 @@ export default {
|
||||
'home.tile.level': 'XP leaderboard — who talks the most?',
|
||||
'home.tile.events': 'Playtests, streams and whatever else is coming up',
|
||||
|
||||
'nav.status': 'Status',
|
||||
'status.tag': '// Operational status',
|
||||
'status.subtitle': 'Reachability of services and game servers — refreshed every 60 seconds.',
|
||||
'status.error': 'The status could not be loaded.',
|
||||
'status.allOk': 'All systems operational',
|
||||
'status.partial': 'Partially degraded',
|
||||
'status.majorOutage': 'Major outage',
|
||||
'status.waiting': 'Checking',
|
||||
'status.nothing': 'Nothing set up',
|
||||
'status.services': '// Services',
|
||||
'status.gameservers': '// Game servers',
|
||||
'status.incidents': '// Incidents in the last 90 days',
|
||||
'status.noIncidents': 'No incidents recorded.',
|
||||
'status.ok': 'operational',
|
||||
'status.down': 'down',
|
||||
'status.ongoing': 'ongoing',
|
||||
'status.minutes': '%s min',
|
||||
'status.days90': '90 days ago',
|
||||
// ── Devlogs ───────────────────────────────────────
|
||||
'devlogs.tag': '// Archive',
|
||||
'devlogs.subtitle': 'Development updates, fresh from the editor — archived automatically.',
|
||||
|
||||
@@ -343,6 +343,7 @@ export default function Settings({ me }) {
|
||||
const [audit, setAudit] = useState([]);
|
||||
const [serverStats, setServerStats] = useState(null);
|
||||
const [watchdog, setWatchdog] = useState(null);
|
||||
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||
const [owner, setOwner] = useState(null);
|
||||
const [auditAlle, setAuditAlle] = useState(false);
|
||||
// Module (An/Aus für alle Funktionen)
|
||||
@@ -2353,16 +2354,76 @@ export default function Settings({ me }) {
|
||||
<div className="wd-card card" key={a.url}>
|
||||
<div className="wd-head">
|
||||
<span className={`wd-dot ${a.online === null ? 'wartet' : a.online ? 'an' : 'aus'}`} />
|
||||
<a className="wd-url" href={a.url} target="_blank" rel="noreferrer">{a.url}</a>
|
||||
<span className="st-name">{a.name}</span>
|
||||
{a.oeffentlich && <span className="tm-chip alle">öffentlich</span>}
|
||||
<span className="wd-meta">
|
||||
{a.online === null && 'noch nicht geprüft'}
|
||||
{a.online === true && `${a.status ?? 'OK'}${a.ms != null ? ` · ${a.ms} ms` : ''}`}
|
||||
{a.online === false && `seit ${vorWieLange(new Date(a.seit).toISOString())} weg`}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-mini"
|
||||
onClick={() => setDienstDraft({
|
||||
id: a.id, name: a.name, url: a.url,
|
||||
gruppe: a.gruppe, oeffentlich: a.oeffentlich,
|
||||
})}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="card-delete" title="Nicht mehr überwachen" onClick={() => dienstLoeschen(a.id, a.name)}>✕</button>
|
||||
</div>
|
||||
<a className="wd-url" href={a.url} target="_blank" rel="noreferrer">{a.url}</a>
|
||||
<UptimeTrack buckets={a.uptime} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>
|
||||
{dienstDraft.id ? 'Dienst bearbeiten' : 'Dienst hinzufügen'}
|
||||
</h3>
|
||||
<div className="field">
|
||||
<label>Name <span className="field-hint">steht so auf der öffentlichen Statusseite</span></label>
|
||||
<input type="text" maxLength={60} placeholder="z. B. Gitea" value={dienstDraft.name}
|
||||
onChange={(e) => setDienstDraft({ ...dienstDraft, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Adresse <span className="field-hint">wird geprüft; erscheint nie öffentlich</span></label>
|
||||
<input type="text" placeholder="https://git.d4rkst3r.de" value={dienstDraft.url}
|
||||
onChange={(e) => setDienstDraft({ ...dienstDraft, url: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={dienstDraft.oeffentlich}
|
||||
onChange={(e) => setDienstDraft({ ...dienstDraft, oeffentlich: e.target.checked })} />
|
||||
Auf der öffentlichen Statusseite zeigen
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-save" onClick={dienstSpeichern}>
|
||||
{dienstDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
|
||||
</button>
|
||||
{dienstDraft.id && (
|
||||
<button className="btn btn-ghost" onClick={() => setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false })}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watchdog?.stoerungen?.length > 0 && (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Störungen</h2>
|
||||
<div className="st-incidents">
|
||||
{watchdog.stoerungen.slice(0, 20).map((v) => (
|
||||
<div className={`st-incident${v.beendet ? '' : ' offen'}`} key={v.id}>
|
||||
<span className="st-inc-name">
|
||||
{watchdog.adressen.find((a) => a.url === v.url)?.name ?? v.url}
|
||||
</span>
|
||||
<span className="st-inc-time">{vorWieLange(v.begonnen)}</span>
|
||||
<span className="st-inc-dur">{v.beendet ? `${v.minuten} min` : 'läuft noch'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -2974,6 +3035,26 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
async function dienstSpeichern() {
|
||||
const body = { ...dienstDraft };
|
||||
try {
|
||||
if (body.id) await apiPut(`/api/monitored/${body.id}`, body);
|
||||
else await apiPost('/api/monitored', body);
|
||||
setDienstDraft({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||
setFeedback('Gespeichert.');
|
||||
} catch (e) {
|
||||
setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.');
|
||||
}
|
||||
setTimeout(() => setFeedback(null), 3000);
|
||||
}
|
||||
|
||||
async function dienstLoeschen(id, name) {
|
||||
if (!window.confirm(`\u201e${name}" nicht mehr überwachen?`)) return;
|
||||
await apiDelete(`/api/monitored/${id}`).catch(() => {});
|
||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||
}
|
||||
|
||||
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
|
||||
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Öffentliche Statusseite im Statuspage-Stil: Gesamtlage oben, darunter die
|
||||
// freigegebenen Dienste und die Game-Server, unten die Störungs-Historie.
|
||||
//
|
||||
// Adressen stehen hier bewusst nicht — der Server liefert nur Namen. Was auf
|
||||
// dieser Seite auftaucht, entscheidet der Schalter „öffentlich" je Dienst.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiGet } from '../api.js';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
import { IconCheck, IconWarning, IconClock } from '../icons.jsx';
|
||||
|
||||
/** Gesamtlage aus allen Teilen — die schlechteste zählt */
|
||||
function gesamtlage(daten) {
|
||||
const alles = [
|
||||
...(daten.dienste ?? []).map((d) => d.online),
|
||||
...(daten.server ?? []).map((s) => s.online),
|
||||
];
|
||||
if (alles.length === 0) return 'leer';
|
||||
if (alles.some((o) => o === false)) return alles.every((o) => o === false) ? 'aus' : 'teil';
|
||||
// null heißt „noch nicht geprüft" und ist keine Störung
|
||||
return alles.some((o) => o === true) ? 'gut' : 'wartet';
|
||||
}
|
||||
|
||||
export default function Status() {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [daten, setDaten] = useState(null);
|
||||
const [fehler, setFehler] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const laden = () => apiGet('/api/status').then(setDaten).catch(() => setFehler(true));
|
||||
laden();
|
||||
const timer = setInterval(laden, 60_000);
|
||||
window.scrollTo(0, 0);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const lage = daten ? gesamtlage(daten) : null;
|
||||
const BANNER = {
|
||||
gut: { icon: IconCheck, text: t('status.allOk') },
|
||||
teil: { icon: IconWarning, text: t('status.partial') },
|
||||
aus: { icon: IconWarning, text: t('status.majorOutage') },
|
||||
wartet: { icon: IconClock, text: t('status.waiting') },
|
||||
leer: { icon: IconClock, text: t('status.nothing') },
|
||||
};
|
||||
const Banner = lage ? BANNER[lage].icon : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div className="page-bg-text">STATUS</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">{t('status.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.status')}</h1>
|
||||
<p className="page-subtitle">{t('status.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{fehler && <p className="notice">{t('status.error')}</p>}
|
||||
{!fehler && !daten && <SkeletonCards n={2} />}
|
||||
|
||||
{lage && (
|
||||
<div className={`st-banner ${lage}`}>
|
||||
<Banner size={20} />
|
||||
<span>{BANNER[lage].text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{daten?.dienste?.length > 0 && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">{t('status.services')}</h2>
|
||||
{daten.dienste.map((d) => (
|
||||
<div className="st-row card" key={d.id}>
|
||||
<div className="st-head">
|
||||
<span className={`wd-dot ${d.online === null ? 'wartet' : d.online ? 'an' : 'aus'}`} />
|
||||
<span className="st-name">{d.name}</span>
|
||||
<span className="st-state">
|
||||
{d.online === null ? t('status.waiting') : d.online ? t('status.ok') : t('status.down')}
|
||||
</span>
|
||||
</div>
|
||||
<UptimeTrack buckets={d.uptime} modus="tage" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{daten?.server?.length > 0 && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">{t('status.gameservers')}</h2>
|
||||
<div className="st-servers">
|
||||
{daten.server.map((s) => (
|
||||
<div className="st-server card" key={s.id}>
|
||||
<span className="st-server-icon">{s.icon}</span>
|
||||
<span className="st-name">{s.name}</span>
|
||||
<span className={`wd-dot ${s.online === null ? 'wartet' : s.online ? 'an' : 'aus'}`} />
|
||||
<span className="st-state">
|
||||
{s.online
|
||||
? `${s.players ?? 0}${s.max ? ` / ${s.max}` : ''}`
|
||||
: s.online === null ? t('status.waiting') : t('status.down')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{daten && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">{t('status.incidents')}</h2>
|
||||
{daten.stoerungen.length === 0 ? (
|
||||
<p className="notice">{t('status.noIncidents')}</p>
|
||||
) : (
|
||||
<div className="st-incidents">
|
||||
{daten.stoerungen.map((v) => (
|
||||
<div className={`st-incident${v.beendet ? '' : ' offen'}`} key={v.id}>
|
||||
<span className="st-inc-name">{v.name}</span>
|
||||
<span className="st-inc-time">
|
||||
{datum(`${v.begonnen.replace(' ', 'T')}Z`, {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span className="st-inc-dur">
|
||||
{v.beendet ? t('status.minutes', v.minuten) : t('status.ongoing')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1815,6 +1815,46 @@ label.toggle {
|
||||
.dash-group-name { flex-basis: 7rem; }
|
||||
}
|
||||
|
||||
/* ── ÖFFENTLICHE STATUSSEITE ───────────────────────── */
|
||||
.st-banner {
|
||||
display: flex; align-items: center; gap: .8rem;
|
||||
padding: 1rem 1.2rem; border-radius: 12px; margin-bottom: 2rem;
|
||||
font-family: var(--mono); font-size: .8rem; letter-spacing: .1em;
|
||||
text-transform: uppercase; border: 1px solid;
|
||||
}
|
||||
.st-banner.gut { border-color: rgba(59, 165, 93, .4); background: rgba(59, 165, 93, .07); color: #3ba55d; }
|
||||
.st-banner.teil { border-color: color-mix(in srgb, var(--neon) 40%, transparent); background: color-mix(in srgb, var(--neon) 7%, transparent); color: var(--neon); }
|
||||
.st-banner.aus { border-color: rgba(242, 63, 67, .45); background: rgba(242, 63, 67, .08); color: #f23f43; }
|
||||
.st-banner.wartet, .st-banner.leer { border-color: var(--border); color: var(--muted); }
|
||||
|
||||
.st-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
|
||||
.st-head { display: flex; align-items: center; gap: .6rem; }
|
||||
.st-name { font-weight: 600; font-size: .92rem; }
|
||||
.st-state {
|
||||
margin-left: auto; font-family: var(--mono);
|
||||
font-size: .62rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2);
|
||||
}
|
||||
|
||||
.st-servers { display: grid; gap: .6rem; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); }
|
||||
.st-server { display: flex; align-items: center; gap: .6rem; padding: .75rem .9rem; min-width: 0; }
|
||||
.st-server-icon { font-size: 1.1rem; }
|
||||
|
||||
.st-incidents { display: flex; flex-direction: column; }
|
||||
.st-incident {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: .6rem .2rem; font-size: .84rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||
}
|
||||
.st-incident:last-child { border-bottom: none; }
|
||||
.st-incident.offen .st-inc-dur { color: #f23f43; }
|
||||
.st-inc-name { font-weight: 600; min-width: 9rem; }
|
||||
.st-inc-time { font-family: var(--mono); font-size: .66rem; color: var(--muted2); }
|
||||
.st-inc-dur { margin-left: auto; font-family: var(--mono); font-size: .68rem; color: var(--muted); }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.st-inc-name { min-width: 0; }
|
||||
}
|
||||
|
||||
/* ── WATCHDOG-ÜBERSICHT ────────────────────────────── */
|
||||
.wd-card { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
|
||||
.wd-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user