// Öffentliche Server-Status-Seite: Übersichtsleiste, Karte pro Server mit Logo, // Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak. // Daten sammelt der Server-Monitor alle 2 Minuten. import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; import { SkeletonCards } from '../components/Skeleton.jsx'; import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; import UptimeTrack from '../components/UptimeTrack.jsx'; const UHRZEIT = { hour: '2-digit', minute: '2-digit' }; /** "vor 3 Std." — grob genug, dass es nicht jede Minute anders aussieht */ function vorWieLange(iso, t) { // SQLite liefert "YYYY-MM-DD HH:MM:SS" ohne Zeitzone; das ist UTC. const ts = Date.parse(iso.includes('T') ? iso : `${iso.replace(' ', 'T')}Z`); if (Number.isNaN(ts)) return null; const min = Math.max(1, Math.round((Date.now() - ts) / 60000)); if (min < 60) return t('server.agoMin', min); const std = Math.round(min / 60); if (std < 48) return t('server.agoHours', std); return t('server.agoDays', Math.round(std / 24)); } /** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie. * Der Peak bekommt eine eigene Linie — ohne die ist eine Fläche ohne Achse nur * Dekoration und man kann nicht sagen, wie hoch „hoch" eigentlich ist. */ function HistoryChart({ history, max }) { if (history.length < 2) return null; const W = 600, H = 100, PAD = 4; const spitze = Math.max(0, ...history.map((h) => (h.online ? h.players ?? 0 : 0))); const peak = Math.max(max ?? 0, 1, spitze); const x = (i) => PAD + (i / (history.length - 1)) * (W - PAD * 2); const y = (p) => H - PAD - ((p ?? 0) / peak) * (H - PAD * 2); const points = history.map((h, i) => `${x(i).toFixed(1)},${y(h.online ? h.players : 0).toFixed(1)}`); const area = `${PAD},${H - PAD} ${points.join(' ')} ${W - PAD},${H - PAD}`; const offline = history.map((h, i) => (h.online ? null : i)).filter((i) => i !== null); const spitzeY = y(spitze); return ( ); } /** Spieler-Peak aus den 24h-Samples (Uptime kommt aus dem Balken oben) */ function historyStats(history) { if (history.length === 0) return null; return { peak: Math.max(0, ...history.map((h) => h.players ?? 0)) }; } function ServerCard({ s }) { const { t } = useT(); const { datum } = useFmt(); const [copied, setCopied] = useState(false); const [alleSpieler, setAlleSpieler] = useState(false); const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0; const stats = historyStats(s.history); // War in 24 h überhaupt jemand drauf? Wenn nicht, ist das Diagramm eine // Nulllinie in hundert Pixeln Leere — das sieht nach Fehler aus, obwohl // es keiner ist. Dann lieber ein Satz statt einer leeren Fläche. const hatVerlauf = s.history.length > 1 && s.history.some((h) => (h.players ?? 0) > 0); // Ab 80 % wird es eng, ab 100 % kommt keiner mehr rein — das soll man // sehen, ohne den Balken mit der Zahl daneben zu vergleichen. const fuellung = pct >= 100 ? 'voll' : pct >= 80 ? 'eng' : 'frei'; const namen = s.spielerNamen ?? []; const gezeigt = alleSpieler ? namen : namen.slice(0, 8); async function copyAddress() { try { await navigator.clipboard.writeText(s.address); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* Browser ohne Clipboard-Rechte */ } } return (
{s.image_url ? : {s.icon}}
{s.name} {s.online === null ? <> {t('server.waiting')} : <> {s.online ? t('server.online') : t('server.offline')}} {/* Das Spiel gehört zum Server, nicht zum Zustand — es steht auch dann noch da, wenn nichts antwortet. */} {s.spiel && {s.spiel}}
{s.online && s.players != null && (
{s.players} {s.max > 0 && / {s.max}}
)}
{s.online && s.players != null && s.max > 0 && (
{pct}%
)} {/* Was der Server über sich verrät — nur, was auch da ist */} {s.online && (s.map || s.ping != null || s.version) && (
{s.map && {s.map}} {s.version && {s.version}} {/* Eigenes Zeichen — mit dem Paket-Icon der Version daneben sähen die beiden Chips wie zusammengehörig aus. */} {s.mods > 0 && {t('server.mods', s.mods)}} {s.ping != null && {s.ping} ms}
)} {/* Offline: die eine Frage, die dann zählt */} {s.online === false && (

{s.zuletztOnline ? t('server.lastOnline', vorWieLange(s.zuletztOnline, t) ?? '—') : t('server.neverOnline')}

)} {namen.length > 0 && (
{t('server.whoIsOn')}
{gezeigt.map((n) => {n})} {!alleSpieler && namen.length > gezeigt.length && ( )}
)} {hatVerlauf ? ( <>
{t('server.players24h')} {/* Uptime steht jetzt am Balken darunter — zweimal dieselbe Zahl in verschiedenen Zeiträumen verwirrt nur. */} {t('server.peak')} {stats.peak}
{t('server.hours24')} {t('server.now')}
) : s.online && (

{t('server.nobody24h')}

)} {(s.connect_url || s.mods_url || s.links?.length > 0 || s.address) && (
{s.connect_url && ( {t('server.connect')} )} {s.mods_url && ( {t('server.modsDownload')} )} {/* Frei eingetragene Links. `noreferrer` weil sie überall hinführen können — das ist ja der Sinn. */} {(s.links ?? []).map((l) => ( {l.label} ))} {s.address && ( )}
)} {s.checkedAt && (

{t('server.checkedAt', datum(s.checkedAt, UHRZEIT))}

)}
); } export default function Server() { const { t } = useT(); const [servers, setServers] = useState(null); const [error, setError] = useState(false); useEffect(() => { const load = () => apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => setError(true)); load(); const timer = setInterval(load, 60_000); window.scrollTo(0, 0); return () => clearInterval(timer); }, []); const online = servers?.filter((s) => s.online) ?? []; const totalPlayers = online.reduce((sum, s) => sum + (s.players ?? 0), 0); const totalSlots = online.reduce((sum, s) => sum + (s.max ?? 0), 0); return ( <>
SERVER
{error &&

{t('server.error')}

} {!error && !servers && } {servers?.length === 0 &&

{t('server.none')}

} {servers?.length > 0 && (
{online.length} / {servers.length} {t('server.serversOnline')}
{totalPlayers} {t('server.playersNow')}
{/* Nur wenn überhaupt jemand eine Obergrenze meldet — ein HTTP-Check hat keine Plätze. */} {totalSlots > 0 && (
{totalSlots} {t('server.slots')}
)}
)}
{servers?.map((s) => )}
); }