Upload via GUI (338 Dateien)
This commit is contained in:
@@ -1,79 +1,135 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Download der Windows- und Android-App für Mitarbeiter.
|
||||
*
|
||||
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
|
||||
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
|
||||
* an angemeldete Mitarbeiter.
|
||||
*/
|
||||
|
||||
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
|
||||
function app_platforms(): array
|
||||
{
|
||||
return [
|
||||
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
|
||||
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_downloads_dir(): string
|
||||
{
|
||||
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
|
||||
}
|
||||
|
||||
/** Legt den Ordner an und sperrt den Direktzugriff. */
|
||||
function app_downloads_prepare_dir(): bool
|
||||
{
|
||||
$dir = app_downloads_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
if (!is_file($htaccess)) {
|
||||
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
|
||||
}
|
||||
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
|
||||
return is_writable($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
|
||||
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
|
||||
* solange sich Änderungsdatum und Größe nicht ändern).
|
||||
*/
|
||||
function app_download_info(string $platform): ?array
|
||||
{
|
||||
$platforms = app_platforms();
|
||||
if (!isset($platforms[$platform])) return null;
|
||||
$p = $platforms[$platform];
|
||||
$path = app_downloads_dir() . '/' . $p['stored'];
|
||||
clearstatcache(true, $path);
|
||||
if (!is_file($path) || (int) filesize($path) < 1024) return null;
|
||||
$size = (int) filesize($path);
|
||||
$mtime = (int) filemtime($path);
|
||||
|
||||
$cacheKey = "app_{$platform}_hash_cache";
|
||||
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
|
||||
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
|
||||
$sha = $cached[2];
|
||||
} else {
|
||||
$sha = (string) hash_file('sha256', $path);
|
||||
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
|
||||
}
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'label' => $p['label'],
|
||||
'size' => $size,
|
||||
'sha256' => $sha,
|
||||
'modified_at' => date('Y-m-d H:i:s', $mtime),
|
||||
'path' => $path,
|
||||
'filename' => $p['download'],
|
||||
'mime' => $p['mime'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_format_size(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
|
||||
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
|
||||
}
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Download der Windows- und Android-App für Mitarbeiter.
|
||||
*
|
||||
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
|
||||
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
|
||||
* an angemeldete Mitarbeiter.
|
||||
*/
|
||||
|
||||
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
|
||||
function app_platforms(): array
|
||||
{
|
||||
return [
|
||||
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
|
||||
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
|
||||
'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_downloads_dir(): string
|
||||
{
|
||||
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
|
||||
}
|
||||
|
||||
/** Official publisher release endpoint; the access key stays on the server. */
|
||||
function app_publisher_endpoint(string $platform, bool $download = false): string
|
||||
{
|
||||
if (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !function_exists('catalog_defaults')) return '';
|
||||
$base = (string) (catalog_defaults()['plugins_url'] ?? '');
|
||||
$parts = parse_url($base);
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
$path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/');
|
||||
return 'https://' . $parts['host'] . ($parts['port'] ?? null ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?platform=' . rawurlencode($platform) . ($download ? '&download=1' : '');
|
||||
}
|
||||
|
||||
function app_publisher_request(string $platform, bool $download = false)
|
||||
{
|
||||
$url = app_publisher_endpoint($platform, $download);
|
||||
if ($url === '') return false;
|
||||
$headers = function_exists('catalog_access_headers') ? catalog_access_headers($url) : [];
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => $download ? 180 : 4, CURLOPT_HTTPHEADER => $headers]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return $code === 200 ? $body : false;
|
||||
}
|
||||
$ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => $download ? 180 : 4, 'ignore_errors' => true, 'header' => implode("\r\n", $headers)]]);
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
$status = $http_response_header[0] ?? '';
|
||||
return is_string($body) && str_contains($status, ' 200 ') ? $body : false;
|
||||
}
|
||||
|
||||
function app_publisher_info(string $platform): ?array
|
||||
{
|
||||
$cacheKey = 'app_publisher_' . $platform . '_cache';
|
||||
$cached = json_decode((string) get_setting($cacheKey, ''), true);
|
||||
if (is_array($cached) && (int) ($cached['at'] ?? 0) > time() - 60) return is_array($cached['info'] ?? null) ? $cached['info'] : null;
|
||||
$json = app_publisher_request($platform);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data) || empty($data['version']) || empty($data['sha256'])) {
|
||||
if (is_array($cached) && is_array($cached['info'] ?? null)) return $cached['info'];
|
||||
set_setting($cacheKey, json_encode(['at' => time(), 'info' => null]));
|
||||
return null;
|
||||
}
|
||||
$p = app_platforms()[$platform] ?? null;
|
||||
if (!$p) return null;
|
||||
$info = ['platform' => $platform, 'label' => $p['label'], 'size' => (int) ($data['size'] ?? 0), 'sha256' => (string) $data['sha256'], 'modified_at' => (string) ($data['created_at'] ?? ''), 'filename' => $p['download'], 'mime' => $p['mime'], 'version' => (string) $data['version'], 'notes' => (string) ($data['notes'] ?? ''), 'source' => 'publisher'];
|
||||
set_setting($cacheKey, json_encode(['at' => time(), 'info' => $info], JSON_UNESCAPED_UNICODE));
|
||||
return $info;
|
||||
}
|
||||
|
||||
/** Legt den Ordner an und sperrt den Direktzugriff. */
|
||||
function app_downloads_prepare_dir(): bool
|
||||
{
|
||||
$dir = app_downloads_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
if (!is_file($htaccess)) {
|
||||
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
|
||||
}
|
||||
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
|
||||
return is_writable($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
|
||||
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
|
||||
* solange sich Änderungsdatum und Größe nicht ändern).
|
||||
*/
|
||||
function app_download_info(string $platform): ?array
|
||||
{
|
||||
$platforms = app_platforms();
|
||||
if (!isset($platforms[$platform])) return null;
|
||||
$p = $platforms[$platform];
|
||||
$path = app_downloads_dir() . '/' . $p['stored'];
|
||||
clearstatcache(true, $path);
|
||||
if (!is_file($path) || (int) filesize($path) < 1024) return null;
|
||||
$size = (int) filesize($path);
|
||||
$mtime = (int) filemtime($path);
|
||||
|
||||
$cacheKey = "app_{$platform}_hash_cache";
|
||||
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
|
||||
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
|
||||
$sha = $cached[2];
|
||||
} else {
|
||||
$sha = (string) hash_file('sha256', $path);
|
||||
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
|
||||
}
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'label' => $p['label'],
|
||||
'size' => $size,
|
||||
'sha256' => $sha,
|
||||
'modified_at' => date('Y-m-d H:i:s', $mtime),
|
||||
'path' => $path,
|
||||
'filename' => $p['download'],
|
||||
'mime' => $p['mime'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_format_size(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
|
||||
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
|
||||
}
|
||||
|
||||
/** Minecraft-Plugin auf der Apps-Seite zeigen? Einstellung unter Einstellungen → System (Standard: aus). */
|
||||
function app_minecraft_enabled(): bool
|
||||
{
|
||||
return get_setting('minecraft_enabled', '0') === '1';
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ Deny from all
|
||||
|
||||
// Zeitlimit erhöhen für große Backups
|
||||
@set_time_limit(300);
|
||||
@ini_set('memory_limit', '256M');
|
||||
// Arbeitsspeicher nur erhöhen, nie senken (ein Limit von 512 MB oder mehr vom Hoster bleibt erhalten)
|
||||
$memBytes = (function (string $v): int { $n = (int) $v; return match (strtolower(substr(trim($v), -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n }; })((string) ini_get('memory_limit'));
|
||||
if ($memBytes >= 0 && $memBytes < 512 * 1048576 && $memBytes !== 0) { @ini_set('memory_limit', '512M'); }
|
||||
|
||||
// php_zip automatisch aktivieren falls nicht geladen
|
||||
if (!class_exists('ZipArchive')) {
|
||||
@@ -96,6 +98,10 @@ Deny from all
|
||||
|
||||
// Alle Dateien sammeln
|
||||
$exclude = [realpath($backupDir) ?: $backupDir];
|
||||
// Der Backup-Ordner des Servers (backups/) gehört NIE ins Backup, auch wenn das Ziel ein anderer Ordner ist (zum Beispiel ein Zwischenordner des
|
||||
// NAS-Plugins): sonst steckten alle älteren Backups noch einmal darin. Nur die .htaccess bleibt dabei, damit der Ordner nach einer
|
||||
// Wiederherstellung geschützt ist.
|
||||
$stdBackups = realpath($rootDir . '/backups') ?: null;
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
@@ -107,8 +113,12 @@ Deny from all
|
||||
$filePath = $file->getRealPath();
|
||||
$norm = str_replace('\\', '/', $filePath);
|
||||
$skip = false;
|
||||
foreach ($exclude as $ex) {
|
||||
if ($ex && strpos($filePath, $ex) === 0) { $skip = true; break; }
|
||||
if ($stdBackups !== null && strpos($filePath, $stdBackups . DIRECTORY_SEPARATOR) === 0) {
|
||||
if ($filePath !== $stdBackups . DIRECTORY_SEPARATOR . '.htaccess') $skip = true; // alles außer der .htaccess
|
||||
} else {
|
||||
foreach ($exclude as $ex) {
|
||||
if ($ex && ($filePath === $ex || strpos($filePath, rtrim($ex, '/' . chr(92)) . DIRECTORY_SEPARATOR) === 0)) { $skip = true; break; }
|
||||
}
|
||||
}
|
||||
foreach (backup_excluded_fragments() as $frag) {
|
||||
if (str_contains($norm, $frag)) { $skip = true; break; }
|
||||
@@ -205,8 +215,13 @@ Deny from all
|
||||
if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
return ['ok' => false, 'error' => 'Backup-ZIP konnte nicht erstellt werden.'];
|
||||
}
|
||||
$canStore = method_exists($zip, 'setCompressionName');
|
||||
foreach ($files as $filePath => $relativePath) {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
// Bereits komprimierte Dateien nur ablegen statt erneut zu packen: spart bei großen Uploads viel Rechenzeit
|
||||
if ($canStore && preg_match('/\.(zip|jpe?g|png|gif|webp|pdf|exe|apk|aab|jar|7z|gz|mp[34]|woff2?)$/i', $relativePath)) {
|
||||
@$zip->setCompressionName($relativePath, ZipArchive::CM_STORE);
|
||||
}
|
||||
}
|
||||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||||
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
|
||||
@@ -276,3 +291,19 @@ Deny from all
|
||||
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperre „es läuft gerade ein Backup“: höchstens ein Backup gleichzeitig (Cronjob, eingebauter Hintergrunddienst, Knopf auf der Seite, NAS-Plugin).
|
||||
* Atomar über die Einstellungstabelle; eine Sperre älter als $maxSeconds (abgestürzter Lauf) gilt als frei.
|
||||
*/
|
||||
function backup_lock_acquire(PDO $pdo, int $maxSeconds = 10800): bool
|
||||
{
|
||||
if (!function_exists('pseudo_cron_try_claim')) return true;
|
||||
return pseudo_cron_try_claim($pdo, 'backup_run_lock', $maxSeconds);
|
||||
}
|
||||
|
||||
function backup_lock_release(PDO $pdo): void
|
||||
{
|
||||
if (!function_exists('pseudo_cron_try_claim')) return;
|
||||
try { $pdo->prepare("UPDATE settings SET setting_value = '1970-01-01 00:00:00' WHERE setting_key = 'backup_run_lock'")->execute(); } catch (Throwable $e) { /* Sperre verfällt von selbst */ }
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ function crontab_marker_block(): string
|
||||
$lines = [
|
||||
DESKHUB_CRON_MARKER_START,
|
||||
"*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.log 2>&1",
|
||||
"* * * * * $php $root/cron/status_checks.php >> $root/cron/status_checks.log 2>&1",
|
||||
"* * * * * $php $root/cron/run_plugins.php >> $root/cron/run_plugins.log 2>&1",
|
||||
"*/15 * * * * $php $root/cron/check_sla.php >> $root/cron/check_sla.log 2>&1",
|
||||
"0 * * * * $php $root/cron/check_escalations.php >> $root/cron/check_escalations.log 2>&1",
|
||||
|
||||
@@ -1,155 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/pseudo_cron.php
|
||||
*
|
||||
* "Eingebauter" Ersatz für echte Betriebssystem-Cronjobs, für Hosting-Umgebungen
|
||||
* ohne Crontab-Zugriff (z.B. viele Shared-Hosting-Pakete). Funktioniert komplett
|
||||
* ohne externe Dienste: Bei jeder authentifizierten Anfrage im Mitarbeiterbereich
|
||||
* (Web-Backend UND mobile App) wird nach dem Ausliefern der Antwort im Hintergrund
|
||||
* geprüft, ob einer der 6 Cronjobs fällig ist – und falls ja, direkt ausgeführt.
|
||||
*
|
||||
* Ablauf, ohne die eigentliche Seite/den API-Request zu verlangsamen:
|
||||
* 1. pseudo_cron_schedule_background() wird beim Seiten-/API-Aufruf registriert.
|
||||
* 2. register_shutdown_function() sorgt dafür, dass der Code erst NACH dem Senden
|
||||
* der Antwort läuft. Wo verfügbar (PHP-FPM) beendet fastcgi_finish_request()
|
||||
* die Verbindung zum Browser sofort – der Nutzer merkt nichts von der Wartezeit.
|
||||
* 3. pseudo_cron_dispatch() prüft für jeden Job das letzte Ausführungsdatum
|
||||
* (Setting "cron_<job>_last_run") gegen sein empfohlenes Intervall und führt
|
||||
* ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"-
|
||||
* Button und der echte Cronjob.
|
||||
*
|
||||
* Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des
|
||||
* last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap").
|
||||
* Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job.
|
||||
*
|
||||
* Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"),
|
||||
* falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht
|
||||
* gebraucht wird.
|
||||
*/
|
||||
|
||||
/** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */
|
||||
function pseudo_cron_job_definitions(): array
|
||||
{
|
||||
return [
|
||||
'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
|
||||
'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
|
||||
'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
|
||||
'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
|
||||
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
|
||||
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
|
||||
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
|
||||
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
|
||||
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
|
||||
<?php
|
||||
/**
|
||||
* includes/pseudo_cron.php
|
||||
*
|
||||
* "Eingebauter" Ersatz für echte Betriebssystem-Cronjobs, für Hosting-Umgebungen
|
||||
* ohne Crontab-Zugriff (z.B. viele Shared-Hosting-Pakete). Funktioniert komplett
|
||||
* ohne externe Dienste: Bei jeder authentifizierten Anfrage im Mitarbeiterbereich
|
||||
* (Web-Backend UND mobile App) wird nach dem Ausliefern der Antwort im Hintergrund
|
||||
* geprüft, ob einer der 6 Cronjobs fällig ist – und falls ja, direkt ausgeführt.
|
||||
*
|
||||
* Ablauf, ohne die eigentliche Seite/den API-Request zu verlangsamen:
|
||||
* 1. pseudo_cron_schedule_background() wird beim Seiten-/API-Aufruf registriert.
|
||||
* 2. register_shutdown_function() sorgt dafür, dass der Code erst NACH dem Senden
|
||||
* der Antwort läuft. Wo verfügbar (PHP-FPM) beendet fastcgi_finish_request()
|
||||
* die Verbindung zum Browser sofort – der Nutzer merkt nichts von der Wartezeit.
|
||||
* 3. pseudo_cron_dispatch() prüft für jeden Job das letzte Ausführungsdatum
|
||||
* (Setting "cron_<job>_last_run") gegen sein empfohlenes Intervall und führt
|
||||
* ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"-
|
||||
* Button und der echte Cronjob.
|
||||
*
|
||||
* Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des
|
||||
* last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap").
|
||||
* Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job.
|
||||
*
|
||||
* Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"),
|
||||
* falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht
|
||||
* gebraucht wird.
|
||||
*/
|
||||
|
||||
/** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */
|
||||
function pseudo_cron_job_definitions(): array
|
||||
{
|
||||
return [
|
||||
'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
|
||||
'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
|
||||
'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
|
||||
'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
|
||||
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
|
||||
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
|
||||
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
|
||||
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
|
||||
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
|
||||
'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
|
||||
'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen:
|
||||
* Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf
|
||||
* "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei
|
||||
* gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE).
|
||||
*/
|
||||
function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool
|
||||
{
|
||||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
// Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig).
|
||||
if ($driver === 'sqlite') {
|
||||
$pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
|
||||
->execute([$settingKey]);
|
||||
} else {
|
||||
$pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
|
||||
->execute([$settingKey]);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$threshold = date('Y-m-d H:i:s', time() - $intervalSeconds);
|
||||
$stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?');
|
||||
$stmt->execute([$now, $settingKey, $threshold]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */
|
||||
function pseudo_cron_dispatch(): void
|
||||
{
|
||||
try {
|
||||
$pdo = get_db();
|
||||
if (get_setting('pseudo_cron_enabled', '1') !== '1') return;
|
||||
|
||||
// Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel
|
||||
// Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen.
|
||||
if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return;
|
||||
|
||||
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
|
||||
|
||||
foreach (pseudo_cron_job_definitions() as $key => $job) {
|
||||
if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue;
|
||||
try {
|
||||
require_once __DIR__ . '/../cron/' . $job['file'];
|
||||
$fn = $job['fn'];
|
||||
$fn($pdo, false);
|
||||
} catch (Throwable $e) {
|
||||
error_log("pseudo_cron [$key]: " . $e->getMessage());
|
||||
if (function_exists('error_alert_notice')) error_alert_notice("Hintergrundjob „{$key}“ ist fehlgeschlagen", "Der Hintergrundjob „{$key}“ hat einen Fehler gemeldet und wurde abgebrochen. Beim nächsten Intervall wird er erneut versucht.", 'cron/' . $job['file'], $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 5 Minuten)
|
||||
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280)) {
|
||||
catalog_refresh_background();
|
||||
}
|
||||
|
||||
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
|
||||
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
|
||||
try {
|
||||
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
|
||||
backup_verify_pending(2);
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
|
||||
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
|
||||
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
|
||||
try {
|
||||
finalize_stale_work_sessions($pdo, false);
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron_dispatch: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert die Hintergrundprüfung für das Ende des aktuellen Requests.
|
||||
* Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen –
|
||||
* läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass
|
||||
* jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss.
|
||||
*/
|
||||
function pseudo_cron_schedule_background(): void
|
||||
{
|
||||
static $scheduled = false;
|
||||
if ($scheduled) return; // pro Request nur einmal registrieren
|
||||
$scheduled = true;
|
||||
|
||||
if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig
|
||||
|
||||
register_shutdown_function(function () {
|
||||
// Verbindung zum Client möglichst sofort beenden, damit die Prüfung
|
||||
// die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt.
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
@fastcgi_finish_request();
|
||||
} else {
|
||||
if (function_exists('ob_get_level')) {
|
||||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||||
}
|
||||
@flush();
|
||||
}
|
||||
pseudo_cron_dispatch();
|
||||
});
|
||||
}
|
||||
'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'],
|
||||
'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen:
|
||||
* Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf
|
||||
* "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei
|
||||
* gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE).
|
||||
*/
|
||||
function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool
|
||||
{
|
||||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
// Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig).
|
||||
if ($driver === 'sqlite') {
|
||||
$pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
|
||||
->execute([$settingKey]);
|
||||
} else {
|
||||
$pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
|
||||
->execute([$settingKey]);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$threshold = date('Y-m-d H:i:s', time() - $intervalSeconds);
|
||||
$stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?');
|
||||
$stmt->execute([$now, $settingKey, $threshold]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */
|
||||
function pseudo_cron_dispatch(): void
|
||||
{
|
||||
try {
|
||||
$pdo = get_db();
|
||||
if (get_setting('pseudo_cron_enabled', '1') !== '1') return;
|
||||
|
||||
// Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel
|
||||
// Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen.
|
||||
if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return;
|
||||
|
||||
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
|
||||
|
||||
foreach (pseudo_cron_job_definitions() as $key => $job) {
|
||||
if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue;
|
||||
try {
|
||||
require_once __DIR__ . '/../cron/' . $job['file'];
|
||||
$fn = $job['fn'];
|
||||
$fn($pdo, false);
|
||||
} catch (Throwable $e) {
|
||||
error_log("pseudo_cron [$key]: " . $e->getMessage());
|
||||
if (function_exists('error_alert_notice')) error_alert_notice("Hintergrundjob „{$key}“ ist fehlgeschlagen", "Der Hintergrundjob „{$key}“ hat einen Fehler gemeldet und wurde abgebrochen. Beim nächsten Intervall wird er erneut versucht.", 'cron/' . $job['file'], $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 5 Minuten)
|
||||
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280)) {
|
||||
catalog_refresh_background();
|
||||
}
|
||||
|
||||
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
|
||||
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
|
||||
try {
|
||||
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
|
||||
backup_verify_pending(2);
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
|
||||
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
|
||||
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
|
||||
try {
|
||||
finalize_stale_work_sessions($pdo, false);
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('pseudo_cron_dispatch: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert die Hintergrundprüfung für das Ende des aktuellen Requests.
|
||||
* Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen –
|
||||
* läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass
|
||||
* jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss.
|
||||
*/
|
||||
function pseudo_cron_schedule_background(): void
|
||||
{
|
||||
static $scheduled = false;
|
||||
if ($scheduled) return; // pro Request nur einmal registrieren
|
||||
$scheduled = true;
|
||||
|
||||
if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig
|
||||
|
||||
register_shutdown_function(function () {
|
||||
// Verbindung zum Client möglichst sofort beenden, damit die Prüfung
|
||||
// die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt.
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
@fastcgi_finish_request();
|
||||
} else {
|
||||
if (function_exists('ob_get_level')) {
|
||||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||||
}
|
||||
@flush();
|
||||
}
|
||||
// Sitzungssperre freigeben: Sonst wartet die nächste Seite desselben Mitarbeiters, bis der Hintergrundjob (Backup, Mail-Abruf …) fertig ist
|
||||
if (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); }
|
||||
@ignore_user_abort(true);
|
||||
pseudo_cron_dispatch();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,175 +1,598 @@
|
||||
<?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],
|
||||
];
|
||||
}
|
||||
|
||||
/** 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 * FROM status_components ORDER BY sort_order, name')->fetchAll();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
$s = $pdo->prepare('UPDATE status_components SET status = ? WHERE id = ?');
|
||||
$s->execute([$status, $id]);
|
||||
return $s->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
|
||||
{
|
||||
$name = trim(mb_substr($name, 0, 120));
|
||||
if ($name === '') return null;
|
||||
$description = trim(mb_substr($description, 0, 255));
|
||||
if ($id) {
|
||||
$pdo->prepare('UPDATE status_components SET name = ?, description = ?, sort_order = ? WHERE id = ?')->execute([$name, $description !== '' ? $description : null, $sort, $id]);
|
||||
return $id;
|
||||
}
|
||||
$pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at) VALUES (?,?,?,?,?)')->execute([$name, $description !== '' ? $description : null, 'operational', $sort, db_now()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
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_components WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] ?? ''), '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)),
|
||||
];
|
||||
}
|
||||
<?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 !== '') 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)),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user