diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php index c3c8a81b..aae50a03 100644 --- a/DeskHub-Webseite/database/migrate.php +++ b/DeskHub-Webseite/database/migrate.php @@ -1542,6 +1542,255 @@ function run_migrations(PDO $pdo, bool $verbose = false): array } } + // Migration 0055: Status-Komponenten lassen sich für Anzeige und Prüfungen aktivieren/deaktivieren. + if (!isset($done['php_0055_status_component_enabled'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll(); + $hasEnabled = false; + foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_enabled') $hasEnabled = true; + if (!$hasEnabled) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_enabled TINYINT NOT NULL DEFAULT 1'); + $categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1'); + $categoryStmt->execute(['Apps']); + $gameCategoryId = $categoryStmt->fetchColumn(); + if (!$gameCategoryId) { + $categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10; + $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]); + $gameCategoryId = (int) $pdo->lastInsertId(); + } + $minecraftStmt = $pdo->query("SELECT id, category_id, category FROM status_components WHERE LOWER(name) LIKE '%minecraft%' ORDER BY id LIMIT 1"); + $minecraft = $minecraftStmt->fetch(PDO::FETCH_ASSOC); + if (!$minecraft) { + $sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10; + $insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)'); + $insert->execute(['Minecraft-Server', 'Minecraft Java – IP-Adresse oder Domain und Port eintragen.', 'operational', $sortOrder, db_now(), (int) $gameCategoryId, 'tcp']); + } elseif (empty($minecraft['category_id']) && trim((string) ($minecraft['category'] ?? '')) === '') { + $pdo->prepare('UPDATE status_components SET category_id = ? WHERE id = ?')->execute([(int) $gameCategoryId, (int) $minecraft['id']]); + } + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0055_status_component_enabled']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0055_status_component_enabled'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0055_status_component_enabled: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0055_status_component_enabled - ' . $e->getMessage(); + } + } + + // Migration 0056: Minecraft-Statusdienst der Kategorie „Apps“ zuordnen. + if (!isset($done['php_0056_minecraft_status_category'])) { + try { + $categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1'); + $categoryStmt->execute(['Apps']); + $gameCategoryId = $categoryStmt->fetchColumn(); + if (!$gameCategoryId) { + $categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10; + $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]); + $gameCategoryId = (int) $pdo->lastInsertId(); + } + $pdo->prepare("UPDATE status_components SET category_id = ? WHERE LOWER(name) LIKE '%minecraft%' AND category_id IS NULL AND (category IS NULL OR TRIM(category) = '')") + ->execute([(int) $gameCategoryId]); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0056_minecraft_status_category']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0056_minecraft_status_category'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0056_minecraft_status_category: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0056_minecraft_status_category - ' . $e->getMessage(); + } + } + + // Migration 0057: Minecraft-Dienst unter „Apps“ einsortieren. + if (!isset($done['php_0057_minecraft_status_apps_category'])) { + try { + $categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1'); + $categoryStmt->execute(['Apps']); + $appCategoryId = $categoryStmt->fetchColumn(); + if (!$appCategoryId) { + $categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10; + $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]); + $appCategoryId = (int) $pdo->lastInsertId(); + } + $pdo->prepare("UPDATE status_components SET category_id = ?, category = NULL WHERE LOWER(name) LIKE '%minecraft%'") + ->execute([(int) $appCategoryId]); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0057_minecraft_status_apps_category']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0057_minecraft_status_apps_category'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0057_minecraft_status_apps_category: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0057_minecraft_status_apps_category - ' . $e->getMessage(); + } + } + + // Migration 0059: Optionalen Backup-Server für die Statusseite anlegen. + if (!isset($done['php_0059_backup_server_status_component'])) { + try { + $categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1'); + $categoryStmt->execute(['Server & Datenbank']); + $categoryId = $categoryStmt->fetchColumn(); + if (!$categoryId) { + $categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10; + $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Server & Datenbank', $categoryOrder]); + $categoryId = (int) $pdo->lastInsertId(); + } + $existing = $pdo->query("SELECT id FROM status_components WHERE LOWER(name) IN ('backup-server', 'backup server', 'ftp-backup-server') LIMIT 1")->fetchColumn(); + if (!$existing) { + $sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10; + $insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)'); + $insert->execute(['Backup-Server', 'Optionaler externer Backup-Server; Prüfung mit Host und Port.', 'operational', $sortOrder, db_now(), (int) $categoryId, 'tcp']); + } + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0059_backup_server_status_component']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0059_backup_server_status_component'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0059_backup_server_status_component: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0059_backup_server_status_component - ' . $e->getMessage(); + } + } + + // Migration 0060: Geplante Wartungsfenster der Statusseite. + if (!isset($done['php_0060_status_maintenance_windows'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS status_maintenance_windows ( + id $ai, + title VARCHAR(200) NOT NULL, + body TEXT NULL, + component_id INT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + created_by INT NULL, + created_at DATETIME NOT NULL, + cancelled_at DATETIME NULL + )$eng"); + $pdo->exec('CREATE INDEX idx_status_maintenance_window_time ON status_maintenance_windows (starts_at, ends_at)'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0060_status_maintenance_windows']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0060_status_maintenance_windows'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0060_status_maintenance_windows: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0060_status_maintenance_windows - ' . $e->getMessage(); + } + } + + // Migration 0061: Prüfintervall je Statusdienst konfigurierbar machen. + if (!isset($done['php_0061_status_check_intervals'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll(); + $hasInterval = false; + foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'check_interval_minutes') $hasInterval = true; + if (!$hasInterval) $pdo->exec('ALTER TABLE status_components ADD COLUMN check_interval_minutes INT NOT NULL DEFAULT 1'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0061_status_check_intervals']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0061_status_check_intervals'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0061_status_check_intervals: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0061_status_check_intervals - ' . $e->getMessage(); + } + } + + // Migration 0063: SLA-Wartepausen und Ereignisverlauf. + if (!isset($done['php_0063_ticket_sla_pause_history'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll(); + $hasPauseColumn = false; + foreach ($cols as $col) if ((string) ($col['name'] ?? $col['Field'] ?? '') === 'sla_paused_at') $hasPauseColumn = true; + if (!$hasPauseColumn) $pdo->exec('ALTER TABLE tickets ADD COLUMN sla_paused_at DATETIME NULL'); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS ticket_sla_history ( + id $ai, ticket_id INT NOT NULL, event_type VARCHAR(24) NOT NULL, + deadline_type VARCHAR(24) NULL, details TEXT NULL, staff_id INT NULL, created_at DATETIME NOT NULL + )$eng"); + $indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(ticket_sla_history)' : 'SHOW INDEX FROM ticket_sla_history')->fetchAll(); + $hasIndex = false; + foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_ticket_sla_history_ticket_time') $hasIndex = true; + if (!$hasIndex) $pdo->exec('CREATE INDEX idx_ticket_sla_history_ticket_time ON ticket_sla_history (ticket_id, created_at)'); + foreach ([['first_response_due_at', 'first_response'], ['resolution_due_at', 'resolution']] as [$dueColumn, $type]) { + $sql = "INSERT INTO ticket_sla_history (ticket_id, event_type, deadline_type, details, created_at) + SELECT t.id, 'started', ?, ?, t.created_at FROM tickets t + WHERE t.$dueColumn IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id = t.id AND h.event_type = 'started' AND h.deadline_type = ? + )"; + $pdo->prepare($sql)->execute([$type, 'SLA gestartet; Fristfeld: ' . $dueColumn, $type]); + } + $now = db_now(); + $pdo->prepare("UPDATE tickets SET sla_paused_at = ? WHERE sla_paused_at IS NULL AND status IN ('pending','on_hold')")->execute([$now]); + $pdo->prepare("INSERT INTO ticket_sla_history (ticket_id, event_type, details, created_at) + SELECT id, 'paused', 'SLA-Pause bei Aktivierung uebernommen', ? FROM tickets + WHERE status IN ('pending','on_hold') AND sla_paused_at = ?")->execute([$now, $now]); + $pdo->exec("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at) + SELECT t.id,'overdue','resolution',NULL,t.resolution_due_at FROM tickets t + WHERE t.is_overdue = 1 AND t.resolution_due_at IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='resolution' + )"); + $backfillResponse = $pdo->prepare("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at) + SELECT t.id,'overdue','first_response',NULL,t.first_response_due_at FROM tickets t + WHERE t.first_responded_at IS NULL AND t.first_response_due_at IS NOT NULL AND t.first_response_due_at < ? + AND t.status NOT IN ('pending','on_hold','resolved','closed') AND NOT EXISTS ( + SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='first_response' + )"); + $backfillResponse->execute([$now]); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0063_ticket_sla_pause_history']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0063_ticket_sla_pause_history'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0063_ticket_sla_pause_history: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0063_ticket_sla_pause_history - ' . $e->getMessage(); + } + } + + // Migration 0062: Problemmanagement und dokumentierte Ticket-Abschlussgründe. + if (!isset($done['php_0062_problems_and_resolution_codes'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $ticketCols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll(); + $has = []; + foreach ($ticketCols as $col) $has[] = (string) ($col['name'] ?? $col['Field'] ?? ''); + if (!in_array('resolution_code', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_code VARCHAR(40) NULL'); + if (!in_array('resolution_note', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_note TEXT NULL'); + $pdo->exec("CREATE TABLE IF NOT EXISTS problems ( + id $ai, + title VARCHAR(200) NOT NULL, + description TEXT NULL, + root_cause TEXT NULL, + workaround TEXT NULL, + resolution TEXT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'investigating', + created_by INT NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + resolved_at DATETIME NULL + )$eng"); + $pdo->exec("CREATE TABLE IF NOT EXISTS problem_tickets ( + problem_id INT NOT NULL, + ticket_id INT NOT NULL, + linked_by INT NULL, + linked_at DATETIME NOT NULL, + PRIMARY KEY (problem_id, ticket_id) + )$eng"); + $indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problem_tickets)' : 'SHOW INDEX FROM problem_tickets')->fetchAll(); + $ticketIndexExists = false; + foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problem_tickets_ticket') $ticketIndexExists = true; + if (!$ticketIndexExists) $pdo->exec('CREATE INDEX idx_problem_tickets_ticket ON problem_tickets (ticket_id)'); + $indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problems)' : 'SHOW INDEX FROM problems')->fetchAll(); + $problemIndexExists = false; + foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problems_status_updated') $problemIndexExists = true; + if (!$problemIndexExists) $pdo->exec('CREATE INDEX idx_problems_status_updated ON problems (status, updated_at)'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0062_problems_and_resolution_codes']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0062_problems_and_resolution_codes'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0062_problems_and_resolution_codes: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0062_problems_and_resolution_codes - ' . $e->getMessage(); + } + } + // Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit). if (!isset($done['php_0049_i18n_missing'])) { try { diff --git a/DeskHub-Webseite/docs/video/deskhub-demo.gif b/DeskHub-Webseite/docs/video/deskhub-demo.gif index 242af019..aa497790 100644 Binary files a/DeskHub-Webseite/docs/video/deskhub-demo.gif and b/DeskHub-Webseite/docs/video/deskhub-demo.gif differ