diff --git a/assets/js/main.js b/assets/js/main.js index 8d8e886..c5a25e5 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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" + } +})(); diff --git a/includes/lib/server_status.php b/includes/lib/server_status.php index cecfb6b..0a077ee 100644 --- a/includes/lib/server_status.php +++ b/includes/lib/server_status.php @@ -1,6 +1,13 @@ $urls + * @return array}> 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', diff --git a/includes/views/projects_section.php b/includes/views/projects_section.php index 1ddc163..29726cb 100644 --- a/includes/views/projects_section.php +++ b/includes/views/projects_section.php @@ -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" style="--accent: ;" + >
@@ -117,6 +128,7 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
diff --git a/index.php b/index.php index 38410d7..7db2710 100644 --- a/index.php +++ b/index.php @@ -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'; diff --git a/scripts/inspect_encoding.php b/scripts/inspect_encoding.php new file mode 100644 index 0000000..12a65d2 --- /dev/null +++ b/scripts/inspect_encoding.php @@ -0,0 +1,26 @@ +\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); + diff --git a/server_status.php b/server_status.php new file mode 100644 index 0000000..0baad63 --- /dev/null +++ b/server_status.php @@ -0,0 +1,64 @@ += 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); +