Upload via GUI (340 Dateien)
This commit is contained in:
@@ -27,6 +27,7 @@ require_once __DIR__ . '/i18n.php';
|
||||
require_once __DIR__ . '/csat.php';
|
||||
require_once __DIR__ . '/status_page.php';
|
||||
require_once __DIR__ . '/ticket_templates.php';
|
||||
require_once __DIR__ . '/problems.php';
|
||||
require_once __DIR__ . '/plugins.php';
|
||||
require_once __DIR__ . '/app_downloads.php';
|
||||
require_once __DIR__ . '/theme_catalog.php';
|
||||
|
||||
@@ -222,6 +222,201 @@ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentI
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_sla_is_paused_status(string $status): bool
|
||||
{
|
||||
return in_array($status, ['pending', 'on_hold'], true);
|
||||
}
|
||||
|
||||
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
|
||||
{
|
||||
try {
|
||||
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
|
||||
function ticket_sla_working_seconds_between(string $from, string $to): int
|
||||
{
|
||||
$start = strtotime($from); $end = strtotime($to);
|
||||
if ($start === false || $end === false || $end <= $start) return 0;
|
||||
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
|
||||
$pdo = get_db(); $hours = [];
|
||||
try {
|
||||
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
|
||||
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
|
||||
$zone = new DateTimeZone(date_default_timezone_get());
|
||||
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
|
||||
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
|
||||
$seconds = 0; $iterations = 0;
|
||||
while ($day <= $lastDay && $iterations++ < 36600) {
|
||||
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
|
||||
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
|
||||
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
|
||||
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
|
||||
$seconds += max(0, min($end, $close) - max($start, $open));
|
||||
}
|
||||
$day = $day->modify('+1 day');
|
||||
}
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
function ticket_sla_add_business_seconds(string $from, int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) return $from;
|
||||
$pdo = get_db(); $hours = [];
|
||||
try {
|
||||
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
|
||||
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
|
||||
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
|
||||
}
|
||||
$zone = new DateTimeZone(date_default_timezone_get());
|
||||
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
|
||||
while ($seconds > 0 && $iterations++ < 36600) {
|
||||
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
|
||||
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
|
||||
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
|
||||
}
|
||||
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
|
||||
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
|
||||
if ($cursor < $open) $cursor = $open;
|
||||
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
|
||||
$available = $close->getTimestamp() - $cursor->getTimestamp();
|
||||
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
|
||||
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
|
||||
}
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
|
||||
{
|
||||
if ($pauseSeconds <= 0) return $deadline;
|
||||
if (get_setting('business_hours_enabled', '0') === '1') {
|
||||
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
|
||||
}
|
||||
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
|
||||
$stmt->execute([$ticketId]);
|
||||
$started = null; $total = 0;
|
||||
foreach ($stmt->fetchAll() as $event) {
|
||||
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
|
||||
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
|
||||
}
|
||||
return $total;
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
|
||||
}
|
||||
|
||||
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
|
||||
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
if ($oldStatus === $newStatus) return;
|
||||
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
|
||||
if (!$wasPaused && $willPause) {
|
||||
$now = db_now();
|
||||
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
|
||||
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
|
||||
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
|
||||
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
|
||||
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
|
||||
}
|
||||
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$ticketId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($wasPaused && !$willPause) {
|
||||
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
|
||||
$stmt->execute([$ticketId]); $row = $stmt->fetch();
|
||||
if (!$row) return;
|
||||
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
|
||||
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
|
||||
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
|
||||
$updates = []; $args = [];
|
||||
if ($seconds > 0) {
|
||||
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
|
||||
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
|
||||
$breach->execute([$ticketId, $deadlineType]);
|
||||
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
|
||||
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
|
||||
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
try {
|
||||
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
|
||||
}
|
||||
|
||||
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
|
||||
{
|
||||
try {
|
||||
$ticketId = (int) $ticket['id'];
|
||||
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
|
||||
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
|
||||
$now = db_now();
|
||||
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
|
||||
}
|
||||
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
|
||||
}
|
||||
}
|
||||
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
|
||||
if (empty($ticket[$field])) continue;
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
|
||||
$check->execute([$ticketId, $type]);
|
||||
if (!(int) $check->fetchColumn()) {
|
||||
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
|
||||
} else {
|
||||
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
|
||||
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
|
||||
}
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
|
||||
{
|
||||
try {
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
|
||||
$check->execute([$ticketId, $deadlineType]);
|
||||
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
|
||||
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
|
||||
@@ -558,9 +753,39 @@ function ip_in_list(string $ip, array $list): bool
|
||||
}
|
||||
|
||||
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
|
||||
function cloudflare_proxy_ranges(): array
|
||||
{
|
||||
static $ranges = null;
|
||||
if ($ranges !== null) return $ranges;
|
||||
$ranges = [];
|
||||
try {
|
||||
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
|
||||
$active->execute(['cloudflare-protection']);
|
||||
if ((int) $active->fetchColumn() === 1) {
|
||||
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
|
||||
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
|
||||
}
|
||||
return $ranges;
|
||||
}
|
||||
|
||||
function trusted_proxy_ranges(): array
|
||||
{
|
||||
static $ranges = null;
|
||||
if ($ranges === null) {
|
||||
$ranges = array_values(array_unique(array_merge(
|
||||
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
|
||||
cloudflare_proxy_ranges()
|
||||
)));
|
||||
}
|
||||
return $ranges;
|
||||
}
|
||||
|
||||
function request_from_trusted_proxy(): bool
|
||||
{
|
||||
$list = (array) (config()['site']['trusted_proxies'] ?? []);
|
||||
$list = trusted_proxy_ranges();
|
||||
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
|
||||
}
|
||||
|
||||
@@ -580,7 +805,12 @@ function client_ip(): string
|
||||
{
|
||||
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
if (!request_from_trusted_proxy()) return $remote;
|
||||
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
|
||||
$cloudflareRanges = cloudflare_proxy_ranges();
|
||||
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
|
||||
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
|
||||
return $cloudflareIp;
|
||||
}
|
||||
$trusted = trusted_proxy_ranges();
|
||||
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
|
||||
foreach ($chain as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
|
||||
@@ -1513,7 +1743,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
|
||||
{
|
||||
if (empty($ruleResult['close'])) return false;
|
||||
$now = db_now();
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
|
||||
$statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
|
||||
$statusStmt->execute([$ticketId]);
|
||||
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
|
||||
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
|
||||
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -151,8 +151,9 @@ function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore =
|
||||
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
"UPDATE tickets SET is_spam=1, status='closed', resolution_code='spam', resolution_note='Als Spam markiert', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
)->execute([$now, $now, $ticket['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', $staffId);
|
||||
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
|
||||
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
|
||||
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/** Problem records group related tickets and retain root-cause documentation. */
|
||||
|
||||
function ticket_resolution_reasons(): array
|
||||
{
|
||||
return [
|
||||
'fixed' => 'Fehler behoben',
|
||||
'guidance' => 'Anleitung oder Beratung',
|
||||
'duplicate' => 'Doppelt / zusammengeführt',
|
||||
'not_reproducible' => 'Nicht reproduzierbar',
|
||||
'no_action' => 'Keine Maßnahme erforderlich',
|
||||
'spam' => 'Spam oder Sperre',
|
||||
'automated' => 'Automatisch geschlossen',
|
||||
'other' => 'Sonstiges',
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_problem_statuses(): array
|
||||
{
|
||||
return [
|
||||
'investigating' => 'Wird untersucht',
|
||||
'workaround' => 'Workaround verfügbar',
|
||||
'monitoring' => 'Wird beobachtet',
|
||||
'resolved' => 'Behoben',
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_problem_number(int $id): string
|
||||
{
|
||||
return 'PRB-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
function ticket_problems(PDO $pdo, bool $activeOnly = false): array
|
||||
{
|
||||
$where = $activeOnly ? "WHERE p.status <> 'resolved'" : '';
|
||||
return $pdo->query("SELECT p.*, s.name AS creator_name, (SELECT COUNT(*) FROM problem_tickets pt WHERE pt.problem_id = p.id) AS ticket_count FROM problems p LEFT JOIN staff s ON s.id = p.created_by $where ORDER BY CASE WHEN p.status = 'resolved' THEN 1 ELSE 0 END, p.updated_at DESC, p.id DESC LIMIT 300")->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_problem_get(PDO $pdo, int $id): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT p.*, s.name AS creator_name FROM problems p LEFT JOIN staff s ON s.id = p.created_by WHERE p.id = ?');
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
function ticket_problem_create(PDO $pdo, array $input, int $staffId): int
|
||||
{
|
||||
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
|
||||
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
|
||||
$status = (string) ($input['status'] ?? 'investigating');
|
||||
if (!isset(ticket_problem_statuses()[$status])) $status = 'investigating';
|
||||
$resolution = trim((string) ($input['resolution'] ?? ''));
|
||||
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
|
||||
$now = db_now();
|
||||
$stmt = $pdo->prepare('INSERT INTO problems (title, description, root_cause, workaround, resolution, status, created_by, updated_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
|
||||
$stmt->execute([
|
||||
$title,
|
||||
trim((string) ($input['description'] ?? '')) ?: null,
|
||||
trim((string) ($input['root_cause'] ?? '')) ?: null,
|
||||
trim((string) ($input['workaround'] ?? '')) ?: null,
|
||||
$resolution ?: null,
|
||||
$status, $staffId, $staffId, $now, $now, $status === 'resolved' ? $now : null,
|
||||
]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function ticket_problem_update(PDO $pdo, int $id, array $input, int $staffId): bool
|
||||
{
|
||||
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
|
||||
$status = (string) ($input['status'] ?? 'investigating');
|
||||
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
|
||||
if (!isset(ticket_problem_statuses()[$status])) throw new InvalidArgumentException('Der Problemstatus ist ungültig.');
|
||||
$resolution = trim((string) ($input['resolution'] ?? ''));
|
||||
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
|
||||
$current = ticket_problem_get($pdo, $id);
|
||||
if (!$current) return false;
|
||||
$resolvedAt = $status === 'resolved' ? ($current['resolved_at'] ?: db_now()) : null;
|
||||
$stmt = $pdo->prepare('UPDATE problems SET title = ?, description = ?, root_cause = ?, workaround = ?, resolution = ?, status = ?, updated_by = ?, updated_at = ?, resolved_at = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$title,
|
||||
trim((string) ($input['description'] ?? '')) ?: null,
|
||||
trim((string) ($input['root_cause'] ?? '')) ?: null,
|
||||
trim((string) ($input['workaround'] ?? '')) ?: null,
|
||||
$resolution ?: null,
|
||||
$status, $staffId, db_now(), $resolvedAt, $id,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ticket_problem_tickets(PDO $pdo, int $problemId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT t.id, t.ticket_number, t.subject, t.status, t.requester_name, t.created_at, s.name AS linked_by_name FROM problem_tickets pt JOIN tickets t ON t.id = pt.ticket_id LEFT JOIN staff s ON s.id = pt.linked_by WHERE pt.problem_id = ? ORDER BY pt.linked_at DESC, t.id DESC');
|
||||
$stmt->execute([$problemId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staffId): bool
|
||||
{
|
||||
if (!ticket_problem_get($pdo, $problemId)) return false;
|
||||
$ticket = $pdo->prepare('SELECT id FROM tickets WHERE id = ?');
|
||||
$ticket->execute([$ticketId]);
|
||||
if (!$ticket->fetchColumn()) return false;
|
||||
$insertIgnore = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||||
$stmt = $pdo->prepare("$insertIgnore INTO problem_tickets (problem_id, ticket_id, linked_by, linked_at) VALUES (?,?,?,?)");
|
||||
$stmt->execute([$problemId, $ticketId, $staffId, db_now()]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): void
|
||||
{
|
||||
$pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?')->execute([$problemId, $ticketId]);
|
||||
}
|
||||
|
||||
function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT p.* FROM problems p JOIN problem_tickets pt ON pt.problem_id = p.id WHERE pt.ticket_id = ? ORDER BY p.updated_at DESC, p.id DESC');
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_record_resolution(PDO $pdo, int $ticketId, string $reason, string $note = ''): bool
|
||||
{
|
||||
if (!isset(ticket_resolution_reasons()[$reason])) return false;
|
||||
$pdo->prepare('UPDATE tickets SET resolution_code = ?, resolution_note = ? WHERE id = ?')
|
||||
->execute([$reason, trim(mb_substr($note, 0, 2000)) ?: null, $ticketId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function report_dimensions(PDO $pdo): array
|
||||
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
|
||||
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
|
||||
'status' => ['Status', 't.status', ''],
|
||||
'resolution_code' => ['Abschlussgrund', "CASE t.resolution_code WHEN 'fixed' THEN 'Fehler behoben' WHEN 'guidance' THEN 'Anleitung oder Beratung' WHEN 'duplicate' THEN 'Doppelt / zusammengeführt' WHEN 'not_reproducible' THEN 'Nicht reproduzierbar' WHEN 'no_action' THEN 'Keine Maßnahme erforderlich' WHEN 'spam' THEN 'Spam oder Sperre' WHEN 'automated' THEN 'Automatisch geschlossen' WHEN 'other' THEN 'Sonstiges' ELSE 'Nicht erfasst' END", ''],
|
||||
'source' => ['Quelle', 't.source', ''],
|
||||
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
|
||||
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
|
||||
|
||||
@@ -51,7 +51,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
|
||||
if ($type === 'manual') return null;
|
||||
if ($type === 'auto') {
|
||||
if ($target !== '') return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
if ($target !== '') {
|
||||
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
|
||||
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
}
|
||||
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
|
||||
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
|
||||
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
|
||||
@@ -70,6 +73,7 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587);
|
||||
return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null;
|
||||
}
|
||||
if ($type === 'tcp' && $target !== '' && str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
|
||||
if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung'];
|
||||
return null;
|
||||
}
|
||||
@@ -117,9 +121,13 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
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);
|
||||
} elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
|
||||
$endpoint = $check['type'] === 'minecraft'
|
||||
? status_resolve_minecraft_target((string) $check['target'])
|
||||
: status_parse_host_port((string) $check['target']);
|
||||
if ($endpoint !== null) {
|
||||
$host = strpos($endpoint['host'], ':') !== false ? '[' . $endpoint['host'] . ']' : $endpoint['host'];
|
||||
$socket = @stream_socket_client('tcp://' . $host . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
|
||||
$ok = is_resource($socket);
|
||||
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
|
||||
} else $error = 'Ungültiges Prüfziel';
|
||||
@@ -147,7 +155,7 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
|
||||
}
|
||||
|
||||
function status_run_component_checks(PDO $pdo): int
|
||||
function status_run_component_checks(PDO $pdo, bool $force = false): int
|
||||
{
|
||||
$components = status_components($pdo);
|
||||
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
|
||||
@@ -156,6 +164,9 @@ function status_run_component_checks(PDO $pdo): int
|
||||
foreach ($components as $component) {
|
||||
$check = status_component_monitor_target($pdo, $component);
|
||||
if ($check === null) continue;
|
||||
$intervalMinutes = max(1, (int) ($component['check_interval_minutes'] ?? 1));
|
||||
$lastChecked = trim((string) ($component['last_checked_at'] ?? ''));
|
||||
if (!$force && $lastChecked !== '' && strtotime($lastChecked) > time() - $intervalMinutes * 60) continue;
|
||||
$result = status_execute_component_check($pdo, $check);
|
||||
$failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1;
|
||||
$update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]);
|
||||
@@ -186,7 +197,7 @@ function status_discover_and_check(PDO $pdo): array
|
||||
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);
|
||||
$checked = status_run_component_checks($pdo, true);
|
||||
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
|
||||
}
|
||||
|
||||
@@ -201,9 +212,10 @@ function status_incident_states(): array
|
||||
];
|
||||
}
|
||||
|
||||
function status_components(PDO $pdo): array
|
||||
function status_components(PDO $pdo, bool $includeDisabled = false): 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();
|
||||
$where = $includeDisabled ? '' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
|
||||
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
|
||||
}
|
||||
|
||||
function status_categories(PDO $pdo): array
|
||||
@@ -227,6 +239,69 @@ function status_monitor_types(): array
|
||||
];
|
||||
}
|
||||
|
||||
/** Parses hostname:port, IPv4:port, or [IPv6]:port for TCP service checks. */
|
||||
function status_parse_host_port(string $target): ?array
|
||||
{
|
||||
$target = trim($target);
|
||||
if (preg_match('/^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/D', $target, $m)) {
|
||||
if (filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) return null;
|
||||
$host = $m[1];
|
||||
$port = (int) $m[2];
|
||||
} elseif (preg_match('/^([A-Za-z0-9.-]+):(\d{1,5})$/D', $target, $m)) {
|
||||
$host = $m[1];
|
||||
$port = (int) $m[2];
|
||||
if (strlen($host) > 253) return null;
|
||||
foreach (explode('.', rtrim($host, '.')) as $label) {
|
||||
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if ($port < 1 || $port > 65535) return null;
|
||||
return ['host' => $host, 'port' => $port];
|
||||
}
|
||||
|
||||
/** Minecraft accepts a bare host and resolves its Java port through DNS SRV. */
|
||||
function status_parse_minecraft_target(string $target): ?array
|
||||
{
|
||||
$target = trim($target);
|
||||
$explicit = status_parse_host_port($target);
|
||||
if ($explicit !== null) return $explicit;
|
||||
|
||||
if (preg_match('/^\[([0-9a-fA-F:.]+)\]$/D', $target, $m)) {
|
||||
return filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
|
||||
? ['host' => $m[1], 'port' => null]
|
||||
: null;
|
||||
}
|
||||
if (filter_var($target, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return ['host' => $target, 'port' => null];
|
||||
if (strlen($target) > 253 || $target === '') return null;
|
||||
foreach (explode('.', rtrim($target, '.')) as $label) {
|
||||
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-' || !preg_match('/^[A-Za-z0-9-]+$/D', $label)) return null;
|
||||
}
|
||||
return ['host' => rtrim($target, '.'), 'port' => null];
|
||||
}
|
||||
|
||||
function status_resolve_minecraft_target(string $target): ?array
|
||||
{
|
||||
$endpoint = status_parse_minecraft_target($target);
|
||||
if ($endpoint === null || $endpoint['port'] !== null) return $endpoint;
|
||||
$host = $endpoint['host'];
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) return ['host' => $host, 'port' => 25565];
|
||||
|
||||
if (function_exists('dns_get_record') && defined('DNS_SRV')) {
|
||||
$records = @dns_get_record('_minecraft._tcp.' . $host, DNS_SRV);
|
||||
if (is_array($records) && $records) {
|
||||
usort($records, static fn(array $a, array $b): int => ((int) ($a['pri'] ?? 0)) <=> ((int) ($b['pri'] ?? 0)));
|
||||
foreach ($records as $record) {
|
||||
$srvHost = rtrim((string) ($record['target'] ?? ''), '.');
|
||||
$port = (int) ($record['port'] ?? 0);
|
||||
if ($srvHost !== '' && $port >= 1 && $port <= 65535 && status_parse_minecraft_target($srvHost) !== null) return ['host' => $srvHost, 'port' => $port];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['host' => $host, 'port' => 25565];
|
||||
}
|
||||
|
||||
/** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */
|
||||
function status_configured_mail_endpoints(PDO $pdo): array
|
||||
{
|
||||
@@ -291,14 +366,23 @@ function status_component_detected_targets(PDO $pdo, array $check): array
|
||||
return [(string) ($check['target'] ?? '')];
|
||||
}
|
||||
|
||||
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target): bool
|
||||
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target, int $intervalMinutes = 1): bool
|
||||
{
|
||||
if (!isset(status_monitor_types()[$type])) return false;
|
||||
if (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
|
||||
$target = trim(mb_substr($target, 0, 500));
|
||||
if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false;
|
||||
if ($type === 'tcp' && $target !== '' && !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]);
|
||||
if ($type === 'tcp' && $target !== '') {
|
||||
$componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE id = ?');
|
||||
$componentStmt->execute([$id]);
|
||||
$componentName = mb_strtolower((string) $componentStmt->fetchColumn());
|
||||
$validTarget = str_contains($componentName, 'minecraft')
|
||||
? status_parse_minecraft_target($target) !== null
|
||||
: status_parse_host_port($target) !== null;
|
||||
if (!$validTarget) return false;
|
||||
}
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, check_interval_minutes = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$type, $target !== '' ? $target : null, $intervalMinutes, $id]);
|
||||
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
@@ -335,17 +419,26 @@ function status_overall(PDO $pdo): array
|
||||
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();
|
||||
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)")->fetchColumn();
|
||||
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
|
||||
$activeMaintenance = status_active_maintenance_map($pdo);
|
||||
$hasActiveMaintenance = isset($activeMaintenance[0]);
|
||||
if (!$hasActiveMaintenance) {
|
||||
foreach (status_components($pdo) as $component) {
|
||||
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
|
||||
}
|
||||
}
|
||||
if ($hasActiveMaintenance && $states[$worst][2] < $states['maintenance'][2]) $worst = 'maintenance';
|
||||
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
|
||||
}
|
||||
|
||||
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
|
||||
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): array
|
||||
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
|
||||
{
|
||||
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||||
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
|
||||
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($onlyOpen ? [] : [$since]);
|
||||
$rows = $stmt->fetchAll();
|
||||
@@ -357,6 +450,59 @@ function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): arr
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Liefert geplante und aktuell laufende Wartungsfenster (oder die Admin-Historie). */
|
||||
function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): array
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
|
||||
$where = $includeHistory
|
||||
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
|
||||
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
|
||||
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
|
||||
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function status_create_maintenance_window(PDO $pdo, string $title, string $body, string $startsAt, string $endsAt, ?int $componentId, ?int $staffId): array
|
||||
{
|
||||
$title = trim(mb_substr($title, 0, 200));
|
||||
$body = trim(mb_substr($body, 0, 4000));
|
||||
$start = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $startsAt);
|
||||
$end = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $endsAt);
|
||||
$startErrors = DateTimeImmutable::getLastErrors();
|
||||
$endErrors = DateTimeImmutable::getLastErrors();
|
||||
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel für die Wartung angeben.'];
|
||||
if (!$start || !$end || ($startErrors && ($startErrors['warning_count'] || $startErrors['error_count'])) || ($endErrors && ($endErrors['warning_count'] || $endErrors['error_count']))) return ['ok' => false, 'error' => 'Bitte gültige Start- und Endzeiten angeben.'];
|
||||
if ($end <= $start || $end <= new DateTimeImmutable()) return ['ok' => false, 'error' => 'Die Endzeit muss nach der Startzeit und in der Zukunft liegen.'];
|
||||
if ($componentId !== null) {
|
||||
$component = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ? AND COALESCE(is_enabled, 1) = 1');
|
||||
$component->execute([$componentId]);
|
||||
if (!(int) $component->fetchColumn()) return ['ok' => false, 'error' => 'Der ausgewählte Dienst ist nicht verfügbar.'];
|
||||
}
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO status_maintenance_windows (title, body, component_id, starts_at, ends_at, created_by, created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$title, $body !== '' ? $body : null, $componentId, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $staffId, $now]);
|
||||
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
function status_cancel_maintenance_window(PDO $pdo, int $id): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL');
|
||||
$stmt->execute([db_now(), $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Aktive Fenster nach Dienst-ID; Schlüssel 0 steht für eine globale Wartung. */
|
||||
function status_active_maintenance_map(PDO $pdo): array
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stmt = $pdo->prepare('SELECT id, component_id FROM status_maintenance_windows WHERE cancelled_at IS NULL AND starts_at <= ? AND ends_at >= ?');
|
||||
$stmt->execute([$now, $now]);
|
||||
$active = [];
|
||||
foreach ($stmt->fetchAll() as $window) $active[(int) ($window['component_id'] ?? 0)] = true;
|
||||
return $active;
|
||||
}
|
||||
|
||||
function status_set_component(PDO $pdo, int $id, string $status): bool
|
||||
{
|
||||
if (!isset(status_component_states()[$status])) return false;
|
||||
@@ -381,6 +527,13 @@ function status_toggle_component_maintenance(PDO $pdo, int $id): bool
|
||||
return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance');
|
||||
}
|
||||
|
||||
function status_set_component_enabled(PDO $pdo, int $id, bool $enabled): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET is_enabled = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$enabled ? 1 : 0, $id]);
|
||||
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
/** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */
|
||||
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
|
||||
{
|
||||
@@ -463,6 +616,7 @@ function status_save_component(PDO $pdo, ?int $id, string $name, string $descrip
|
||||
function status_delete_component(PDO $pdo, int $id): void
|
||||
{
|
||||
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
|
||||
$pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE component_id = ? AND cancelled_at IS NULL')->execute([db_now(), $id]);
|
||||
$pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
@@ -474,8 +628,10 @@ function status_restore_core_components(PDO $pdo): int
|
||||
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
|
||||
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
|
||||
['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']],
|
||||
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
|
||||
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
|
||||
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
|
||||
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
|
||||
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
|
||||
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
|
||||
];
|
||||
@@ -512,6 +668,7 @@ function status_restore_core_components(PDO $pdo): int
|
||||
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
|
||||
if ($id) {
|
||||
status_set_component_monitor($pdo, $id, $monitorType, '');
|
||||
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
|
||||
$maxOrder += 10;
|
||||
$restored++;
|
||||
}
|
||||
@@ -594,5 +751,9 @@ function status_summary(PDO $pdo): array
|
||||
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
|
||||
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
|
||||
], status_incidents($pdo, 14)),
|
||||
'maintenanceWindows' => array_map(static fn(array $w): array => [
|
||||
'id' => (int) $w['id'], 'title' => (string) $w['title'], 'body' => (string) ($w['body'] ?? ''),
|
||||
'component' => (string) ($w['component_name'] ?? ''), 'startsAt' => (string) $w['starts_at'], 'endsAt' => (string) $w['ends_at'],
|
||||
], status_maintenance_windows($pdo)),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user