diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js
index 3f08c14..c9f421b 100644
--- a/frontend/src/locales/de.js
+++ b/frontend/src/locales/de.js
@@ -124,6 +124,9 @@ export default {
'server.players24h': 'Spielerzahl · letzte 24h',
'server.peak': 'Peak',
'server.uptime': 'Uptime',
+ 'server.days7': 'vor 7 Tagen',
+ 'server.now': 'jetzt',
+ 'server.noData': 'keine Messung',
'server.connect': 'Connect',
'server.copyAddress': 'Adresse kopieren',
'server.copied': 'Kopiert!',
diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js
index 5405edb..d014071 100644
--- a/frontend/src/locales/en.js
+++ b/frontend/src/locales/en.js
@@ -123,6 +123,9 @@ export default {
'server.players24h': 'Player count · last 24h',
'server.peak': 'Peak',
'server.uptime': 'Uptime',
+ 'server.days7': '7 days ago',
+ 'server.now': 'now',
+ 'server.noData': 'no reading',
'server.connect': 'Connect',
'server.copyAddress': 'Copy address',
'server.copied': 'Copied!',
diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx
index ea86aa4..7340d5a 100644
--- a/frontend/src/pages/Server.jsx
+++ b/frontend/src/pages/Server.jsx
@@ -38,14 +38,73 @@ function HistoryChart({ history, max }) {
);
}
-/** Uptime (%) und Spieler-Peak aus den 24h-Samples */
+/**
+ * Erreichbarkeits-Balken: ein Strich pro Stunde der letzten 7 Tage.
+ *
+ * Der Server liefert nur Stunden, in denen wirklich gemessen wurde. Fehlende
+ * werden hier als Lücke gezeichnet statt als „online" — sonst sähe eine
+ * Bot-Auszeit aus wie ein einwandfrei laufender Server.
+ */
+function UptimeTrack({ buckets }) {
+ 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 striche = [];
+ let gemessen = 0;
+ let erreichbar = 0;
+ for (let i = 167; 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);
+ let art = 'leer';
+ if (b) {
+ gemessen += b.proben;
+ erreichbar += b.an;
+ art = b.an === b.proben ? 'gut' : b.an === 0 ? 'aus' : 'teil';
+ }
+ striche.push({ key, zeit: d, art, b });
+ }
+
+ 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' });
+ return s.b
+ ? `${wann} — ${Math.round((s.b.an / s.b.proben) * 100)} %`
+ : `${wann} — ${t('server.noData')}`;
+ };
+
+ return (
+
+
+ {striche.map((s) => (
+
+ ))}
+
+
+ {t('server.days7')}
+ {quote != null && (
+ = 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
+ {quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
+
+ )}
+ {t('server.now')}
+
+
+ );
+}
+
+/** Spieler-Peak aus den 24h-Samples (Uptime kommt aus dem Balken oben) */
function historyStats(history) {
if (history.length === 0) return null;
- const online = history.filter((h) => h.online).length;
- return {
- uptime: Math.round((online / history.length) * 100),
- peak: Math.max(0, ...history.map((h) => h.players ?? 0)),
- };
+ return { peak: Math.max(0, ...history.map((h) => h.players ?? 0)) };
}
function ServerCard({ s }) {
@@ -99,15 +158,14 @@ function ServerCard({ s }) {
{stats && (
{t('server.players24h')}
-
- {t('server.peak')} {stats.peak} · {t('server.uptime')}{' '}
- = 99 ? 'up-good' : stats.uptime >= 90 ? '' : 'up-bad'}>
- {stats.uptime}%
-
-
+ {/* Uptime steht jetzt am Balken darunter — zweimal dieselbe
+ Zahl in verschiedenen Zeiträumen verwirrt nur. */}
+ {t('server.peak')} {stats.peak}
)}
+
+
{(s.connect_url || s.address) && (
{s.connect_url && (
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 6875c94..3f85c9a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1736,6 +1736,40 @@ label.toggle {
.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
/* ── AUDIT-LOG ─────────────────────────────────────── */
+/* ── ERREICHBARKEITS-BALKEN ────────────────────────── */
+/* Ein Strich pro Stunde, 168 Stück. Auf schmalen Schirmen werden die Striche
+ dünner als ein Pixel — deshalb scrollt der Balken dort lieber, statt zu
+ Matsch zu verschmelzen. */
+/* Ohne das sprengt der Balken auf schmalen Schirmen die ganze Karte: Grid-Items
+ haben min-width:auto und schrumpfen nicht unter ihren Inhalt — 168 Striche à
+ 3 px sind rund 840 px, und die Karte wuchs auf 724 statt 335 px. */
+.srv-card { min-width: 0; }
+.up-track-wrap { margin-top: .9rem; min-width: 0; }
+.up-track {
+ display: flex; gap: 1px; align-items: stretch;
+ height: 30px; overflow-x: auto; overflow-y: hidden;
+ scrollbar-width: none;
+}
+.up-track::-webkit-scrollbar { display: none; }
+.up-tick {
+ flex: 1 0 3px; min-width: 3px; border-radius: 1px;
+ background: var(--success, #3ba55d);
+ transition: transform .1s ease;
+}
+.up-tick:hover { transform: scaleY(1.15); }
+.up-tick.teil { background: var(--neon); }
+.up-tick.aus { background: #f23f43; }
+/* Keine Messung ist kein „läuft" — deshalb sichtbar leer statt grün */
+.up-tick.leer { background: rgba(255, 255, 255, .07); }
+
+.up-track-foot {
+ display: flex; justify-content: space-between; align-items: baseline;
+ font-family: var(--mono); font-size: .62rem; letter-spacing: .08em;
+ color: var(--muted2); margin-top: .45rem;
+}
+.up-track-foot .up-good { color: var(--success, #3ba55d); }
+.up-track-foot .up-bad { color: #f23f43; }
+
/* ── SPRACHUMSCHALTER ──────────────────────────────── */
.lang-switch {
display: inline-flex;
diff --git a/src/db.js b/src/db.js
index 505703a..a97e578 100644
--- a/src/db.js
+++ b/src/db.js
@@ -695,9 +695,21 @@ const playerHistoryStmt = db.prepare(`
WHERE server_id = ? AND ts >= datetime('now', ?) ORDER BY ts
`);
const prunePlayerHistoryStmt = db.prepare(`DELETE FROM player_history WHERE ts < datetime('now', '-7 days')`);
+// Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken.
+// Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server;
+// als Stundeneimer sind es höchstens 168 und die Seite bleibt schlank.
+const uptimeBucketsStmt = db.prepare(`
+ SELECT strftime('%Y-%m-%dT%H', ts) AS stunde,
+ COUNT(*) AS proben,
+ SUM(online) AS an
+ FROM player_history
+ WHERE server_id = ? AND ts >= datetime('now', ?)
+ GROUP BY stunde ORDER BY stunde
+`);
export const recordPlayerSample = (serverId, online, players) =>
insertPlayerSample.run(serverId, online ? 1 : 0, players);
export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(serverId, `-${hours} hours`);
+export const uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.all(serverId, `-${hours} hours`);
export const prunePlayerHistory = () => prunePlayerHistoryStmt.run();
// Moderation: Verwarnungen + Sticky-Roles
diff --git a/src/web/api.js b/src/web/api.js
index aa32403..fd02b14 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -15,7 +15,7 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
- logAudit, listAudit, playerHistory, deleteUserData,
+ logAudit, listAudit, playerHistory, uptimeBuckets, deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
@@ -182,6 +182,9 @@ export function registerApiRoutes(app, client) {
ping: r?.ping ?? null,
checkedAt: r?.checkedAt ?? null,
history: playerHistory(s.id, 24),
+ // Erreichbarkeit der letzten 7 Tage, stundenweise. Länger geht
+ // nicht: prunePlayerHistory räumt alles Ältere weg.
+ uptime: uptimeBuckets(s.id, 168),
};
}),
}));