bmc_hub/app/modules/drift/templates/drift.html

622 lines
28 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Drift - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Drift</h2>
<p class="text-muted mb-0">Operations Center med aktive alarmer og historik.</p>
</div>
<button class="btn btn-outline-primary" onclick="window.location.reload()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="text-muted small">Aktive alarmer</div>
<div class="display-6 fw-bold" id="drift-active-alerts">0</div>
</div>
</div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="text-muted small">Kritiske</div>
<div class="display-6 fw-bold text-danger" id="drift-critical">0</div>
</div>
</div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="text-muted small">Warnings</div>
<div class="display-6 fw-bold text-warning" id="drift-warnings">0</div>
</div>
</div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="text-muted small">Løst i dag</div>
<div class="display-6 fw-bold text-success" id="drift-resolved-today">0</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Bulk Mapping</h5>
<div class="small text-muted">Par monitorer til Hub-kunder via dropdown i stedet for customer_id.</div>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadBulkMappings()">
<i class="bi bi-diagram-3 me-1"></i>Indlæs unmapped monitorer
</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Monitor</th>
<th>Kilde</th>
<th>Foreslået kunde</th>
<th>Hub-kunde</th>
<th></th>
</tr>
</thead>
<tbody id="drift-bulk-map-body">
<tr><td colspan="5" class="text-muted">Klik "Indlæs unmapped monitorer".</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="fw-semibold">UISP Health</div>
<span class="badge text-bg-secondary" id="drift-uisp-configured">Ikke konfigureret</span>
<span class="small text-muted">Aktive: <strong id="drift-uisp-active">0</strong></span>
<span class="small text-muted">Total: <strong id="drift-uisp-total">0</strong></span>
<span class="small text-muted">Sidste sync: <strong id="drift-uisp-last-sync">-</strong></span>
<button class="btn btn-sm btn-outline-primary ms-auto" onclick="applyUispActiveFilter()">
<i class="bi bi-filter me-1"></i>Vis kun UISP aktive
</button>
</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<select class="form-select form-select-sm w-auto" id="drift-status-filter">
<option value="">Status: Alle</option>
<option value="active">Aktiv</option>
2026-07-03 20:20:22 +02:00
<option value="acknowledged">Godkendt</option>
<option value="resolved">Løst</option>
</select>
<select class="form-select form-select-sm w-auto" id="drift-severity-filter">
<option value="">Severity: Alle</option>
<option value="critical" selected>Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select class="form-select form-select-sm w-auto" id="drift-source-filter">
<option value="">Kilde: Alle</option>
<option value="uptime kuma">Uptime Kuma</option>
<option value="uisp">UISP</option>
</select>
<button class="btn btn-sm btn-outline-primary" onclick="loadDriftEvents()">
<i class="bi bi-funnel me-1"></i>Opdater filtre
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Status</th>
<th>Severity</th>
<th>Kilde</th>
<th>Kunde</th>
<th>Device</th>
2026-07-03 20:20:22 +02:00
<th>IP</th>
<th>Site / Client</th>
<th>Start</th>
<th>Last seen</th>
<th>Varighed</th>
<th>Sag</th>
</tr>
</thead>
<tbody id="drift-events-body">
2026-07-03 20:20:22 +02:00
<tr><td colspan="11" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body">
<h5 class="mb-3">Historik</h5>
<div id="drift-history-list" class="vstack gap-2"></div>
</div>
</div>
</div>
<div class="modal fade" id="driftMapModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Knyt monitor til kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="small text-muted mb-2" id="driftMapModalMonitorText">Monitor</div>
<input type="hidden" id="driftMapMonitorKey">
<input type="hidden" id="driftMapMonitorName">
2026-07-03 20:20:22 +02:00
<input type="hidden" id="driftMapSource" value="uptime-kuma">
<label class="form-label">Vælg kunde</label>
<select class="form-select" id="driftMapCustomerSelect"></select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveMapFromModal()">Gem mapping</button>
</div>
</div>
</div>
</div>
<script>
let driftCustomersCache = [];
let driftBlacklistCache = [];
function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return [];
let values = [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
values = parsed;
} else if (typeof parsed === 'string') {
values = [parsed];
}
} catch (e) {
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
}
const seen = new Set();
const cleaned = [];
values.forEach(item => {
const normalized = String(item || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
cleaned.push(normalized);
});
return cleaned;
}
async function loadDriftBlacklist() {
try {
const res = await fetch('/api/v1/settings/drift_device_blacklist', { credentials: 'include' });
if (!res.ok) return driftBlacklistCache;
const payload = await res.json();
driftBlacklistCache = parseDriftBlacklistValue(payload?.value || '[]');
} catch (e) {
console.warn('Kunne ikke hente drift blacklist:', e);
}
return driftBlacklistCache;
}
function isDriftEventBlacklisted(event, blacklist) {
if (!Array.isArray(blacklist) || !blacklist.length) return false;
const tokens = new Set();
const add = (value) => {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) return;
tokens.add(normalized);
};
const sourceEventId = String(event?.source_event_id || '').trim().toLowerCase();
add(sourceEventId);
if (sourceEventId.startsWith('uisp-')) add(sourceEventId.slice(5));
if (sourceEventId.startsWith('kuma-')) add(sourceEventId.slice(5));
add(event?.device);
const rawJson = (event && typeof event.raw_json === 'object' && event.raw_json) ? event.raw_json : {};
const rawItem = (rawJson && typeof rawJson.raw_item === 'object' && rawJson.raw_item) ? rawJson.raw_item : {};
const identification = (rawItem && typeof rawItem.identification === 'object' && rawItem.identification) ? rawItem.identification : {};
add(identification.id);
add(identification.name);
add(identification.hostname);
add(identification.mac);
return blacklist.some(item => tokens.has(String(item || '').trim().toLowerCase()));
}
function syncBottomDriftCountFromVisibleEvents(events) {
const activeStatuses = new Set(['active', 'warning', 'critical', 'new']);
const activeCount = (Array.isArray(events) ? events : []).filter(event => {
const status = String((event && event.status) || '').trim().toLowerCase();
return activeStatuses.has(status);
}).length;
const bubble = document.querySelector('.bb-chip[data-bb-key="drift"] .bb-chip-bubble');
const text = document.querySelector('.bb-chip[data-bb-key="drift"] .bb-chip-text');
const chip = document.querySelector('.bb-chip[data-bb-key="drift"]');
if (bubble) bubble.textContent = String(activeCount);
if (text) text.textContent = `Drift: ${activeCount}`;
if (chip) {
chip.classList.toggle('has-items', activeCount > 0);
chip.classList.remove('sev-ok', 'sev-warn', 'sev-critical');
chip.classList.add(activeCount > 0 ? 'sev-critical' : 'sev-ok');
chip.setAttribute('title', `Drift alerts: ${activeCount}`);
chip.setAttribute('aria-label', `Drift alerts: ${activeCount}`);
}
window.dispatchEvent(new CustomEvent('bb:setDriftCount', {
detail: { count: activeCount }
}));
}
async function loadDriftCustomers() {
if (driftCustomersCache.length) return driftCustomersCache;
const res = await fetch('/api/v1/drift/customers-lite', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
driftCustomersCache = Array.isArray(payload) ? payload : [];
return driftCustomersCache;
}
function renderCustomerSelectOptions(selectEl, selectedCustomerId) {
if (!selectEl) return;
const options = ['<option value="">Vælg kunde...</option>']
.concat((driftCustomersCache || []).map(c => {
const selected = Number(selectedCustomerId || 0) === Number(c.id || 0) ? 'selected' : '';
return `<option value="${Number(c.id || 0)}" ${selected}>${(c.name || '').replace(/</g, '&lt;')}</option>`;
}));
selectEl.innerHTML = options.join('');
}
async function loadDriftSummary() {
try {
const res = await fetch('/api/v1/drift/summary', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const blacklist = await loadDriftBlacklist();
const fetchVisibleEvents = async (params) => {
const query = new URLSearchParams(params || {});
query.set('limit', '200');
const eventsRes = await fetch(`/api/v1/drift/events?${query.toString()}`, { credentials: 'include' });
if (!eventsRes.ok) return [];
const payload = await eventsRes.json();
const events = Array.isArray(payload) ? payload : [];
return events.filter(event => !isDriftEventBlacklisted(event, blacklist));
};
const [activeEvents, uispEvents] = await Promise.all([
fetchVisibleEvents({ status: 'active' }),
fetchVisibleEvents({ source: 'uisp' }),
]);
const criticalCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'critical').length;
const warningCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'warning').length;
const uispActiveCount = uispEvents.filter(event => String(event?.status || '').toLowerCase() === 'active').length;
document.getElementById('drift-active-alerts').textContent = activeEvents.length;
document.getElementById('drift-critical').textContent = criticalCount;
document.getElementById('drift-warnings').textContent = warningCount;
document.getElementById('drift-resolved-today').textContent = data.resolved_today ?? 0;
const uisp = data.uisp || {};
const configured = !!uisp.configured;
const configuredEl = document.getElementById('drift-uisp-configured');
configuredEl.textContent = configured ? 'Konfigureret' : 'Ikke konfigureret';
configuredEl.className = configured ? 'badge text-bg-success' : 'badge text-bg-secondary';
document.getElementById('drift-uisp-active').textContent = uispActiveCount;
document.getElementById('drift-uisp-total').textContent = uispEvents.length;
document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync
? new Date(uisp.last_sync).toLocaleString('da-DK')
: '-';
} catch (e) {
console.error(e);
document.getElementById('drift-active-alerts').textContent = '—';
document.getElementById('drift-critical').textContent = '—';
document.getElementById('drift-warnings').textContent = '—';
document.getElementById('drift-resolved-today').textContent = '—';
document.getElementById('drift-uisp-configured').textContent = 'Fejl';
document.getElementById('drift-uisp-configured').className = 'badge text-bg-danger';
document.getElementById('drift-uisp-active').textContent = '—';
document.getElementById('drift-uisp-total').textContent = '—';
document.getElementById('drift-uisp-last-sync').textContent = '—';
}
}
async function loadDriftEvents() {
const status = document.getElementById('drift-status-filter').value;
const severity = document.getElementById('drift-severity-filter').value;
const source = document.getElementById('drift-source-filter').value;
const params = new URLSearchParams();
if (status) params.set('status', status);
if (severity) params.set('severity', severity);
if (source) params.set('source', source);
const body = document.getElementById('drift-events-body');
try {
const res = await fetch(`/api/v1/drift/events?${params.toString()}`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const events = await res.json();
const blacklist = await loadDriftBlacklist();
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
syncBottomDriftCountFromVisibleEvents(visibleEvents);
if (!visibleEvents.length) {
2026-07-03 20:20:22 +02:00
body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>';
return;
}
body.innerHTML = visibleEvents.map(event => `
<tr>
<td><span class="badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}">${event.status || 'new'}</span></td>
<td><span class="badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}">${event.severity || 'info'}</span></td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.source || 'Ukendt'}</span>
${event.source_link ? `<a class="btn btn-sm btn-outline-secondary" href="${event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn kilde"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.customer || '-'}</span>
2026-07-03 20:20:22 +02:00
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})' title="Knyt monitor til kunde">Knyt</button>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.device || '-'}</span>
${(event.device_link || event.source_link) ? `<a class="btn btn-sm btn-outline-secondary" href="${event.device_link || event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn enhed"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div>
</td>
2026-07-03 20:20:22 +02:00
<td>${event.ip || '-'}</td>
<td>${event.site_client_name || event.site || event.customer || '-'}</td>
<td>${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td>
<td>${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}</td>
<td>${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}</td>
<td>
<div class="d-flex align-items-center gap-2">
${event.status === 'active' ? `<button class="btn btn-sm btn-outline-success" onclick="acknowledgeDriftEvent(${event.id})">Godkend</button>` : ''}
<button class="btn btn-sm btn-outline-danger" onclick="blacklistDriftEvent(${event.id})" title="Blacklist device fremadrettet">
Blacklist
</button>
${event.ticket_id ? `<a href="/sag/${event.ticket_id}/v3" class="btn btn-sm btn-outline-secondary">Sag #${event.ticket_id}</a>` : '<button class="btn btn-sm btn-outline-primary" onclick="createDriftCase(' + event.id + ')">Opret sag</button>'}
</div>
</td>
</tr>
`).join('');
} catch (e) {
console.error(e);
syncBottomDriftCountFromVisibleEvents([]);
2026-07-03 20:20:22 +02:00
body.innerHTML = '<tr><td colspan="11" class="text-muted">Kunne ikke hente drift-data.</td></tr>';
}
}
function applyUispActiveFilter() {
document.getElementById('drift-source-filter').value = 'uisp';
document.getElementById('drift-status-filter').value = 'active';
document.getElementById('drift-severity-filter').value = '';
loadDriftEvents();
}
async function acknowledgeDriftEvent(eventId) {
const res = await fetch(`/api/v1/drift/events/${eventId}/acknowledge`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json().catch(() => ({}));
if (res.status === 404) {
alert('Godkend-endpoint mangler i den kørende backend. Genstart API og prøv igen.');
return;
}
if (!res.ok) {
alert(data.detail || 'Kunne ikke godkende alarm');
return;
}
2026-07-03 20:20:22 +02:00
const statusFilter = document.getElementById('drift-status-filter');
if (statusFilter && (statusFilter.value === '' || statusFilter.value === 'active')) {
statusFilter.value = 'acknowledged';
}
await loadDriftSummary();
await loadDriftEvents();
}
async function blacklistDriftEvent(eventId, deviceName = '') {
const label = (deviceName || '').trim() || `event #${eventId}`;
if (!confirm(`Blacklist ${label} for fremtidige Drift sync?`)) {
return;
}
const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(deviceName || '').trim().toLowerCase() })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke blacklist device');
return;
}
await loadDriftSummary();
await loadDriftEvents();
showNotification('Device blacklistet for fremtidige sync', 'success');
}
async function mapDriftCustomer(monitorKey, monitorName) {
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
2026-07-03 20:20:22 +02:00
const source = document.getElementById('driftMapSource')?.value || 'uptime-kuma';
if (!Number.isInteger(customerId) || customerId <= 0) {
alert('Vælg en kunde');
return false;
}
const res = await fetch('/api/v1/drift/customer-mappings', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
2026-07-03 20:20:22 +02:00
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke gemme mapping');
return false;
}
await loadDriftEvents();
await loadDriftSummary();
return true;
}
2026-07-03 20:20:22 +02:00
async function openMapCustomerModal(monitorKey, monitorName, source = 'uptime-kuma') {
await loadDriftCustomers();
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
document.getElementById('driftMapMonitorName').value = monitorName || '';
2026-07-03 20:20:22 +02:00
document.getElementById('driftMapSource').value = source || 'uptime-kuma';
document.getElementById('driftMapModalMonitorText').textContent = `Monitor: ${monitorName || monitorKey}`;
renderCustomerSelectOptions(document.getElementById('driftMapCustomerSelect'));
const modalEl = document.getElementById('driftMapModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
async function saveMapFromModal() {
const monitorKey = document.getElementById('driftMapMonitorKey').value;
const monitorName = document.getElementById('driftMapMonitorName').value;
const ok = await mapDriftCustomer(monitorKey, monitorName);
if (!ok) {
return;
}
const modalEl = document.getElementById('driftMapModal');
bootstrap.Modal.getOrCreateInstance(modalEl).hide();
alert('Mapping gemt');
}
function findSuggestedCustomerId(nameGuess) {
const guess = String(nameGuess || '').trim().toLowerCase();
if (!guess) return 0;
const direct = (driftCustomersCache || []).find(c => String(c.name || '').trim().toLowerCase() === guess);
if (direct) return Number(direct.id || 0);
const contains = (driftCustomersCache || []).find(c => guess.includes(String(c.name || '').trim().toLowerCase()));
return contains ? Number(contains.id || 0) : 0;
}
async function loadBulkMappings() {
const body = document.getElementById('drift-bulk-map-body');
body.innerHTML = '<tr><td colspan="5" class="text-muted">Indlæser...</td></tr>';
try {
await loadDriftCustomers();
const res = await fetch('/api/v1/drift/unmapped-monitors?limit=500', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const monitors = await res.json();
if (!Array.isArray(monitors) || monitors.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="text-success">Ingen unmapped monitorer fundet.</td></tr>';
return;
}
body.innerHTML = monitors.map((m, idx) => {
const selectId = `bulkMapCustomer_${idx}`;
const suggestedId = findSuggestedCustomerId(m.inferred_customer);
const options = ['<option value="">Vælg kunde...</option>']
.concat((driftCustomersCache || []).map(c => {
const selected = Number(c.id || 0) === Number(suggestedId || 0) ? 'selected' : '';
return `<option value="${Number(c.id || 0)}" ${selected}>${(c.name || '').replace(/</g, '&lt;')}</option>`;
})).join('');
return `
<tr>
<td>${m.monitor_name || m.monitor_key || '-'}</td>
<td>${m.monitor_url || m.monitor_hostname || '-'}</td>
<td>${m.inferred_customer || '-'}</td>
<td><select class="form-select form-select-sm" id="${selectId}">${options}</select></td>
<td>
<button class="btn btn-sm btn-primary" onclick='saveBulkMapping(${JSON.stringify(m.monitor_key || '')}, ${JSON.stringify(m.monitor_name || '')}, "${selectId}")'>Gem</button>
</td>
</tr>
`;
}).join('');
} catch (e) {
console.error(e);
body.innerHTML = '<tr><td colspan="5" class="text-danger">Kunne ikke indlæse bulk mapping-data.</td></tr>';
}
}
async function saveBulkMapping(monitorKey, monitorName, selectId) {
const customerId = Number(document.getElementById(selectId)?.value || 0);
if (!customerId) {
alert('Vælg en kunde først');
return;
}
const res = await fetch('/api/v1/drift/customer-mappings', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
2026-07-03 20:20:22 +02:00
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source: 'uptime-kuma' })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke gemme mapping');
return;
}
await loadBulkMappings();
await loadDriftEvents();
await loadDriftSummary();
}
async function createDriftCase(eventId) {
const res = await fetch(`/api/v1/drift/events/${eventId}/ticket`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (!res.ok) {
alert(data.detail || 'Kunne ikke oprette sag');
return;
}
window.location.href = `/sag/${data.sag_id}/v3`;
}
async function loadDriftHistory() {
const list = document.getElementById('drift-history-list');
try {
const res = await fetch('/api/v1/drift/events?limit=10', { credentials: 'include' });
const events = await res.json();
list.innerHTML = events.map(event => `
<div class="border rounded p-3">
<div class="fw-semibold">${event.started ? new Date(event.started).toLocaleString('da-DK') : 'Ukendt'} • ${event.device || 'Ukendt enhed'}</div>
<div class="small text-muted">${event.message || 'Ingen besked'}</div>
</div>
`).join('');
} catch (e) {
list.innerHTML = '<div class="text-muted">Kunne ikke indlæse historik.</div>';
}
}
window.addEventListener('DOMContentLoaded', () => {
loadDriftCustomers().catch(() => {});
loadDriftSummary();
loadDriftEvents();
loadDriftHistory();
});
</script>
{% endblock %}