Add server status monitoring functionality with visual indicators for improved user awareness
This commit is contained in:
parent
6e38956be3
commit
6e633e0308
276
index.php
276
index.php
@ -64,7 +64,226 @@ $projects = [
|
||||
],
|
||||
],
|
||||
];
|
||||
?>
|
||||
|
||||
// ── Serverstatus (dependency-free, mit Cache & SSRF-Guards) ───────────────
|
||||
/**
|
||||
* Contract:
|
||||
* - Input: allowlist targets (host/url/port)
|
||||
* - Output: array of results with state + latency
|
||||
* - Safety: no user input, no redirects, blocks private/loopback IPs
|
||||
*/
|
||||
|
||||
$serverStatusTargets = [
|
||||
[
|
||||
'title' => 'Gitea',
|
||||
'url' => 'https://fabianschieder.com/git',
|
||||
],
|
||||
[
|
||||
'title' => 'Nextcloud',
|
||||
'url' => 'https://fabianschieder.com/nextcloud',
|
||||
],
|
||||
[
|
||||
'title' => 'Home Assistant',
|
||||
'url' => 'http://homeassistant.fabianschieder.com',
|
||||
],
|
||||
[
|
||||
'title' => 'NAS',
|
||||
'url' => 'http://nas.fabianschieder.com',
|
||||
],
|
||||
[
|
||||
'title' => 'Cockpit',
|
||||
'url' => 'https://cockpit.fabianschieder.com',
|
||||
],
|
||||
[
|
||||
'title' => 'Geizkragen',
|
||||
'url' => 'https://geizkragen.store',
|
||||
],
|
||||
];
|
||||
|
||||
/** @return bool true if IP is public-ish */
|
||||
function is_public_ip(string $ip): bool
|
||||
{
|
||||
// FILTER_FLAG_NO_PRIV_RANGE + NO_RES_RANGE covers most private/reserved ranges.
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: treat unknown/invalid as not allowed.
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Resolve host to IPs (A/AAAA) */
|
||||
function resolve_host_ips(string $host): array
|
||||
{
|
||||
$ips = [];
|
||||
|
||||
// IPv4
|
||||
$a = @dns_get_record($host, DNS_A);
|
||||
if (is_array($a)) {
|
||||
foreach ($a as $rec) {
|
||||
if (!empty($rec['ip'])) $ips[] = $rec['ip'];
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6
|
||||
$aaaa = @dns_get_record($host, DNS_AAAA);
|
||||
if (is_array($aaaa)) {
|
||||
foreach ($aaaa as $rec) {
|
||||
if (!empty($rec['ipv6'])) $ips[] = $rec['ipv6'];
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
$ips = array_values(array_unique(array_filter($ips)));
|
||||
return $ips;
|
||||
}
|
||||
|
||||
function is_allowed_http_url(string $url, array $allowedHosts): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) return false;
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) return false;
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
if (!in_array($host, $allowedHosts, true)) return false;
|
||||
|
||||
// Resolve and ensure all resolved IPs are public.
|
||||
$ips = resolve_host_ips($host);
|
||||
if (empty($ips)) return false;
|
||||
foreach ($ips as $ip) {
|
||||
if (!is_public_ip($ip)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP HEAD check (no redirects).
|
||||
* @return array{ok:bool, code:int|null, ms:int|null, error:string|null}
|
||||
*/
|
||||
function check_http_head(string $url, float $timeoutSeconds = 1.6): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'HEAD',
|
||||
'timeout' => $timeoutSeconds,
|
||||
'ignore_errors' => true,
|
||||
'follow_location' => 0,
|
||||
'max_redirects' => 0,
|
||||
'header' => "User-Agent: ServerStatus/1.0\r\n",
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$code = null;
|
||||
$error = null;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message) use (&$error): bool {
|
||||
$error = $message;
|
||||
return true;
|
||||
});
|
||||
$fp = @fopen($url, 'rb', false, $ctx);
|
||||
restore_error_handler();
|
||||
|
||||
if ($fp !== false) {
|
||||
fclose($fp);
|
||||
}
|
||||
|
||||
$ms = (int)round((microtime(true) - $start) * 1000);
|
||||
|
||||
// Parse status code from $http_response_header
|
||||
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||
foreach ($http_response_header as $h) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) {
|
||||
$code = (int)$m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ok = ($code !== null) && ($code >= 200 && $code < 400);
|
||||
if ($code === null && $error === null) {
|
||||
$error = 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error];
|
||||
}
|
||||
|
||||
function build_server_status(array $targets): array
|
||||
{
|
||||
$allowedHosts = [];
|
||||
foreach ($targets as $t) {
|
||||
if (!empty($t['url'])) {
|
||||
$p = parse_url((string)$t['url']);
|
||||
if (is_array($p) && !empty($p['host'])) {
|
||||
$allowedHosts[] = strtolower((string)$p['host']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$allowedHosts = array_values(array_unique($allowedHosts));
|
||||
|
||||
$results = [];
|
||||
foreach ($targets as $t) {
|
||||
$url = (string)($t['url'] ?? '');
|
||||
if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
|
||||
$results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$r = check_http_head($url);
|
||||
$results[] = [
|
||||
'url' => $url,
|
||||
'state' => $r['ok'] ? 'up' : 'down',
|
||||
'ms' => $r['ms'],
|
||||
'detail' => $r['ok'] ? null : ($r['error'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
$cacheTtlSeconds = 30;
|
||||
$cacheKey = 'server_status_v1';
|
||||
$cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json';
|
||||
|
||||
$serverStatus = null;
|
||||
$cacheOk = false;
|
||||
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'];
|
||||
$cacheOk = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
if (is_array($serverStatus)) {
|
||||
foreach ($serverStatus as $svc) {
|
||||
if (!empty($svc['url']) && is_string($svc['url'])) {
|
||||
$serverStatusByUrl[$svc['url']] = $svc;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@ -110,6 +329,40 @@ $projects = [
|
||||
|
||||
<div class="cards">
|
||||
<?php foreach ($items as $project): ?>
|
||||
<?php
|
||||
// Optional: Status für dieses Projekt erkennen
|
||||
$status = null;
|
||||
$statusKey = null;
|
||||
|
||||
// 1) Externe absolute URL direkt matchen
|
||||
if (!empty($project['url']) && isset($serverStatusByUrl[$project['url']])) {
|
||||
$statusKey = $project['url'];
|
||||
}
|
||||
|
||||
// 2) Interne Pfade (/git, /nextcloud) auf Domain mappen
|
||||
if ($statusKey === null && !empty($project['url']) && is_string($project['url']) && substr($project['url'], 0, 1) === '/') {
|
||||
$statusKeyCandidate = 'https://fabianschieder.com' . $project['url'];
|
||||
if (isset($serverStatusByUrl[$statusKeyCandidate])) {
|
||||
$statusKey = $statusKeyCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($statusKey !== null) {
|
||||
$status = $serverStatusByUrl[$statusKey];
|
||||
}
|
||||
|
||||
$state = $status ? (string)($status['state'] ?? 'unknown') : null;
|
||||
$detail = $status && !empty($status['detail']) ? (string)$status['detail'] : '';
|
||||
$ms = ($status && isset($status['ms']) && is_int($status['ms'])) ? $status['ms'] : null;
|
||||
|
||||
$badgeText = null;
|
||||
if ($state !== null) {
|
||||
$badgeText = 'Unbekannt';
|
||||
if ($state === 'up') $badgeText = 'Online';
|
||||
elseif ($state === 'down') $badgeText = 'Offline';
|
||||
}
|
||||
?>
|
||||
|
||||
<a
|
||||
href="<?= htmlspecialchars($project['url'], ENT_QUOTES) ?>"
|
||||
class="card"
|
||||
@ -133,10 +386,20 @@ $projects = [
|
||||
|
||||
<div class="card-body">
|
||||
<h3><?= htmlspecialchars($project['title'], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($project['description'], ENT_QUOTES) ?></p>
|
||||
<p>
|
||||
<?= htmlspecialchars($project['description'], ENT_QUOTES) ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-arrow" aria-hidden="true">→</div>
|
||||
<?php if ($badgeText !== null): ?>
|
||||
<div class="status-right">
|
||||
<span class="status-badge status-badge--<?= htmlspecialchars($state, ENT_QUOTES) ?>" title="<?= htmlspecialchars($detail !== '' ? $detail : $badgeText, ENT_QUOTES) ?>">
|
||||
<?= htmlspecialchars($badgeText, ENT_QUOTES) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-arrow" aria-hidden="true">→</div>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -156,9 +419,8 @@ $projects = [
|
||||
let wi = 0, ci = 0, deleting = false;
|
||||
|
||||
function type() {
|
||||
const word = words[wi];
|
||||
const display = deleting ? word.slice(0, ci--) : word.slice(0, ci++);
|
||||
el.textContent = display;
|
||||
const word = words[wi];
|
||||
el.textContent = deleting ? word.slice(0, ci--) : word.slice(0, ci++);
|
||||
|
||||
let delay = deleting ? 60 : 110;
|
||||
if (!deleting && ci > word.length) { delay = 1400; deleting = true; }
|
||||
@ -248,3 +510,5 @@ document.querySelectorAll('.card').forEach(card => {
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
81
style.css
81
style.css
@ -373,7 +373,73 @@ footer:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
/* ===== SERVER STATUS ===== */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: 0.6rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.status-badge--up {
|
||||
color: #22c55e;
|
||||
border-color: rgba(34,197,94,0.22);
|
||||
background: rgba(34,197,94,0.10);
|
||||
}
|
||||
|
||||
.status-badge--down {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239,68,68,0.22);
|
||||
background: rgba(239,68,68,0.10);
|
||||
}
|
||||
|
||||
.status-badge--unknown {
|
||||
color: #a1a1aa;
|
||||
border-color: rgba(161,161,170,0.22);
|
||||
background: rgba(161,161,170,0.08);
|
||||
}
|
||||
|
||||
.status-meta {
|
||||
color: color-mix(in srgb, var(--text-muted) 82%, transparent);
|
||||
margin-left: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ===== SERVER STATUS (right indicator) ===== */
|
||||
.status-right {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 1.5rem 0.75rem 3rem;
|
||||
@ -382,6 +448,16 @@ footer:hover {
|
||||
.card-body p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
margin-left: 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
}
|
||||
|
||||
.status-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== PARTICLE CANVAS ===== */
|
||||
@ -465,6 +541,3 @@ body::before {
|
||||
@keyframes ripple-anim {
|
||||
to { transform: scale(4); opacity: 0; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user