[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 !== '') 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 (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'; $connection = status_app_connection($pdo, $platform); $ok = $connection['online']; if (!$ok) $error = $connection['last_seen'] === null ? 'Noch keine Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android') . ' gemeldet' : 'Keine aktuelle Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android'); } 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 ($check['type'] === 'tcp') { if (preg_match('/^([a-zA-Z0-9.-]+):(\\d{1,5})$/', (string) $check['target'], $m) && (int) $m[2] > 0 && (int) $m[2] <= 65535) { $socket = @stream_socket_client('tcp://' . $m[1] . ':' . $m[2], $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): 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; $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); 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): array { 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 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', ]; } /** 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 ['Noch keine Verbindung von ' . $label . ' gemeldet']; $when = format_dt($connection['last_seen']); return [$connection['online'] ? 'Verbunden · letzter Kontakt ' . $when : 'Letzter Kontakt ' . $when . ' · älter als ' . (int) $connection['timeout_minutes'] . ' Minuten']; } 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): bool { if (!isset(status_monitor_types()[$type])) 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 !== '' && !preg_match('/^[a-zA-Z0-9.-]+:\\d{1,5}$/', $target)) return false; $stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?'); $stmt->execute([$type, $target !== '' ? $target : null, $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 WHERE status <> 'resolved'")->fetchColumn(); if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded'; 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): array { $since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400); $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 >= ?)") . ' 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; } 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'); } /** 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('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']], ['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']], ['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']], ['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, ''); $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)), ]; }