Files
deskhub/DeskHub-Webseite/status.php
T
2026-10-26 21:19:21 +00:00

276 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* status.php – öffentliche Statusseite: Zustand der Dienste und aktuelle Meldungen.
* Wird im Mitarbeiterbereich unter „Statusseite“ gepflegt und nur gezeigt, wenn sie dort eingeschaltet ist.
*/
require_once __DIR__ . '/includes/bootstrap.php';
$pdo = get_db();
$activePage = 'status';
$pageTitle = (string) get_setting('status_page_title', 'Systemstatus');
if (!status_page_enabled()) {
http_response_code(404);
require __DIR__ . '/includes/header_customer.php';
echo '<h1>Seite nicht gefunden</h1><p class="muted">Die angeforderte Seite existiert nicht.</p><p><a href="/">Zurück zur Startseite</a></p>';
require __DIR__ . '/includes/footer_customer.php';
exit;
}
$states = status_component_states();
$overall = status_overall($pdo);
$components = status_components($pdo);
foreach ($components as &$component) {
$component['timeline'] = status_component_timeline($pdo, (int) $component['id'], 30);
}
unset($component);
$incidents = status_incidents($pdo, 14);
$maintenanceWindows = status_maintenance_windows($pdo);
$activeMaintenance = status_active_maintenance_map($pdo);
$open = array_values(array_filter($incidents, fn($i) => $i['status'] !== 'resolved'));
$past = array_values(array_filter($incidents, fn($i) => $i['status'] === 'resolved'));
$incStates = status_incident_states();
if (($_GET['live'] ?? '') === '1') {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
$liveComponents = [];
foreach ($components as $component) {
$componentStatus = (string) $component['status'];
if (($componentStatus === 'operational') && (isset($activeMaintenance[0]) || isset($activeMaintenance[(int) $component['id']]))) $componentStatus = 'maintenance';
$componentState = $states[$componentStatus] ?? $states['operational'];
$liveComponents[] = [
'id' => (int) $component['id'],
'status' => $componentStatus,
'statusLabel' => (string) $componentState[0],
'color' => (string) $componentState[1],
'uptime' => $component['timeline']['uptime'],
'bars' => array_map(static function (array $bar) use ($states): array {
$key = $bar['status'];
return [
'date' => (string) $bar['date'],
'status' => $key,
'label' => $key !== null ? (string) ($states[$key][0] ?? $key) : 'Keine Daten',
'color' => $key !== null ? (string) ($states[$key][1] ?? '#cbd5e1') : '#dfe4e8',
];
}, $component['timeline']['bars']),
];
}
$liveIncidents = [];
foreach ($open as $incident) {
$liveIncidents[] = [
'title' => (string) $incident['title'],
'statusLabel' => (string) ($incStates[$incident['status']] ?? $incident['status']),
'componentName' => (string) ($incident['component_name'] ?? ''),
'updates' => array_map(static fn(array $update): array => [
'statusLabel' => (string) ($incStates[$update['status']] ?? $update['status']),
'createdAt' => format_dt((string) $update['created_at']),
'body' => (string) ($update['body'] ?? ''),
], $incident['updates']),
];
}
$liveMaintenance = [];
foreach ($maintenanceWindows as $window) {
$liveMaintenance[] = [
'title' => (string) $window['title'], 'body' => (string) ($window['body'] ?? ''),
'componentName' => (string) ($window['component_name'] ?? ''),
'startsAt' => format_dt((string) $window['starts_at']), 'endsAt' => format_dt((string) $window['ends_at']),
'active' => strtotime((string) $window['starts_at']) <= time() && strtotime((string) $window['ends_at']) >= time(),
];
}
echo json_encode([
'updatedAt' => date('d.m.Y H:i:s'),
'overall' => $overall,
'components' => $liveComponents,
'incidents' => $liveIncidents,
'maintenanceWindows' => $liveMaintenance,
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
exit;
}
header('Cache-Control: no-cache, no-store, must-revalidate');
require __DIR__ . '/includes/header_customer.php';
?>
<div style="max-width:820px;margin:0 auto">
<h1><?= e($pageTitle) ?></h1>
<div id="status-live-overall" class="card" style="padding:18px 22px;margin:16px 0;border-left:6px solid <?= e($overall['color']) ?>">
<strong id="status-live-overall-label" style="font-size:1.2rem;color:<?= e($overall['color']) ?>"><?= e($overall['label']) ?></strong>
<div id="status-live-updated-at" class="muted" style="font-size:.82rem;margin-top:2px">Stand: <?= e(date("d.m.Y H:i:s")) ?> Uhr &middot; Live-Aktualisierung aktiv</div>
</div>
<div id="status-live-maintenance">
<?php foreach ($maintenanceWindows as $window): $isActiveWindow = strtotime((string) $window['starts_at']) <= time() && strtotime((string) $window['ends_at']) >= time(); ?>
<div class="card" style="padding:14px 18px;margin:0 0 12px;border-left:6px solid #2563eb">
<strong><?= $isActiveWindow ? 'Laufende Wartung' : 'Geplante Wartung' ?>: <?= e($window['title']) ?></strong>
<div class="muted" style="font-size:.85rem;margin-top:4px"><?= $isActiveWindow ? 'Läuft seit ' : 'Geplant von ' ?><?= e(format_dt((string) $window['starts_at'])) ?> bis <?= e(format_dt((string) $window['ends_at'])) ?></div>
<?php if (!empty($window['component_name'])): ?><div class="muted" style="font-size:.82rem">Betrifft: <?= e($window['component_name']) ?></div><?php else: ?><div class="muted" style="font-size:.82rem">Betrifft: gesamte Plattform</div><?php endif; ?>
<?php if (trim((string) ($window['body'] ?? '')) !== ''): ?><div style="margin-top:6px;white-space:pre-line"><?= e((string) $window['body']) ?></div><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div id="status-live-incidents">
<?php foreach ($open as $i): ?>
<div class="card" style="padding:16px 20px;margin-bottom:14px;border-left:6px solid #dc2626">
<strong><?= e($i['title']) ?></strong>
<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:2px 10px;font-size:.72rem;font-weight:700;margin-left:6px"><?= e($incStates[$i['status']] ?? $i['status']) ?></span>
<?php if ($i['component_name']): ?><div class="muted" style="font-size:.82rem">Betrifft: <?= e($i['component_name']) ?></div><?php endif; ?>
<?php foreach ($i['updates'] as $u): ?>
<div style="margin-top:10px;font-size:.9rem"><strong><?= e($incStates[$u['status']] ?? $u['status']) ?></strong> <span class="muted" style="font-size:.78rem">· <?= e(format_dt((string) $u['created_at'])) ?></span>
<?php if (trim((string) $u['body']) !== ''): ?><div><?= nl2br(e((string) $u['body'])) ?></div><?php endif; ?></div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php if (!$components): ?>
<div class="card" style="padding:18px 20px;margin:16px 0"><p class="muted" style="margin:0">Es sind noch keine Dienste eingetragen.</p></div>
<?php else: ?>
<?php $groups = []; foreach ($components as $c) $groups[trim((string) ($c['category_name'] ?? '')) ?: 'Weitere Dienste'][] = $c; ?>
<?php foreach ($groups as $category => $group): ?>
<h2 style="font-size:1.05rem;margin:22px 0 8px"><?= e($category) ?></h2>
<section class="card" style="padding:0 20px;margin:0 0 16px">
<?php foreach ($group as $groupIndex => $c): $componentStatus = (string) $c['status']; if ($componentStatus === 'operational' && (isset($activeMaintenance[0]) || isset($activeMaintenance[(int) $c['id']]))) $componentStatus = 'maintenance'; $st = $states[$componentStatus] ?? $states['operational']; ?>
<div data-status-component="<?= (int) $c['id'] ?>" style="padding:13px 0;<?= $groupIndex < count($group) - 1 ? 'border-bottom:1px solid var(--border,#e3e9ee)' : '' ?>">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<div><strong><?= e($c['name']) ?></strong><?php if (!empty($c['description'])): ?><div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div><?php endif; ?></div>
<span data-status-label style="color:<?= e($st[1]) ?>;font-weight:700;white-space:nowrap"><span data-status-dot style="display:inline-block;width:9px;height:9px;border-radius:50%;background:<?= e($st[1]) ?>;margin-right:6px"></span><span data-status-text><?= e($st[0]) ?></span></span>
</div>
<?php $timeline = $c['timeline']; ?>
<div style="display:flex;align-items:center;gap:14px;margin-top:12px">
<div data-status-bars role="img" aria-label="<?= e($c['name']) ?>: letzte automatische Pr&uuml;fungen" style="display:flex;flex:1;gap:3px;min-width:0">
<?php foreach ($timeline['bars'] as $bar): $barStatus = $bar['status']; $barColor = $barStatus !== null ? ($states[$barStatus][1] ?? '#cbd5e1') : '#dfe4e8'; $barLabel = $barStatus !== null ? ($states[$barStatus][0] ?? $barStatus) : 'Noch keine Verlaufsdaten'; ?>
<span title="<?= e($bar['date'] . ': ' . $barLabel) ?>" style="display:block;flex:1;min-width:2px;height:22px;border-radius:3px;background:<?= e($barColor) ?>"></span>
<?php endforeach; ?>
</div>
<span data-status-uptime title="Verf&uuml;gbarkeit der letzten 24 Stunden" style="min-width:112px;text-align:right;font-size:.82rem;font-weight:600;color:var(--text,#334155)"><?= $timeline['uptime'] === null ? 'Noch keine Pr&uuml;fungen' : e(number_format((float) $timeline['uptime'], 2, ',', '.') . ' %') ?></span>
</div>
<div class="muted" style="display:flex;justify-content:space-between;font-size:.72rem;margin-top:4px"><span>Letzte Pr&uuml;fungen</span><span>Jetzt</span></div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:6px;font-size:.7rem;color:var(--muted,#64748b)">
<?php foreach ($states as $stateKey => $stateMeta): ?><span><i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:<?= e($stateMeta[1]) ?>;margin-right:4px"></i><?= e($stateMeta[0]) ?></span><?php endforeach; ?>
<span><i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#dfe4e8;margin-right:4px"></i>Keine Daten</span>
</div>
</div>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
<?php endif; ?>
<h2 style="margin-top:28px">Vergangene Meldungen (letzte 14 Tage)</h2>
<?php if (!$past): ?><p class="muted">Keine Störungen in den letzten 14 Tagen.</p><?php endif; ?>
<?php foreach ($past as $i): ?>
<div style="padding:12px 0;border-top:1px solid var(--border,#e3e9ee)">
<strong><?= e($i['title']) ?></strong> <span class="muted" style="font-size:.8rem">· <?= e(format_dt((string) $i['created_at'])) ?> · behoben</span>
<?php foreach ($i['updates'] as $u): if (trim((string) $u['body']) === '') continue; ?>
<div style="font-size:.88rem;margin-top:4px"><?= nl2br(e((string) $u['body'])) ?></div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<script>
(() => {
const endpoint = new URL(window.location.href);
endpoint.searchParams.set('live', '1');
const overallCard = document.getElementById('status-live-overall');
const overallLabel = document.getElementById('status-live-overall-label');
const updatedAt = document.getElementById('status-live-updated-at');
const incidentList = document.getElementById('status-live-incidents');
const maintenanceList = document.getElementById('status-live-maintenance');
let polling = false;
function addText(parent, tag, text, className, style) {
const node = document.createElement(tag);
if (className) node.className = className;
if (style) node.style.cssText = style;
node.textContent = text || '';
parent.appendChild(node);
return node;
}
function renderIncidents(incidents) {
if (!incidentList) return;
incidentList.replaceChildren();
for (const incident of incidents || []) {
const card = document.createElement('div');
card.className = 'card';
card.style.cssText = 'padding:16px 20px;margin-bottom:14px;border-left:6px solid #dc2626';
addText(card, 'strong', incident.title);
addText(card, 'span', incident.statusLabel, '', 'background:#fef3c7;color:#92400e;border-radius:20px;padding:2px 10px;font-size:.72rem;font-weight:700;margin-left:6px');
if (incident.componentName) addText(card, 'div', 'Betrifft: ' + incident.componentName, 'muted', 'font-size:.82rem');
for (const update of incident.updates || []) {
const line = document.createElement('div');
line.style.cssText = 'margin-top:10px;font-size:.9rem';
addText(line, 'strong', update.statusLabel);
addText(line, 'span', ' - ' + update.createdAt, 'muted', 'font-size:.78rem');
if (update.body) addText(line, 'div', update.body, '', 'white-space:pre-line');
card.appendChild(line);
}
incidentList.appendChild(card);
}
}
function renderMaintenance(windows) {
if (!maintenanceList) return;
maintenanceList.replaceChildren();
for (const window of windows || []) {
const card = document.createElement('div');
card.className = 'card';
card.style.cssText = 'padding:14px 18px;margin:0 0 12px;border-left:6px solid #2563eb';
addText(card, 'strong', (window.active ? 'Laufende Wartung: ' : 'Geplante Wartung: ') + window.title);
addText(card, 'div', (window.active ? 'Endet ' : 'Geplant von ') + window.startsAt + ' bis ' + window.endsAt, 'muted', 'font-size:.85rem;margin-top:4px');
addText(card, 'div', 'Betrifft: ' + (window.componentName || 'gesamte Plattform'), 'muted', 'font-size:.82rem');
if (window.body) addText(card, 'div', window.body, '', 'margin-top:6px;white-space:pre-line');
maintenanceList.appendChild(card);
}
}
async function refreshStatus() {
if (polling || document.visibilityState === 'hidden') return;
polling = true;
try {
const response = await fetch(endpoint, {cache: 'no-store', headers: {'Accept': 'application/json'}});
if (!response.ok) return;
const data = await response.json();
if (overallCard && data.overall) {
overallCard.style.borderLeftColor = data.overall.color;
overallLabel.textContent = data.overall.label;
overallLabel.style.color = data.overall.color;
}
if (updatedAt) updatedAt.textContent = 'Stand: ' + data.updatedAt + ' Uhr - Live-Aktualisierung aktiv';
for (const component of data.components || []) {
const row = document.querySelector('[data-status-component="' + component.id + '"]');
if (!row) continue;
const label = row.querySelector('[data-status-label]');
const text = row.querySelector('[data-status-text]');
const dot = row.querySelector('[data-status-dot]');
if (label) label.style.color = component.color;
if (text) text.textContent = component.statusLabel;
if (dot) dot.style.backgroundColor = component.color;
const bars = row.querySelector('[data-status-bars]');
if (bars) {
bars.replaceChildren();
for (const bar of component.bars || []) {
const block = document.createElement('span');
block.title = (bar.date ? bar.date + ': ' : '') + bar.label;
block.style.cssText = 'display:block;flex:1;min-width:2px;height:22px;border-radius:3px;background:' + bar.color;
bars.appendChild(block);
}
}
const uptime = row.querySelector('[data-status-uptime]');
if (uptime) uptime.textContent = component.uptime === null ? 'Noch keine Pr\u00fcfungen' : Number(component.uptime).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' %';
}
renderIncidents(data.incidents);
renderMaintenance(data.maintenanceWindows);
} catch (_) {
// Bei einem voruebergehenden Netzfehler bleibt die zuletzt geladene Anzeige sichtbar.
} finally {
polling = false;
}
}
refreshStatus();
window.setInterval(refreshStatus, 15000);
})();
</script>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>