Files
deskhub/DeskHub-Webseite/includes/status_page.php
T
2026-10-28 07:54:38 +00:00

758 lines
44 KiB
PHP

<?php
/**
* includes/status_page.php
*
* Statusseite für Kunden (status.php): Zustand der Dienste („Komponenten“) und Meldungen zu Störungen und Wartungen.
* Verwaltet wird sie im Mitarbeiterbereich (Adminportal → Statusseite) und über die Apps.
*/
function status_page_enabled(): bool
{
return get_setting('status_page_enabled', '0') === '1';
}
/** Zustände einer Komponente, vom besten zum schlechtesten. Schlüssel => [Beschriftung, Farbe, Rang]. */
function status_component_states(): array
{
return [
'operational' => ['In Betrieb', '#157a45', 0],
'maintenance' => ['Wartung', '#2563eb', 1],
'degraded' => ['Eingeschränkt', '#b45309', 2],
'outage' => ['Ausfall', '#dc2626', 3],
];
}
/** Erkennt ohne zusätzliche Einstellung Datenbank, DeskHub-API und konfigurierte Prüf-URLs. */
function status_app_connection(PDO $pdo, string $platform): array
{
$timeoutSeconds = $platform === 'android' ? 3600 : 600;
$timeoutMinutes = (int) ($timeoutSeconds / 60);
$cutoff = date('Y-m-d H:i:s', time() - $timeoutSeconds);
$deviceFilter = $platform === 'windows'
? "device_name LIKE 'Windows Desktop (%'"
: "(device_name IS NULL OR device_name NOT LIKE 'Windows Desktop (%')";
try {
$stmt = $pdo->prepare("SELECT MAX(last_used_at) FROM mobile_api_tokens WHERE revoked_at IS NULL AND expires_at > ? AND last_used_at IS NOT NULL AND $deviceFilter");
$stmt->execute([db_now()]);
$lastSeen = $stmt->fetchColumn();
} catch (Throwable $e) {
$lastSeen = false;
}
$lastSeen = is_string($lastSeen) && $lastSeen !== '' ? $lastSeen : null;
return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes];
}
function status_component_monitor_target(PDO $pdo, array $component): ?array
{
$type = (string) ($component['monitor_type'] ?? 'auto');
$target = trim((string) ($component['monitor_target'] ?? ''));
$category = mb_strtolower((string) ($component['category_name'] ?? ''));
$name = mb_strtolower((string) ($component['name'] ?? ''));
if ($type === 'manual') return null;
if ($type === 'auto') {
if ($target !== '') {
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
}
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
if (str_contains($name, 'api') || str_contains($category, 'api')) return ['type' => 'http', 'target' => rtrim(base_url(), '/') . '/api/public/v1/health.php', 'label' => 'DeskHub-API'];
if ((str_contains($name, 'support') || str_contains($name, 'kundenportal')) && (str_contains($category, 'web') || $category === '')) return ['type' => 'http', 'target' => rtrim(base_url(), '/') . '/index.php', 'label' => 'DeskHub-Portal'];
if (str_contains($name, 'mail') || str_contains($category, 'e-mail')) return status_configured_mail_endpoints($pdo) ? ['type' => 'mail', 'target' => '', 'label' => 'E-Mail-Server'] : null;
if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'];
return null;
}
if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
if ($type === 'mail') return status_configured_mail_endpoints($pdo) ? ['type' => 'mail', 'target' => '', 'label' => 'E-Mail-Server'] : null;
if ($type === 'fcm') return function_exists('fcm_configured') && fcm_configured() ? ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'] : null;
if ($type === 'smtp') {
if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587);
return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null;
}
if ($type === 'tcp' && $target !== '' && str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung'];
return null;
}
function status_execute_component_check(PDO $pdo, array $check): array
{
$started = microtime(true);
$ok = false;
$error = '';
if ($check['type'] === 'database') {
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; }
} elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) {
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
// Erreichbar heißt: Der Server nimmt Anfragen der App an (Datenbank und Schnittstelle laufen). Ob gerade ein PC oder Handy
// eingeschaltet ist, sagt darüber nichts: Ohne laufendes Programm ist der Dienst trotzdem erreichbar.
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Schnittstelle für die App nicht erreichbar (Datenbankabfrage fehlgeschlagen)'; }
} elseif ($check['type'] === 'mail') {
$endpoints = status_configured_mail_endpoints($pdo);
$ok = count($endpoints) > 0;
foreach ($endpoints as $endpoint) {
$socket = @stream_socket_client('tcp://' . $endpoint['host'] . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
if (!is_resource($socket)) {
$ok = false;
$error = $endpoint['name'] . '-Server nicht erreichbar (' . $endpoint['host'] . ':' . $endpoint['port'] . ')';
break;
}
fclose($socket);
}
if (!$ok && $error === '') $error = 'Kein E-Mail-Server eingerichtet';
} elseif ($check['type'] === 'fcm') {
$mode = function_exists('fcm_mode') ? fcm_mode() : 'none';
if ($mode === 'own' && function_exists('fcm_service_account') && function_exists('fcm_access_token')) {
$account = fcm_service_account();
$ok = $account !== null && fcm_access_token($account) !== null;
if (!$ok) $error = 'Firebase-Zugang konnte nicht bestätigt werden';
} elseif ($mode === 'relay') {
$url = function_exists('fcm_relay_url') ? fcm_relay_url() : '';
$host = (string) parse_url($url, PHP_URL_HOST);
$port = (int) (parse_url($url, PHP_URL_PORT) ?: 443);
if ($host !== '') {
$socket = @stream_socket_client('tcp://' . $host . ':' . $port, $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
$ok = is_resource($socket);
if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar';
} else $error = 'Push-Dienst nicht eingerichtet';
} else $error = 'Push-Dienst nicht eingerichtet';
} elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
$endpoint = $check['type'] === 'minecraft'
? status_resolve_minecraft_target((string) $check['target'])
: status_parse_host_port((string) $check['target']);
if ($endpoint !== null) {
$host = strpos($endpoint['host'], ':') !== false ? '[' . $endpoint['host'] . ']' : $endpoint['host'];
$socket = @stream_socket_client('tcp://' . $host . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
$ok = is_resource($socket);
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
} else $error = 'Ungültiges Prüfziel';
} elseif ($check['type'] === 'http') {
$url = (string) $check['target'];
if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => static fn($handle, string $body): int => strlen($body), CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 4, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_TIMEOUT => 7, CURLOPT_USERAGENT => 'DeskHub Status Check']);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
$ok = $code >= 200 && $code < 400;
} else {
$context = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 7, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 4], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
@file_get_contents($url, false, $context);
$code = 0;
foreach (($http_response_header ?? []) as $header) if (preg_match('/^HTTP\\/\\S+\\s+(\\d{3})/', $header, $m)) $code = (int) $m[1];
$ok = $code >= 200 && $code < 400;
}
if (!$ok && $error === '') $error = 'Webseite antwortet mit HTTP ' . ($code ?: 'ohne Antwort');
} else $error = 'Ungültige oder nicht unterstützte Prüf-URL';
}
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
}
function status_run_component_checks(PDO $pdo, bool $force = false): int
{
$components = status_components($pdo);
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
$sample = $pdo->prepare('INSERT INTO status_component_checks (component_id, status, checked_at, response_ms) VALUES (?,?,?,?)');
$checked = 0;
foreach ($components as $component) {
$check = status_component_monitor_target($pdo, $component);
if ($check === null) continue;
$intervalMinutes = max(1, (int) ($component['check_interval_minutes'] ?? 1));
$lastChecked = trim((string) ($component['last_checked_at'] ?? ''));
if (!$force && $lastChecked !== '' && strtotime($lastChecked) > time() - $intervalMinutes * 60) continue;
$result = status_execute_component_check($pdo, $check);
$failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1;
$update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]);
$currentStatus = (string) $component['status'];
if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') {
status_set_component($pdo, (int) $component['id'], 'operational');
$currentStatus = 'operational';
} elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') {
status_set_component($pdo, (int) $component['id'], 'outage');
$currentStatus = 'outage';
}
$sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]);
$checked++;
}
if (function_exists('pseudo_cron_try_claim') && pseudo_cron_try_claim($pdo, 'status_check_samples_cleanup', 86400)) {
$pdo->prepare('DELETE FROM status_component_checks WHERE checked_at < ?')->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]);
}
return $checked;
}
/** Erkennt vorhandene automatische Prüfziele und prüft sie sofort; externe Ziele ohne URL bleiben offen. */
function status_discover_and_check(PDO $pdo): array
{
$recognized = [];
$pending = [];
foreach (status_components($pdo) as $component) {
if (($component['monitor_type'] ?? 'auto') === 'manual') continue;
if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name'];
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name'];
}
$checked = status_run_component_checks($pdo, true);
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
}
/** Stufen einer Störungsmeldung. */
function status_incident_states(): array
{
return [
'investigating' => 'Wird untersucht',
'identified' => 'Ursache gefunden',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
];
}
function status_components(PDO $pdo, bool $includeDisabled = false): array
{
$where = $includeDisabled ? '' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
}
function status_categories(PDO $pdo): array
{
return $pdo->query('SELECT * FROM status_categories ORDER BY sort_order, name')->fetchAll();
}
function status_monitor_types(): array
{
return [
'auto' => 'Automatisch erkennen',
'http' => 'Webseite / URL',
'android_app' => 'Android-App-Verbindung',
'windows_app' => 'Windows-Programm-Verbindung',
'database' => 'Datenbankverbindung',
'smtp' => 'SMTP-Server',
'mail' => 'E-Mail (SMTP und IMAP)',
'fcm' => 'Push-Dienst',
'tcp' => 'Server und Port',
'manual' => 'Manuell verwalten',
];
}
/** Parses hostname:port, IPv4:port, or [IPv6]:port for TCP service checks. */
function status_parse_host_port(string $target): ?array
{
$target = trim($target);
if (preg_match('/^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/D', $target, $m)) {
if (filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) return null;
$host = $m[1];
$port = (int) $m[2];
} elseif (preg_match('/^([A-Za-z0-9.-]+):(\d{1,5})$/D', $target, $m)) {
$host = $m[1];
$port = (int) $m[2];
if (strlen($host) > 253) return null;
foreach (explode('.', rtrim($host, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') return null;
}
} else {
return null;
}
if ($port < 1 || $port > 65535) return null;
return ['host' => $host, 'port' => $port];
}
/** Minecraft accepts a bare host and resolves its Java port through DNS SRV. */
function status_parse_minecraft_target(string $target): ?array
{
$target = trim($target);
$explicit = status_parse_host_port($target);
if ($explicit !== null) return $explicit;
if (preg_match('/^\[([0-9a-fA-F:.]+)\]$/D', $target, $m)) {
return filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
? ['host' => $m[1], 'port' => null]
: null;
}
if (filter_var($target, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return ['host' => $target, 'port' => null];
if (strlen($target) > 253 || $target === '') return null;
foreach (explode('.', rtrim($target, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-' || !preg_match('/^[A-Za-z0-9-]+$/D', $label)) return null;
}
return ['host' => rtrim($target, '.'), 'port' => null];
}
function status_resolve_minecraft_target(string $target): ?array
{
$endpoint = status_parse_minecraft_target($target);
if ($endpoint === null || $endpoint['port'] !== null) return $endpoint;
$host = $endpoint['host'];
if (filter_var($host, FILTER_VALIDATE_IP) !== false) return ['host' => $host, 'port' => 25565];
if (function_exists('dns_get_record') && defined('DNS_SRV')) {
$records = @dns_get_record('_minecraft._tcp.' . $host, DNS_SRV);
if (is_array($records) && $records) {
usort($records, static fn(array $a, array $b): int => ((int) ($a['pri'] ?? 0)) <=> ((int) ($b['pri'] ?? 0)));
foreach ($records as $record) {
$srvHost = rtrim((string) ($record['target'] ?? ''), '.');
$port = (int) ($record['port'] ?? 0);
if ($srvHost !== '' && $port >= 1 && $port <= 65535 && status_parse_minecraft_target($srvHost) !== null) return ['host' => $srvHost, 'port' => $port];
}
}
}
return ['host' => $host, 'port' => 25565];
}
/** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */
function status_configured_mail_endpoints(PDO $pdo): array
{
$targets = [];
$globalHost = trim((string) get_setting('smtp_host', ''));
if ($globalHost !== '') $targets[] = ['host' => $globalHost, 'port' => (int) get_setting('smtp_port', '587') ?: 587, 'name' => 'SMTP'];
try {
foreach ($pdo->query("SELECT smtp_host, smtp_port FROM email_addresses WHERE smtp_enabled = 1 AND is_active = 1 AND smtp_host <> ''")->fetchAll() as $row) {
if (trim((string) $row['smtp_host']) !== '') $targets[] = ['host' => trim((string) $row['smtp_host']), 'port' => (int) ($row['smtp_port'] ?: 587), 'name' => 'SMTP'];
}
} catch (Throwable $e) {
// Einzelne oder alte Installationen können andere E-Mail-Tabellen haben.
}
try {
foreach ($pdo->query('SELECT host, port FROM imap_accounts WHERE is_active = 1')->fetchAll() as $row) {
if (trim((string) $row['host']) !== '') $targets[] = ['host' => trim((string) $row['host']), 'port' => (int) ($row['port'] ?: 993), 'name' => 'IMAP'];
}
} catch (Throwable $e) {
// IMAP ist optional.
}
$unique = [];
foreach ($targets as $target) $unique[$target['host'] . ':' . $target['port']] = $target;
return array_values($unique);
}
/** Zeigt die dynamisch verwendeten Prüfziele im Mitarbeiterbereich ohne Zugangsdaten an. */
function status_component_detected_targets(PDO $pdo, array $check): array
{
if (in_array($check['type'], ['android_app', 'windows_app'], true)) {
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
$label = $platform === 'windows' ? 'Windows' : 'Android';
$connection = status_app_connection($pdo, $platform);
if ($connection['last_seen'] === null) return ['Server erreichbar · noch kein Gerät mit ' . $label . ' angemeldet gewesen'];
$when = format_dt($connection['last_seen']);
return ['Server erreichbar · ' . ($connection['online'] ? 'Gerät gerade verbunden, letzter Kontakt ' . $when : 'kein Gerät gerade aktiv, letzter Kontakt ' . $when)];
}
if ($check['type'] === 'database') {
$db = (array) (config()['db'] ?? []);
$driver = strtolower((string) ($db['driver'] ?? ''));
if ($driver === 'sqlite') return ['SQLite-Datenbank (lokal)'];
if ($driver === 'mysql') {
$host = trim((string) ($db['host'] ?? ''));
$port = (int) ($db['port'] ?? 3306) ?: 3306;
return [$host !== '' ? 'MySQL · ' . $host . ':' . $port : 'MySQL-Datenbank'];
}
return [$driver !== '' ? strtoupper($driver) . '-Datenbank' : 'Datenbankverbindung'];
}
if ($check['type'] === 'mail') {
return array_map(static fn(array $endpoint): string => $endpoint['name'] . ' · ' . $endpoint['host'] . ':' . $endpoint['port'], status_configured_mail_endpoints($pdo));
}
if ($check['type'] === 'fcm') {
$mode = function_exists('fcm_mode') ? fcm_mode() : 'none';
if ($mode === 'own') return ['Firebase Cloud Messaging'];
if ($mode === 'relay') {
$url = function_exists('fcm_relay_url') ? fcm_relay_url() : '';
$host = (string) parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
return [$host !== '' ? 'Push-Relay · ' . $host . ($port ? ':' . $port : '') : 'Push-Relay'];
}
return ['Push-Dienst'];
}
return [(string) ($check['target'] ?? '')];
}
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target, int $intervalMinutes = 1): bool
{
if (!isset(status_monitor_types()[$type])) return false;
if (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
$target = trim(mb_substr($target, 0, 500));
if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false;
if ($type === 'tcp' && $target !== '') {
$componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE id = ?');
$componentStmt->execute([$id]);
$componentName = mb_strtolower((string) $componentStmt->fetchColumn());
$validTarget = str_contains($componentName, 'minecraft')
? status_parse_minecraft_target($target) !== null
: status_parse_host_port($target) !== null;
if (!$validTarget) return false;
}
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, check_interval_minutes = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$type, $target !== '' ? $target : null, $intervalMinutes, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
function status_save_category(PDO $pdo, string $name): bool
{
$name = trim(mb_substr($name, 0, 80));
if ($name === '') return false;
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE LOWER(name) = LOWER(?)');
$check->execute([$name]);
if ((int) $check->fetchColumn() > 0) return false;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute([$name, count(status_categories($pdo)) * 10 + 10]);
return true;
}
function status_delete_category(PDO $pdo, int $id): void
{
$pdo->prepare('UPDATE status_components SET category_id = NULL, category = NULL WHERE category_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_categories WHERE id = ?')->execute([$id]);
}
function status_set_category_order(PDO $pdo, int $id, int $sort): bool
{
$stmt = $pdo->prepare('UPDATE status_categories SET sort_order = ? WHERE id = ?');
$stmt->execute([$sort, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_categories WHERE id = ' . (int) $id)->fetchColumn();
}
/** Gesamtzustand: der schlechteste Zustand aller Komponenten und offener Störungen. @return array{key:string,label:string,color:string} */
function status_overall(PDO $pdo): array
{
$states = status_component_states();
$worst = 'operational';
foreach (status_components($pdo) as $c) {
if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status'];
}
// Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)")->fetchColumn();
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
$activeMaintenance = status_active_maintenance_map($pdo);
$hasActiveMaintenance = isset($activeMaintenance[0]);
if (!$hasActiveMaintenance) {
foreach (status_components($pdo) as $component) {
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
}
}
if ($hasActiveMaintenance && $states[$worst][2] < $states['maintenance'][2]) $worst = 'maintenance';
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
}
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
{
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
$stmt = $pdo->prepare($sql);
$stmt->execute($onlyOpen ? [] : [$since]);
$rows = $stmt->fetchAll();
$up = $pdo->prepare('SELECT * FROM status_incident_updates WHERE incident_id = ? ORDER BY created_at DESC, id DESC');
foreach ($rows as &$r) {
$up->execute([$r['id']]);
$r['updates'] = $up->fetchAll();
}
return $rows;
}
/** Liefert geplante und aktuell laufende Wartungsfenster (oder die Admin-Historie). */
function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): array
{
$now = date('Y-m-d H:i:s');
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
$where = $includeHistory
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
return $stmt->fetchAll();
}
function status_create_maintenance_window(PDO $pdo, string $title, string $body, string $startsAt, string $endsAt, ?int $componentId, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
$body = trim(mb_substr($body, 0, 4000));
$start = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $startsAt);
$end = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $endsAt);
$startErrors = DateTimeImmutable::getLastErrors();
$endErrors = DateTimeImmutable::getLastErrors();
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel für die Wartung angeben.'];
if (!$start || !$end || ($startErrors && ($startErrors['warning_count'] || $startErrors['error_count'])) || ($endErrors && ($endErrors['warning_count'] || $endErrors['error_count']))) return ['ok' => false, 'error' => 'Bitte gültige Start- und Endzeiten angeben.'];
if ($end <= $start || $end <= new DateTimeImmutable()) return ['ok' => false, 'error' => 'Die Endzeit muss nach der Startzeit und in der Zukunft liegen.'];
if ($componentId !== null) {
$component = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ? AND COALESCE(is_enabled, 1) = 1');
$component->execute([$componentId]);
if (!(int) $component->fetchColumn()) return ['ok' => false, 'error' => 'Der ausgewählte Dienst ist nicht verfügbar.'];
}
$now = db_now();
$pdo->prepare('INSERT INTO status_maintenance_windows (title, body, component_id, starts_at, ends_at, created_by, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([$title, $body !== '' ? $body : null, $componentId, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $staffId, $now]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
function status_cancel_maintenance_window(PDO $pdo, int $id): bool
{
$stmt = $pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL');
$stmt->execute([db_now(), $id]);
return $stmt->rowCount() > 0;
}
/** Aktive Fenster nach Dienst-ID; Schlüssel 0 steht für eine globale Wartung. */
function status_active_maintenance_map(PDO $pdo): array
{
$now = date('Y-m-d H:i:s');
$stmt = $pdo->prepare('SELECT id, component_id FROM status_maintenance_windows WHERE cancelled_at IS NULL AND starts_at <= ? AND ends_at >= ?');
$stmt->execute([$now, $now]);
$active = [];
foreach ($stmt->fetchAll() as $window) $active[(int) ($window['component_id'] ?? 0)] = true;
return $active;
}
function status_set_component(PDO $pdo, int $id, string $status): bool
{
if (!isset(status_component_states()[$status])) return false;
$current = $pdo->prepare('SELECT status FROM status_components WHERE id = ?');
$current->execute([$id]);
$previous = $current->fetchColumn();
if ($previous === false) return false;
$s = $pdo->prepare('UPDATE status_components SET status = ? WHERE id = ?');
$s->execute([$status, $id]);
if ($previous !== $status) {
$pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)')->execute([$id, $status, db_now()]);
}
return true;
}
function status_toggle_component_maintenance(PDO $pdo, int $id): bool
{
$stmt = $pdo->prepare('SELECT status FROM status_components WHERE id = ?');
$stmt->execute([$id]);
$current = $stmt->fetchColumn();
if ($current === false) return false;
return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance');
}
function status_set_component_enabled(PDO $pdo, int $id, bool $enabled): bool
{
$stmt = $pdo->prepare('UPDATE status_components SET is_enabled = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$enabled ? 1 : 0, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
/** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
{
$checks = max(1, min(100, $checks));
$now = time();
$windowStart = $now - 86400;
$priorQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? AND checked_at < ? ORDER BY checked_at DESC, id DESC LIMIT 1');
$priorQuery->execute([$componentId, date('Y-m-d H:i:s', $windowStart)]);
$prior = $priorQuery->fetch();
$recentQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? AND checked_at >= ? AND checked_at <= ? ORDER BY checked_at, id');
$recentQuery->execute([$componentId, date('Y-m-d H:i:s', $windowStart), date('Y-m-d H:i:s', $now)]);
$recent = $recentQuery->fetchAll();
$priorTime = $prior ? strtotime((string) $prior['checked_at']) : null;
$state = $priorTime !== null && $priorTime >= $windowStart - 120 ? (string) $prior['status'] : null;
$cursor = $state !== null ? max($windowStart, $priorTime) : $windowStart;
$knownSeconds = 0;
$upSeconds = 0;
foreach ($recent as $check) {
$checkTime = strtotime((string) $check['checked_at']);
if ($state !== null && $checkTime > $cursor) {
$duration = min($checkTime - $cursor, 120);
$knownSeconds += $duration;
if ($state === 'operational') $upSeconds += $duration;
}
$state = (string) $check['status'];
$cursor = max($cursor, $checkTime);
}
if ($state !== null && $now > $cursor) {
$duration = min($now - $cursor, 120);
$knownSeconds += $duration;
if ($state === 'operational') $upSeconds += $duration;
}
$barsQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? ORDER BY checked_at DESC, id DESC LIMIT ' . $checks);
$barsQuery->execute([$componentId]);
$bars = array_reverse($barsQuery->fetchAll());
$bars = array_map(static fn(array $bar): array => [
'date' => date('d.m.Y H:i:s', strtotime((string) $bar['checked_at'])),
'status' => (string) $bar['status'],
], $bars);
while (count($bars) < $checks) array_unshift($bars, ['date' => '', 'status' => null]);
return [
'bars' => $bars,
'uptime' => $knownSeconds > 0 ? round($upSeconds / $knownSeconds * 100, 2) : null,
];
}
function status_set_component_category(PDO $pdo, int $id, ?int $categoryId, int $sort): bool
{
if ($categoryId !== null) {
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE id = ?');
$check->execute([$categoryId]);
if (!(int) $check->fetchColumn()) $categoryId = null;
}
$stmt = $pdo->prepare('UPDATE status_components SET category_id = ?, category = NULL, sort_order = ? WHERE id = ?');
$stmt->execute([$categoryId, $sort, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
function status_save_component(PDO $pdo, ?int $id, string $name, string $description, int $sort, ?int $categoryId = null): ?int
{
$name = trim(mb_substr($name, 0, 120));
if ($name === '') return null;
$description = trim(mb_substr($description, 0, 255));
if ($categoryId !== null) {
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE id = ?');
$check->execute([$categoryId]);
if (!(int) $check->fetchColumn()) $categoryId = null;
}
if ($id) {
$pdo->prepare('UPDATE status_components SET name = ?, description = ?, sort_order = ?, category_id = ?, category = NULL WHERE id = ?')->execute([$name, $description !== '' ? $description : null, $sort, $categoryId, $id]);
return $id;
}
$pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, category) VALUES (?,?,?,?,?,?,NULL)')->execute([$name, $description !== '' ? $description : null, 'operational', $sort, db_now(), $categoryId]);
$newId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)')->execute([$newId, 'operational', db_now()]);
return $newId;
}
function status_delete_component(PDO $pdo, int $id): void
{
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
$pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE component_id = ? AND cancelled_at IS NULL')->execute([db_now(), $id]);
$pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
}
/** Restore missing DeskHub core services without changing existing entries. */
function status_restore_core_components(PDO $pdo): int
{
$defaults = [
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']],
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
];
$find = $pdo->prepare('SELECT id, category_id FROM status_components WHERE LOWER(name) = LOWER(?) LIMIT 1');
$findCategory = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryIds = [];
$nextCategoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
foreach (array_unique(array_column($defaults, 1)) as $categoryName) {
$findCategory->execute([$categoryName]);
$categoryId = $findCategory->fetchColumn();
if (!$categoryId) {
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?, ?)')->execute([$categoryName, $nextCategoryOrder]);
$categoryId = (int) $pdo->lastInsertId();
$nextCategoryOrder += 10;
}
$categoryIds[$categoryName] = (int) $categoryId;
}
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn();
$restored = 0;
foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) {
$existing = null;
foreach (array_merge([$name], $aliases) as $candidate) {
$find->execute([$candidate]);
$row = $find->fetch(PDO::FETCH_ASSOC);
if ($row) { $existing = $row; break; }
}
$categoryId = $categoryIds[$categoryName] ?? null;
if ($existing) {
if (empty($existing['category_id']) && $categoryId) {
$pdo->prepare('UPDATE status_components SET category_id = ?, category = NULL WHERE id = ?')->execute([$categoryId, (int) $existing['id']]);
}
continue;
}
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
if ($id) {
status_set_component_monitor($pdo, $id, $monitorType, '');
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
$maxOrder += 10;
$restored++;
}
}
return $restored;
}
/**
* Neue Störungs- oder Wartungsmeldung. Mit $componentState wird die zugeordnete Komponente gleich auf diesen Zustand gesetzt.
* @return array{ok:bool,id?:int,error?:string}
*/
function status_create_incident(PDO $pdo, string $title, string $body, string $status, ?int $componentId, ?string $componentState, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel angeben.'];
if (!isset(status_incident_states()[$status])) $status = 'investigating';
if ($componentId !== null) {
$c = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ?');
$c->execute([$componentId]);
if (!(int) $c->fetchColumn()) $componentId = null;
}
$now = db_now();
$pdo->prepare('INSERT INTO status_incidents (title, body, status, impact, component_id, created_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$title, trim($body) !== '' ? trim($body) : null, $status, 'minor', $componentId, $staffId, $now, $now, $status === 'resolved' ? $now : null]);
$id = (int) $pdo->lastInsertId();
if (trim($body) !== '') {
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$id, $status, trim($body), $staffId, $now]);
}
if ($componentId !== null && $componentState !== null && isset(status_component_states()[$componentState])) status_set_component($pdo, $componentId, $componentState);
return ['ok' => true, 'id' => $id];
}
/**
* Update zu einer Meldung. Wird sie als „Behoben“ markiert, setzt die zugeordnete Komponente zurück auf „In Betrieb“, wenn $restore true ist.
* @return array{ok:bool,error?:string}
*/
function status_update_incident(PDO $pdo, int $id, string $status, string $body, ?int $staffId, bool $restore = true): array
{
if (!isset(status_incident_states()[$status])) return ['ok' => false, 'error' => 'Unbekannter Status.'];
$s = $pdo->prepare('SELECT * FROM status_incidents WHERE id = ?');
$s->execute([$id]);
$inc = $s->fetch();
if (!$inc) return ['ok' => false, 'error' => 'Meldung nicht gefunden.'];
$now = db_now();
$pdo->prepare('UPDATE status_incidents SET status = ?, updated_at = ?, resolved_at = ? WHERE id = ?')
->execute([$status, $now, $status === 'resolved' ? ($inc['resolved_at'] ?: $now) : null, $id]);
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')
->execute([$id, $status, trim(mb_substr($body, 0, 4000)), $staffId, $now]);
if ($status === 'resolved' && $restore && $inc['component_id']) {
// Nur zurücksetzen, wenn keine andere offene Meldung dieselbe Komponente betrifft
$o = $pdo->prepare("SELECT COUNT(*) FROM status_incidents WHERE component_id = ? AND status <> 'resolved' AND id <> ?");
$o->execute([$inc['component_id'], $id]);
if (!(int) $o->fetchColumn()) status_set_component($pdo, (int) $inc['component_id'], 'operational');
}
return ['ok' => true];
}
function status_delete_incident(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM status_incident_updates WHERE incident_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_incidents WHERE id = ?')->execute([$id]);
}
/** Zusammenfassung für die Apps. */
function status_summary(PDO $pdo): array
{
$states = status_component_states();
$overall = status_overall($pdo);
return [
'enabled' => status_page_enabled(),
'title' => (string) get_setting('status_page_title', 'Systemstatus'),
'url' => base_url() . '/status.php',
'overall' => $overall,
'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states),
'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()),
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], status_components($pdo)),
'incidents' => array_map(fn($i) => [
'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'],
'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null,
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
], status_incidents($pdo, 14)),
'maintenanceWindows' => array_map(static fn(array $w): array => [
'id' => (int) $w['id'], 'title' => (string) $w['title'], 'body' => (string) ($w['body'] ?? ''),
'component' => (string) ($w['component_name'] ?? ''), 'startsAt' => (string) $w['starts_at'], 'endsAt' => (string) $w['ends_at'],
], status_maintenance_windows($pdo)),
];
}