$hosts Hostnamen der Website, der bevorzugte zuerst (die Adresse der Website selbst) * @param array $known schon bekannte Kennungen je Hostname (null: dort nicht überwacht) * @param ?string $dashboardUrl Adresse des Dashboards (siehe dashboardUrl()); null: kein Link * @param ?array $previous der bisher gespeicherte Stand: Antwortet eine Domain gerade nicht, * bleibt ihr Eintrag daraus stehen, statt die übrigen mitzureißen * @return array{fetched_at: int, domains: list>} */ public function run(array $hosts, int $now, array $known = [], ?string $dashboardUrl = null, ?array $previous = null): array { $missing = array_values(array_filter($hosts, static fn (string $host) => ! array_key_exists($host, $known))); $ids = []; foreach ($hosts as $host) { if (isset($known[$host])) { $ids[$host] = $known[$host]; } } foreach ($missing === [] ? [] : $this->api->findDomains($missing) as $host => $domain) { $ids[$host] = $domain['id']; } $before = []; foreach (is_array($previous['domains'] ?? null) ? $previous['domains'] : [] as $entry) { if (is_array($entry) && is_string($entry['domain_id'] ?? null)) { $before[$entry['domain_id']] = $entry; } } $domains = []; $failure = null; $answered = 0; // Reihenfolge der Hostnamen beibehalten: die Domain der Website selbst steht oben foreach ($hosts as $host) { $id = $ids[$host] ?? null; if ($id === null || isset($domains[$id])) { continue; } try { $entry = $this->domain($id, $host, $hosts, $ids, $now, $dashboardUrl); $answered++; } catch (TransportException $e) { // Eine Domain, die gerade nicht antwortet, soll die übrigen nicht mitreißen: Sie behält ihren // letzten Eintrag, die anderen bekommen neue Zahlen. Ohne alten Eintrag fehlt sie diesmal. $failure = $e; $entry = $before[$id] ?? null; } if ($entry !== null) { $domains[$id] = $entry; } } // Antwortet gar nichts, ist das kein Stand, sondern ein Ausfall: dann lieber den alten ganz stehen lassen if ($failure !== null && $answered === 0) { throw $failure; } return ['fetched_at' => $now, 'domains' => array_values($domains)]; } /** * Der Eintrag einer Domain, frisch geholt; null, wenn DomainWarn sie nicht mehr kennt (gelöscht). * * @param list $hosts * @param array $ids * @return ?array */ private function domain(string $id, string $host, array $hosts, array $ids, int $now, ?string $dashboardUrl): ?array { $detail = $this->api->domain($id); if ($detail === null) { return null; } $own = array_values(array_filter($hosts, static fn (string $h) => ($ids[$h] ?? null) === $id)); $http = self::monitor($detail['monitors'] ?? null, 'http', $own); $month = $http !== null ? $this->api->uptime($http, '30d') : null; $week = $http !== null ? $this->api->uptime($http, '7d') : null; $incidents = self::int($detail['open_incidents_count'] ?? null); if ($incidents === null) { // eine ältere Installation zählt die Vorfälle in der Detailantwort noch nicht mit $incidents = count($this->api->openIncidents($id, $host)); } // ?org= wählt im Dashboard die Organisation des Tokens, falls der Nutzer dort mehrere hat $link = $dashboardUrl !== null ? $dashboardUrl.'/domains/'.rawurlencode($id).'?'.http_build_query(['org' => $this->api->organization()]) : null; return self::entry($id, $detail, $own, $month, $week, $incidents, $now, $link); } /** * Ist der gespeicherte Stand jünger als $maxAge? Ein kaputter oder fehlender Stand ist es nie. * * @param ?array $snapshot */ public static function fresh(?array $snapshot, int $now, int $maxAge = self::REFRESH_AFTER_SECONDS): bool { return is_array($snapshot) && is_int($snapshot['fetched_at'] ?? null) && is_array($snapshot['domains'] ?? null) && $now - $snapshot['fetched_at'] < $maxAge; } /** * Ganze Tage bis zu einem Zeitpunkt (ISO 8601), abgerundet; negativ, wenn er vorbei ist. Die Ansicht rechnet * damit beim Aufruf neu, statt die gespeicherte Zahl zu zeigen: Steht WP-Cron eine Woche, stimmte die sonst * nicht mehr. */ public static function daysLeft(?string $moment, int $now): ?int { if ($moment === null || $moment === '') { return null; } $time = strtotime($moment); if ($time === false) { return null; } return (int) floor(($time - $now) / 86400); } /** * Adresse des Dashboards, ohne Schrägstrich am Ende. Sie kommt aus dem Stand der gehosteten Funktionen * (SiteHosting::snapshot), wenn es ihn gibt: Dort nennt DomainWarn die eigenen Dashboard-Links * (/domains//hosting), und das stimmt auch für eine eigene Installation mit beliebigem Host. * Sonst wird sie aus der API-Adresse abgeleitet wie bei „Mit DomainWarn verbinden“: aus api.domainwarn.com wird * app.domainwarn.com, eine andere API-Adresse behält ihren Host. Eine eigene Abfrage von GET /meta dafür * wäre genauer, kostete aber eine Anfrage mehr für etwas, das in aller Regel schon bekannt ist. * * @param ?array $hostingSnapshot */ public static function dashboardUrl(string $apiBaseUrl, ?array $hostingSnapshot = null): ?string { foreach (is_array($hostingSnapshot['domains'] ?? null) ? $hostingSnapshot['domains'] : [] as $domain) { $link = is_array($domain) ? self::text($domain['dashboard_url'] ?? null) : null; if ($link !== null && preg_match('~^(https?://[^/\s"\'<>?#]+(?:/[^\s"\'<>?#]*?)?)/domains/[^/]+/hosting(?:/[a-z-]+)?$~i', $link, $m) === 1) { return $m[1]; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- rahmenfrei, ohne WordPress prüfbar; das Ergebnis wird unten streng geprüft. $parts = parse_url(trim($apiBaseUrl)); if (! is_array($parts) || ! isset($parts['host'])) { return null; } $scheme = strtolower((string) ($parts['scheme'] ?? 'https')); $host = strtolower($parts['host']); if (! in_array($scheme, ['https', 'http'], true) || preg_match('/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/', $host) !== 1) { return null; } $host = str_starts_with($host, 'api.') ? 'app.'.substr($host, 4) : $host; return $scheme.'://'.$host.(isset($parts['port']) ? ':'.(int) $parts['port'] : ''); } /** * Ein Eintrag des Stands, auf das Gezeigte reduziert. * * @param array $detail Antwort von GET …/domains/{id} * @param list $hosts Hostnamen der Website unter dieser Domain * @param ?array $month Antwort von GET …/uptime?range=30d * @param ?array $week Antwort von GET …/uptime?range=7d * @param ?string $link Seite der Domain im Dashboard * @return array */ private static function entry(string $id, array $detail, array $hosts, ?array $month, ?array $week, int $incidents, int $now, ?string $link): array { $name = self::text($detail['name'] ?? null) ?? ($hosts[0] ?? $id); $health = self::text($detail['health_status'] ?? null); $tls = self::monitorPayload($detail['monitors'] ?? null, 'tls', $hosts); $certificate = is_array($tls['certificate'] ?? null) ? self::text($tls['certificate']['valid_to'] ?? null) : null; // .de und andere Endungen ohne öffentliches Ablaufdatum: DomainWarn sagt es mit expiry_available $expires = ($detail['expiry_available'] ?? true) !== false ? self::text($detail['expires_at'] ?? null) : null; $ratio = self::number($month['summary']['uptime_ratio'] ?? null); $latency = self::int($week['summary']['latency_p50'] ?? null) ?? self::int($month['summary']['latency_p50'] ?? null); return [ 'name' => $name, 'unicode_name' => self::text($detail['unicode_name'] ?? null) ?? $name, 'domain_id' => $id, 'health' => in_array($health, self::HEALTH, true) ? $health : 'unknown', 'paused' => ($detail['is_paused'] ?? false) === true, 'uptime_30d' => $ratio !== null && $ratio >= 0 && $ratio <= 1 ? $ratio : null, 'downtime_seconds_30d' => is_array($month) ? self::int($month['summary']['downtime_seconds'] ?? null) : null, 'latency_p50_ms' => $latency, 'latency_series' => self::series($week['points'] ?? null), 'certificate_valid_to' => $certificate, 'certificate_days_left' => self::daysLeft($certificate, $now), 'domain_expires_at' => $expires, 'domain_days_left' => self::daysLeft($expires, $now), 'open_incidents' => max(0, $incidents), 'dashboard_url' => $link, ]; } /** * Kennung der Prüfung dieses Typs, die zur Website gehört: die, deren Ziel einen Hostnamen der Website trifft * (in der Reihenfolge der Hostnamen), sonst die erste eingeschaltete, sonst die erste überhaupt. * * @param list $hosts */ private static function monitor(mixed $monitors, string $type, array $hosts): ?string { $found = self::pick($monitors, $type, $hosts); return $found !== null ? self::text($found['id'] ?? null) : null; } /** * Letztes Ergebnis der passenden Prüfung dieses Typs, oder null. * * @param list $hosts * @return ?array */ private static function monitorPayload(mixed $monitors, string $type, array $hosts): ?array { $found = self::pick($monitors, $type, $hosts); return $found !== null && is_array($found['last_payload'] ?? null) ? $found['last_payload'] : null; } /** * @param list $hosts * @return ?array */ private static function pick(mixed $monitors, string $type, array $hosts): ?array { $candidates = []; foreach (is_array($monitors) ? $monitors : [] as $monitor) { if (is_array($monitor) && ($monitor['type'] ?? null) === $type && self::text($monitor['id'] ?? null) !== null) { $candidates[] = $monitor; } } foreach ($hosts as $host) { foreach ($candidates as $monitor) { if (self::targetHost($monitor['target'] ?? null) === $host) { return $monitor; } } } foreach ($candidates as $monitor) { if (($monitor['is_enabled'] ?? true) !== false) { return $monitor; } } return $candidates[0] ?? null; } /** Hostname im Ziel einer Prüfung: bei HTTP eine Adresse (https://kunde.de/), bei TLS der Name selbst. */ private static function targetHost(mixed $target): ?string { $target = self::text($target); if ($target === null) { return null; } if (preg_match('~^[a-z][a-z0-9+.-]*://([^/:?#]+)~i', $target, $m) === 1) { return strtolower(rtrim($m[1], '.')); } return strtolower(rtrim(explode(':', $target, 2)[0], '.')); } /** * Mittlere Antwortzeit je Stunde (Median der Stunde) für die Kurve, höchstens SERIES_MAX Werte, die jüngsten. * Eine Stunde ohne Messung bleibt null – die Kurve zeigt dort eine Lücke statt einer erfundenen Null. * * @return list */ private static function series(mixed $points): array { $out = []; foreach (is_array($points) ? $points : [] as $point) { if (is_array($point)) { $out[] = self::int($point['latency_p50'] ?? null); } } return array_slice($out, -self::SERIES_MAX); } /** Ganze Zahl ab null; JSON liefert Zahlen auch als Text, wenn die Datenbank sie so hergibt. */ private static function int(mixed $value): ?int { if (is_int($value)) { return $value >= 0 ? $value : null; } if (is_float($value) || (is_string($value) && is_numeric($value))) { $value = (float) $value; return is_finite($value) && $value >= 0 ? (int) round($value) : null; } return null; } private static function number(mixed $value): ?float { if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { $value = (float) $value; return is_finite($value) ? $value : null; } return null; } private static function text(mixed $value): ?string { return is_string($value) && $value !== '' && strlen($value) <= 2048 ? $value : null; } }