234 lines
13 KiB
PHP
234 lines
13 KiB
PHP
<?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);
|
||
$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) {
|
||
$componentState = $states[$component['status']] ?? $states['operational'];
|
||
$liveComponents[] = [
|
||
'id' => (int) $component['id'],
|
||
'status' => (string) $component['status'],
|
||
'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']),
|
||
];
|
||
}
|
||
echo json_encode([
|
||
'updatedAt' => date('d.m.Y H:i:s'),
|
||
'overall' => $overall,
|
||
'components' => $liveComponents,
|
||
'incidents' => $liveIncidents,
|
||
], 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 · Live-Aktualisierung aktiv</div>
|
||
</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): $st = $states[$c['status']] ?? $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ü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ü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ü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ü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');
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
} 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'; ?>
|