Implement async server status badge updates with a new JSON endpoint and improved caching

This commit is contained in:
Fabian Schieder 2026-03-01 14:52:49 +01:00
parent bcea63dcb8
commit 044546ad00
3 changed files with 76 additions and 91 deletions

56
api/server_status.php Normal file
View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
// JSON endpoint for async status badge updates.
// Important: output must be clean UTF-8 JSON.
require_once __DIR__ . '/../includes/lib/server_status.php';
$serverStatusTargets = require __DIR__ . '/../includes/config/server_status_targets.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
$cacheTtlSeconds = 30;
$cacheKey = 'server_status_api_v1';
$cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json';
$noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';
$serverStatus = null;
if (!$noCache && is_file($cacheFile)) {
$raw = @file_get_contents($cacheFile);
if ($raw !== false) {
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['ts'], $decoded['data']) && is_array($decoded['data'])) {
$age = time() - (int)$decoded['ts'];
if ($age >= 0 && $age <= $cacheTtlSeconds) {
$serverStatus = $decoded['data'];
}
}
}
}
if (!is_array($serverStatus)) {
$serverStatus = build_server_status($serverStatusTargets);
@file_put_contents($cacheFile, json_encode(['ts' => time(), 'data' => $serverStatus], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
$byUrlNormalized = [];
foreach ($serverStatus as $svc) {
if (!empty($svc['url']) && is_string($svc['url'])) {
$byUrlNormalized[rtrim($svc['url'], '/')] = $svc;
}
}
$payload = json_encode([
'ts' => time(),
'byUrlNormalized' => $byUrlNormalized,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($payload === false) {
http_response_code(500);
$payload = '{"error":"json_encode_failed"}';
}
echo $payload;

View File

@ -94,41 +94,37 @@ document.querySelectorAll('.card').forEach(card => {
});
});
// ── Server Status Badges (async) ───────────────────────────
// ── Server Status Badges (async, no reload) ───────────────
(async function () {
const cards = Array.from(document.querySelectorAll('.card[data-status-url]'));
if (cards.length === 0) return;
const endpoint = './server_status.php';
const endpoint = '/api/server_status.php';
const normalize = (url) => {
if (!url) return '';
return String(url).replace(/\/+$/, '');
};
const parsePossiblyUtf16Json = (text) => {
if (!text) return null;
const cleaned = String(text)
.replace(/^?\uFEFF/, '')
const safeJsonParse = (text) => {
const cleaned = String(text || '')
.replace(/^\uFEFF/, '')
.replace(/\u0000/g, '')
.trim();
if (!cleaned) return null;
return JSON.parse(cleaned);
};
const applyPayload = (payload) => {
const byUrl = payload && payload.byUrl ? payload.byUrl : {};
const byUrlNormalized = payload && payload.byUrlNormalized ? payload.byUrlNormalized : {};
let applied = 0;
const apply = (byUrl) => {
for (const card of cards) {
const key = normalize(card.getAttribute('data-status-url'));
const svc = byUrl[key] || byUrl[key + '/'] || byUrlNormalized[key] || null;
const svc = byUrl[key] || byUrl[key + '/'] || null;
if (!svc) continue;
const state = svc.state || 'unknown';
const badge = card.querySelector('[data-status-badge="1"]');
if (!badge) continue;
const state = svc.state || 'unknown';
badge.classList.remove('status-badge--unknown', 'status-badge--up', 'status-badge--down');
badge.classList.add('status-badge--' + state);
@ -142,37 +138,22 @@ document.querySelectorAll('.card').forEach(card => {
badge.textContent = 'Unbekannt';
badge.title = svc.detail || 'Unbekannt';
}
applied++;
}
console.debug('[server-status] applied badges:', applied);
};
const fetchAndApply = async () => {
// Exactly ~1s delay, then fetch.
await new Promise(r => setTimeout(r, 1000));
try {
const res = await fetch(endpoint, { cache: 'no-store' });
if (!res.ok) throw new Error('status_fetch_failed_' + res.status);
if (!res.ok) return;
const text = await res.text();
let payload;
try {
payload = parsePossiblyUtf16Json(text);
} catch (e) {
console.warn('[server-status] JSON parse failed, first 120 chars:', text.slice(0, 120));
throw e;
}
if (!payload) throw new Error('status_json_empty');
const payload = safeJsonParse(text);
if (!payload || !payload.byUrlNormalized) return;
applyPayload(payload);
};
const delays = [1000, 1000, 1500];
for (const d of delays) {
await new Promise(r => setTimeout(r, d));
try {
await fetchAndApply();
break;
} catch (e) {
console.warn('[server-status] update failed:', e);
}
apply(payload.byUrlNormalized);
} catch (e) {
// keep "Unbekannt"
}
})();

View File

@ -15,65 +15,13 @@ require_once __DIR__ . '/includes/lib/server_status.php';
require_once __DIR__ . '/includes/lib/view_helpers.php';
// ── Serverstatus ─────────────────────────────────────────────────────────
// Performance: Beim ersten Rendern sofort antworten (=> Unbekannt).
// Nach ~1s lädt die Seite einmal neu mit ?status=1 und rendert dann serverseitig Online/Offline.
$shouldComputeStatus = isset($_GET['status']) && $_GET['status'] === '1';
// Performance: initial sofort rendern (=> Unbekannt). Badges werden clientseitig nachgeladen.
$serverStatusByUrl = [];
if ($shouldComputeStatus) {
$cacheTtlSeconds = 30;
$cacheKey = 'server_status_v5';
$cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json';
$serverStatus = null;
if (is_file($cacheFile)) {
$raw = @file_get_contents($cacheFile);
if ($raw !== false) {
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['ts'], $decoded['data']) && is_array($decoded['data'])) {
$age = time() - (int)$decoded['ts'];
if ($age >= 0 && $age <= $cacheTtlSeconds) {
$serverStatus = $decoded['data'];
}
}
}
}
if (!is_array($serverStatus)) {
$serverStatus = build_server_status($serverStatusTargets);
@file_put_contents($cacheFile, json_encode(['ts' => time(), 'data' => $serverStatus], JSON_UNESCAPED_SLASHES));
}
foreach ($serverStatus as $svc) {
if (!empty($svc['url']) && is_string($svc['url'])) {
$serverStatusByUrl[$svc['url']] = $svc;
}
}
}
require __DIR__ . '/includes/views/layout_head.php';
require __DIR__ . '/includes/views/header.php';
?>
<?php if (!$shouldComputeStatus): ?>
<script>
// Einmaliger Reload nach ~1 Sekunde, um dann serverseitig den Status zu laden.
setTimeout(function () {
try {
const url = new URL(window.location.href);
if (url.searchParams.get('status') === '1') return;
url.searchParams.set('status', '1');
window.location.replace(url.toString());
} catch (e) {
// Fallback
if (window.location.search.indexOf('status=1') === -1) {
window.location.replace(window.location.pathname + '?status=1');
}
}
}, 1000);
</script>
<?php endif; ?>
<main>
<?php foreach ($projects as $category => $items): ?>
<?php require __DIR__ . '/includes/views/projects_section.php'; ?>