Refactor server status handling and add async UI updates with JSON endpoint
This commit is contained in:
parent
f6fd033c21
commit
f3fae8ae51
@ -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"
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
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)
|
* Serverstatus (dependency-free, mit Cache & SSRF-Guards)
|
||||||
*
|
*
|
||||||
@ -198,14 +205,84 @@ function check_http_request(string $url, string $method, float $timeoutSeconds =
|
|||||||
return $res;
|
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).
|
* 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}
|
* @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);
|
$head = check_http_request($url, 'HEAD', $timeoutSeconds);
|
||||||
|
|
||||||
@ -281,6 +358,18 @@ function build_server_status(array $targets): array
|
|||||||
$allowedHosts = array_values(array_unique($allowedHosts));
|
$allowedHosts = array_values(array_unique($allowedHosts));
|
||||||
|
|
||||||
$results = [];
|
$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) {
|
foreach ($targets as $t) {
|
||||||
$url = (string)($t['url'] ?? '');
|
$url = (string)($t['url'] ?? '');
|
||||||
if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
|
if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
|
||||||
@ -288,7 +377,35 @@ function build_server_status(array $targets): array
|
|||||||
continue;
|
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[] = [
|
$results[] = [
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
'state' => $r['ok'] ? 'up' : 'down',
|
'state' => $r['ok'] ? 'up' : 'down',
|
||||||
|
|||||||
@ -57,6 +57,16 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
|
|||||||
$status = null;
|
$status = null;
|
||||||
$statusKey = 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
|
// 1) Externe absolute URL direkt matchen
|
||||||
if (!empty($project['url']) && isset($__serverStatusByUrlNormalized[$__normalize_status_url((string)$project['url'])])) {
|
if (!empty($project['url']) && isset($__serverStatusByUrlNormalized[$__normalize_status_url((string)$project['url'])])) {
|
||||||
$statusKey = $__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];
|
$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'] : '';
|
$detail = $status && !empty($status['detail']) ? (string)$status['detail'] : '';
|
||||||
|
|
||||||
$badgeText = null;
|
$badgeText = null;
|
||||||
if ($state !== null && $category !== 'dienste') {
|
if ($category !== 'dienste') {
|
||||||
$badgeText = 'Unbekannt';
|
$badgeText = 'Unbekannt';
|
||||||
if ($state === 'up') $badgeText = 'Online';
|
if ($state === 'up') $badgeText = 'Online';
|
||||||
elseif ($state === 'down') $badgeText = 'Offline';
|
elseif ($state === 'down') $badgeText = 'Offline';
|
||||||
@ -91,6 +101,7 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
|
|||||||
class="card"
|
class="card"
|
||||||
<?= !empty($project['external']) ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
|
<?= !empty($project['external']) ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
|
||||||
style="--accent: <?= htmlspecialchars((string)($project['color'] ?? '#6366f1'), ENT_QUOTES) ?>;"
|
style="--accent: <?= htmlspecialchars((string)($project['color'] ?? '#6366f1'), ENT_QUOTES) ?>;"
|
||||||
|
<?= $statusUrl ? 'data-status-url="' . htmlspecialchars($statusUrl, ENT_QUOTES) . '"' : '' ?>
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="card-icon">
|
<div class="card-icon">
|
||||||
@ -117,6 +128,7 @@ foreach ($serverStatusByUrl as $__u => $__svc) {
|
|||||||
<?php if ($badgeText !== null): ?>
|
<?php if ($badgeText !== null): ?>
|
||||||
<div class="status-right">
|
<div class="status-right">
|
||||||
<span class="status-badge status-badge--<?= htmlspecialchars((string)$state, ENT_QUOTES) ?>"
|
<span class="status-badge status-badge--<?= htmlspecialchars((string)$state, ENT_QUOTES) ?>"
|
||||||
|
data-status-badge="1"
|
||||||
title="<?= htmlspecialchars($detail !== '' ? $detail : (string)$badgeText, ENT_QUOTES) ?>">
|
title="<?= htmlspecialchars($detail !== '' ? $detail : (string)$badgeText, ENT_QUOTES) ?>">
|
||||||
<?= htmlspecialchars((string)$badgeText, ENT_QUOTES) ?>
|
<?= htmlspecialchars((string)$badgeText, ENT_QUOTES) ?>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
35
index.php
35
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/server_status.php';
|
||||||
require_once __DIR__ . '/includes/lib/view_helpers.php';
|
require_once __DIR__ . '/includes/lib/view_helpers.php';
|
||||||
|
|
||||||
// ── Serverstatus (mit Cache) ──────────────────────────────────────────────
|
// ── Serverstatus ─────────────────────────────────────────────────────────
|
||||||
$cacheTtlSeconds = 30;
|
// Wichtig für Performance: Beim initialen Rendern NICHT blockierend live prüfen.
|
||||||
$cacheKey = 'server_status_v4';
|
// Die Badges starten als „Unbekannt“ und werden clientseitig über /server_status.php aktualisiert.
|
||||||
$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
|
|
||||||
$serverStatusByUrl = [];
|
$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/layout_head.php';
|
||||||
require __DIR__ . '/includes/views/header.php';
|
require __DIR__ . '/includes/views/header.php';
|
||||||
|
|||||||
26
scripts/inspect_encoding.php
Normal file
26
scripts/inspect_encoding.php
Normal 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
64
server_status.php
Normal 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);
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user