Refactor server status handling and add async UI updates with JSON endpoint

This commit is contained in:
Fabian Schieder 2026-03-01 14:43:00 +01:00
parent f6fd033c21
commit f3fae8ae51
6 changed files with 273 additions and 39 deletions

View File

@ -94,3 +94,47 @@ document.querySelectorAll('.card').forEach(card => {
});
});
// ── Server Status Badges (async) ───────────────────────────
(async function () {
const cards = Array.from(document.querySelectorAll('.card[data-status-url]'));
if (cards.length === 0) return;
try {
const res = await fetch('/server_status.php', { cache: 'no-store' });
if (!res.ok) return;
const payload = await res.json();
const byUrl = payload && payload.byUrl ? payload.byUrl : {};
// Normalisierung muss zur PHP-Normalisierung passen (Trailing Slash entfernen)
const normalize = (url) => {
if (!url) return '';
return String(url).replace(/\/+$/, '');
};
for (const card of cards) {
const key = normalize(card.getAttribute('data-status-url'));
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;
badge.classList.remove('status-badge--unknown', 'status-badge--up', 'status-badge--down');
badge.classList.add('status-badge--' + state);
if (state === 'up') {
badge.textContent = 'Online';
badge.title = 'Online';
} else if (state === 'down') {
badge.textContent = 'Offline';
badge.title = svc.detail || 'Offline';
} else {
badge.textContent = 'Unbekannt';
badge.title = svc.detail || 'Unbekannt';
}
}
} catch (e) {
// still fine: keep "Unbekannt"
}
})();

View File

@ -1,6 +1,13 @@
<?php
declare(strict_types=1);
/**
* Default-Timeouts: schnell genug für den Seitenaufbau.
* (Wenn ein Dienst nicht antwortet, lieber kurz "Offline" als die ganze Seite zu blockieren.)
*/
const SERVER_STATUS_TIMEOUT_SECONDS = 0.9;
const SERVER_STATUS_TIMEOUT_GET_FALLBACK_SECONDS = 1.2;
/**
* Serverstatus (dependency-free, mit Cache & SSRF-Guards)
*
@ -198,14 +205,84 @@ function check_http_request(string $url, string $method, float $timeoutSeconds =
return $res;
}
/**
* Parallel HTTP status checks using curl_multi if available.
* @param array<int,string> $urls
* @return array<string,array{code:int|null, error:string|null, ms:int|null, headers:array<int,string>}> map url => result
*/
function curl_multi_head_status(array $urls, float $timeoutSeconds): array
{
$out = [];
if (empty($urls) || !function_exists('curl_multi_init')) {
return $out;
}
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_NOBODY => true,
CURLOPT_CUSTOMREQUEST => 'HEAD',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
CURLOPT_CONNECTTIMEOUT_MS => (int)round($timeoutSeconds * 1000),
CURLOPT_TIMEOUT_MS => (int)round($timeoutSeconds * 1000),
CURLOPT_USERAGENT => 'ServerStatus/1.2',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
curl_multi_add_handle($mh, $ch);
$handles[(string)$url] = $ch;
}
$running = null;
do {
$mrc = curl_multi_exec($mh, $running);
if ($running) {
// wait for activity (avoid busy loop)
curl_multi_select($mh, 0.2);
}
} while ($running && $mrc === CURLM_OK);
foreach ($handles as $url => $ch) {
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
$ms = is_float($totalTime) ? (int)round($totalTime * 1000) : null;
$rawHeaders = curl_multi_getcontent($ch);
$headers = [];
if (is_string($rawHeaders) && $rawHeaders !== '') {
foreach (preg_split("/\r\n|\n|\r/", trim($rawHeaders)) as $line) {
if ($line !== '') $headers[] = $line;
}
}
$out[$url] = [
'code' => ($code > 0 ? (int)$code : null),
'error' => ($err !== '' ? $err : null),
'ms' => $ms,
'headers' => $headers,
];
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $out;
}
/**
* HTTP check (no redirects).
* - Tries HEAD first.
* - If result is a redirect, probes the Location once, because we don't follow redirects automatically.
* - If HEAD fails, tries a minimal GET with Range.
* @return array{ok:bool, code:int|null, ms:int|null, error:string|null}
*/
function check_http_head(string $url, float $timeoutSeconds = 1.6): array
function check_http_head(string $url, float $timeoutSeconds = SERVER_STATUS_TIMEOUT_SECONDS): array
{
$head = check_http_request($url, 'HEAD', $timeoutSeconds);
@ -281,6 +358,18 @@ function build_server_status(array $targets): array
$allowedHosts = array_values(array_unique($allowedHosts));
$results = [];
// Attempt fast parallel HEAD checks via cURL.
$urls = [];
foreach ($targets as $t) {
$u = (string)($t['url'] ?? '');
if ($u !== '' && is_allowed_http_url($u, $allowedHosts)) {
$urls[] = $u;
}
}
$curlHeadMap = curl_multi_head_status($urls, SERVER_STATUS_TIMEOUT_SECONDS);
foreach ($targets as $t) {
$url = (string)($t['url'] ?? '');
if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
@ -288,7 +377,35 @@ function build_server_status(array $targets): array
continue;
}
$r = check_http_head($url);
// If we have a curl result, use it as a quick first pass.
if (isset($curlHeadMap[$url])) {
$c = $curlHeadMap[$url];
$code = $c['code'];
$ok = is_reachable_http_code($code);
// Handle redirects: probe location once using existing logic (stream-based) for correctness.
if ($code !== null && $code >= 300 && $code < 400) {
$r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS);
$results[] = [
'url' => $url,
'state' => $r['ok'] ? 'up' : 'down',
'ms' => $r['ms'],
'detail' => $r['ok'] ? null : ($r['error'] ?? null),
];
continue;
}
$results[] = [
'url' => $url,
'state' => $ok ? 'up' : 'down',
'ms' => $c['ms'],
'detail' => $ok ? null : ($c['error'] ?? ($code !== null ? ('HTTP ' . (string)$code) : null)),
];
continue;
}
// Fallback: previous sequential stream-based check.
$r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS);
$results[] = [
'url' => $url,
'state' => $r['ok'] ? 'up' : 'down',

View File

@ -57,6 +57,16 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
$status = null;
$statusKey = null;
// Für JS-Update: eine kanonische Status-URL bestimmen
$statusUrl = null;
if (!empty($project['url']) && is_string($project['url'])) {
if (substr($project['url'], 0, 1) === '/') {
$statusUrl = $__normalize_status_url('https://fabianschieder.com' . $project['url']);
} else {
$statusUrl = $__normalize_status_url((string)$project['url']);
}
}
// 1) Externe absolute URL direkt matchen
if (!empty($project['url']) && isset($__serverStatusByUrlNormalized[$__normalize_status_url((string)$project['url'])])) {
$statusKey = $__normalize_status_url((string)$project['url']);
@ -75,11 +85,11 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
$status = $__serverStatusByUrlNormalized[$statusKey];
}
$state = $status ? (string)($status['state'] ?? 'unknown') : null;
$state = $status ? (string)($status['state'] ?? 'unknown') : 'unknown';
$detail = $status && !empty($status['detail']) ? (string)$status['detail'] : '';
$badgeText = null;
if ($state !== null && $category !== 'dienste') {
if ($category !== 'dienste') {
$badgeText = 'Unbekannt';
if ($state === 'up') $badgeText = 'Online';
elseif ($state === 'down') $badgeText = 'Offline';
@ -91,6 +101,7 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
class="card"
<?= !empty($project['external']) ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
style="--accent: <?= htmlspecialchars((string)($project['color'] ?? '#6366f1'), ENT_QUOTES) ?>;"
<?= $statusUrl ? 'data-status-url="' . htmlspecialchars($statusUrl, ENT_QUOTES) . '"' : '' ?>
>
<div class="card-icon">
@ -117,6 +128,7 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
<?php if ($badgeText !== null): ?>
<div class="status-right">
<span class="status-badge status-badge--<?= htmlspecialchars((string)$state, ENT_QUOTES) ?>"
data-status-badge="1"
title="<?= htmlspecialchars($detail !== '' ? $detail : (string)$badgeText, ENT_QUOTES) ?>">
<?= htmlspecialchars((string)$badgeText, ENT_QUOTES) ?>
</span>

View File

@ -14,39 +14,10 @@ $serverStatusTargets = require __DIR__ . '/includes/config/server_status_targets
require_once __DIR__ . '/includes/lib/server_status.php';
require_once __DIR__ . '/includes/lib/view_helpers.php';
// ── Serverstatus (mit Cache) ──────────────────────────────────────────────
$cacheTtlSeconds = 30;
$cacheKey = 'server_status_v4';
$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));
}
// Index: url => status
// ── Serverstatus ─────────────────────────────────────────────────────────
// Wichtig für Performance: Beim initialen Rendern NICHT blockierend live prüfen.
// Die Badges starten als „Unbekannt“ und werden clientseitig über /server_status.php aktualisiert.
$serverStatusByUrl = [];
if (is_array($serverStatus)) {
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';

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
$path = $argv[1] ?? null;
if (!$path) {
fwrite(STDERR, "Usage: php scripts/inspect_encoding.php <file>\n");
exit(2);
}
$fp = fopen($path, 'rb');
if (!$fp) {
fwrite(STDERR, "Cannot open: $path\n");
exit(1);
}
$bytes = fread($fp, 16);
fclose($fp);
$arr = [];
for ($i = 0; $i < strlen($bytes); $i++) {
$arr[] = ord($bytes[$i]);
}
fwrite(STDOUT, json_encode($arr));
fwrite(STDOUT, PHP_EOL);

64
server_status.php Normal file
View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// Ensure clean UTF-8 JSON output
@ini_set('default_charset', 'UTF-8');
// JSON endpoint for server status (used for async UI updates)
$serverStatusTargets = require __DIR__ . '/includes/config/server_status_targets.php';
require_once __DIR__ . '/includes/lib/server_status.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
$cacheTtlSeconds = 30;
$cacheKey = 'server_status_v4';
$cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json';
$noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';
$serverStatus = null;
$cacheTs = 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'])) {
$cacheTs = (int)$decoded['ts'];
$age = time() - $cacheTs;
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));
}
// index by (normalized-ish) url
$byUrl = [];
foreach ($serverStatus as $svc) {
if (!empty($svc['url']) && is_string($svc['url'])) {
$byUrl[$svc['url']] = $svc;
}
}
$json = json_encode([
'ts' => time(),
'data' => $serverStatus,
'byUrl' => $byUrl,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
http_response_code(500);
$json = '{"error":"json_encode_failed"}';
}
// Write as raw bytes to avoid any output-encoding conversion.
fwrite(STDOUT, $json);