2026-06-26 14:15:46 +02:00
{% 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 >
2026-11-03 01:16:12 +01:00
< details class = "card shadow-sm border-0 mb-4" >
< summary class = "card-header bg-body border-0 py-3 px-4 fw-semibold" style = "cursor:pointer" >
< i class = "bi bi-gear me-2" > < / i > Opsætning, kundemapping og ignorerede enheder
< / summary >
< div class = "card-body border-top" >
2026-06-26 14:15:46 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< hr class = "my-4" >
< div class = "d-flex justify-content-between align-items-center mb-2" >
< div >
< h5 class = "mb-1" > Ignorerede enheder< / h5 >
< div class = "small text-muted" > Enhederne ignoreres ved alle fremtidige sync. De kan altid sættes under overvågning igen.< / div >
< / div >
< button class = "btn btn-sm btn-outline-primary" onclick = "loadIgnoreRules()" > < i class = "bi bi-arrow-clockwise me-1" > < / i > Opdater< / button >
< / div >
< div class = "table-responsive" >
< table class = "table table-sm align-middle mb-0" >
< thead > < tr > < th > Enhed< / th > < th > Kilde< / th > < th > Årsag< / th > < th > Ignoreret af< / th > < th > Dato< / th > < th > < / th > < / tr > < / thead >
< tbody id = "drift-ignore-rules-body" > < tr > < td colspan = "6" class = "text-muted" > Indlæser...< / td > < / tr > < / tbody >
< / table >
< / div >
2026-06-26 14:15:46 +02:00
< / div >
2026-11-03 01:16:12 +01:00
< / details >
2026-06-26 14:15:46 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< option value = "active" selected > Kræver handling< / option >
2026-07-03 20:20:22 +02:00
< option value = "acknowledged" > Godkendt< / option >
2026-06-26 14:15:46 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< option value = "critical" > Critical< / option >
2026-06-26 14:15:46 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< select class = "form-select form-select-sm w-auto" id = "drift-attention-filter" >
< option value = "" > Alle alarmer< / option >
< option value = "no_case" > Uden sag< / option >
< option value = "unknown_customer" > Ukendt kunde< / option >
< option value = "missing_ip" > Mangler IP< / option >
< / select >
< input class = "form-control form-control-sm" style = "max-width:240px" id = "drift-search-filter" placeholder = "Søg kunde, device, IP…" >
2026-06-26 14:15:46 +02:00
< button class = "btn btn-sm btn-outline-primary" onclick = "loadDriftEvents()" >
< i class = "bi bi-funnel me-1" > < / i > Opdater filtre
< / button >
2026-11-03 01:16:12 +01:00
< span class = "small text-muted align-self-center ms-auto" id = "drift-result-count" > < / span >
2026-06-26 14:15:46 +02:00
< / 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 >
2026-06-26 14:15:46 +02:00
< 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 >
2026-06-26 14:15:46 +02:00
< / 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 >
2026-11-03 01:16:12 +01:00
< div class = "modal fade" id = "driftIgnoreModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Ignorér enhed fremover< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Luk" > < / button >
< / div >
< div class = "modal-body" >
< p class = "mb-3" id = "driftIgnoreDeviceText" > < / p >
< input type = "hidden" id = "driftIgnoreEventId" >
< label class = "form-label" for = "driftIgnoreReason" > Årsag< / label >
< select class = "form-select" id = "driftIgnoreReason" >
< option value = "test" > Testudstyr< / option >
< option value = "retired" > Nedlagt udstyr< / option >
< option value = "unmanaged" > Ikke administreret af BMC< / option >
< option value = "noisy" > Støjende/irrelevant alarm< / option >
< option value = "other" > Andet< / option >
< / select >
< div class = "form-text" > Reglen bindes til enhedens stabile ID hos kilden, også hvis navnet senere ændres.< / div >
< / 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-danger" onclick = "confirmIgnoreDevice()" > Ignorér enhed fremover< / button >
< / div >
< / div >
< / div >
< / div >
2026-06-26 14:15:46 +02:00
< 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" >
2026-06-26 14:15:46 +02:00
< 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 = [];
2026-11-03 01:16:12 +01:00
function escapeDriftHtml(value) {
return String(value ?? '')
.replaceAll('& ', '& ').replaceAll('< ', '< ').replaceAll('>', '> ')
.replaceAll('"', '" ').replaceAll("'", '' ');
}
function driftReasonLabel(value) {
return ({ test: 'Testudstyr', retired: 'Nedlagt udstyr', unmanaged: 'Ikke administreret', noisy: 'Støjende alarm', other: 'Andet' })[value] || 'Andet';
}
function formatDriftDuration(totalMinutes) {
const minutes = Math.max(0, Number(totalMinutes || 0));
if (minutes < 60 ) return ` $ { minutes } min ` ;
const hours = Math.floor(minutes / 60);
if (hours < 48 ) return ` $ { hours } t $ { minutes % 60 } min ` ;
return `${Math.floor(hours / 24)} d ${hours % 24} t`;
}
2026-06-26 14:15:46 +02:00
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']);
2026-11-03 01:16:12 +01:00
const activeCount = typeof events === 'number' ? Math.max(0, events) : (Array.isArray(events) ? events : []).filter(event => {
2026-06-26 14:15:46 +02:00
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, '< ')}< / 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();
2026-11-03 01:16:12 +01:00
document.getElementById('drift-active-alerts').textContent = data.active_alerts ?? 0;
document.getElementById('drift-critical').textContent = data.critical ?? 0;
document.getElementById('drift-warnings').textContent = data.warnings ?? 0;
2026-06-26 14:15:46 +02:00
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';
2026-11-03 01:16:12 +01:00
document.getElementById('drift-uisp-active').textContent = uisp.active_events ?? 0;
document.getElementById('drift-uisp-total').textContent = uisp.total_events ?? 0;
2026-06-26 14:15:46 +02:00
document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync
? new Date(uisp.last_sync).toLocaleString('da-DK')
: '-';
2026-11-03 01:16:12 +01:00
syncBottomDriftCountFromVisibleEvents(Number(data.active_alerts || 0));
2026-06-26 14:15:46 +02:00
} 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();
2026-11-03 01:16:12 +01:00
const attention = document.getElementById('drift-attention-filter').value;
const search = document.getElementById('drift-search-filter').value.trim().toLowerCase();
const visibleEvents = (Array.isArray(events) ? events : [])
.filter(event => {
if (attention === 'no_case' & & event.ticket_id) return false;
if (attention === 'unknown_customer' & & event.customer & & event.customer !== 'UISP' & & event.customer !== 'Uptime Kuma') return false;
if (attention === 'missing_ip' & & event.ip) return false;
if (!search) return true;
return [event.customer, event.device, event.ip, event.site_client_name, event.source]
.some(value => String(value || '').toLowerCase().includes(search));
})
.sort((a, b) => {
const severity = { critical: 0, warning: 1, info: 2 };
const customerA = (!a.customer || a.customer === 'UISP' || a.customer === 'Uptime Kuma') ? '' : a.customer;
const customerB = (!b.customer || b.customer === 'UISP' || b.customer === 'Uptime Kuma') ? '' : b.customer;
const groupA = String(customerA || a.site_client_name || 'Ukendt kunde');
const groupB = String(customerB || b.site_client_name || 'Ukendt kunde');
return groupA.localeCompare(groupB, 'da')
|| (severity[String(a.severity || '').toLowerCase()] ?? 3) - (severity[String(b.severity || '').toLowerCase()] ?? 3)
|| Number(b.duration_minutes || 0) - Number(a.duration_minutes || 0);
});
document.getElementById('drift-result-count').textContent = `${visibleEvents.length} viste alarmer`;
2026-06-26 14:15:46 +02:00
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 > ';
2026-06-26 14:15:46 +02:00
return;
}
2026-11-03 01:16:12 +01:00
let previousGroup = '';
body.innerHTML = visibleEvents.map(event => {
const realCustomer = (!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '' : event.customer;
const group = realCustomer || event.site_client_name || 'Ukendt kunde';
const groupHeader = group !== previousGroup
? `< tr class = "table-light" > < td colspan = "11" class = "fw-semibold" > < i class = "bi bi-building me-2" > < / i > ${escapeDriftHtml(group)}< / td > < / tr > `
: '';
previousGroup = group;
const quality = [
(!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '< span class = "badge text-bg-warning" > Ukendt kunde< / span > ' : '',
!event.ip ? '< span class = "badge text-bg-secondary" > Mangler IP< / span > ' : '',
].filter(Boolean).join(' ');
return groupHeader + `
2026-06-26 14:15:46 +02:00
< tr >
2026-11-03 01:16:12 +01:00
< td > < span class = "badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}" > ${event.status === 'active' ? 'Kræver handling' : event.status === 'acknowledged' ? 'Set' : event.status === 'resolved' ? 'Løst' : escapeDriftHtml(event.status || 'Ny')}< / span > < / td >
< td > < span class = "badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}" > ${escapeDriftHtml(event.severity || 'info')}< / span > < / td >
2026-06-26 14:15:46 +02:00
< td >
< div class = "d-flex align-items-center gap-2" >
2026-11-03 01:16:12 +01:00
< span > ${escapeDriftHtml(event.source || 'Ukendt')}< / span >
2026-06-26 14:15:46 +02:00
${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" >
2026-11-03 01:16:12 +01:00
< span > ${escapeDriftHtml(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 > ` : ''}
2026-06-26 14:15:46 +02:00
< / div >
< / td >
< td >
< div class = "d-flex align-items-center gap-2" >
2026-11-03 01:16:12 +01:00
< span > ${escapeDriftHtml(event.device || '-')}< / span >
2026-06-26 14:15:46 +02:00
${(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-11-03 01:16:12 +01:00
< td > ${escapeDriftHtml(event.ip || '-')}< div class = "mt-1" > ${quality}< / div > < / td >
< td > ${escapeDriftHtml(event.site_client_name || event.site || event.customer || '-')}< / td >
2026-06-26 14:15:46 +02:00
< td > ${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}< / td >
< td > ${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}< / td >
2026-11-03 01:16:12 +01:00
< td > ${formatDriftDuration(event.duration_minutes)}< / td >
2026-06-26 14:15:46 +02:00
< td >
2026-11-03 01:16:12 +01:00
< div class = "d-flex align-items-center gap-1 flex-nowrap" >
${event.ticket_id ? `< a href = "/sag/${Number(event.ticket_id)}/v3" class = "btn btn-sm btn-primary" > Åbn sag< / a > ` : `< button class = "btn btn-sm btn-primary" onclick = "createDriftCase(${Number(event.id)})" > Opret sag< / button > `}
< div class = "dropdown" >
< button class = "btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle = "dropdown" aria-label = "Flere handlinger" > Mere< / button >
< ul class = "dropdown-menu dropdown-menu-end" >
${event.status === 'active' ? `< li > < button class = "dropdown-item" onclick = "acknowledgeDriftEvent(${Number(event.id)})" > < i class = "bi bi-check2 me-2" > < / i > Markér som set< / button > < / li > ` : ''}
${event.monitor_key ? `< li > < button class = "dropdown-item" onclick = 'openMapCustomerModal(${JSON.stringify(event.monitor_key || ' ' ) } , $ { JSON . stringify ( event . device | | ' ' ) } , $ { JSON . stringify ( String ( event . source_event_id | | ' ' ) . startsWith ( ' uisp- ' ) ? ' uisp ' : ' uptime-kuma ' ) } ) ' > < i class = "bi bi-link-45deg me-2" > < / i > Knyt kunde< / button > < / li > ` : ''}
< li > < button class = "dropdown-item text-danger" onclick = 'openIgnoreDeviceModal(${Number(event.id)}, ${JSON.stringify(event.device || ' Ukendt enhed ' ) } , $ { JSON . stringify ( event . source | | ' Ukendt kilde ' ) } ) ' > < i class = "bi bi-eye-slash me-2" > < / i > Ignorér enhed fremover< / button > < / li >
< / ul >
< / div >
2026-06-26 14:15:46 +02:00
< / div >
< / td >
< / tr >
2026-11-03 01:16:12 +01:00
`}).join('');
2026-06-26 14:15:46 +02:00
} catch (e) {
console.error(e);
2026-07-03 20:20:22 +02:00
body.innerHTML = '< tr > < td colspan = "11" class = "text-muted" > Kunne ikke hente drift-data.< / td > < / tr > ';
2026-06-26 14:15:46 +02:00
}
}
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';
}
2026-06-26 14:15:46 +02:00
await loadDriftSummary();
await loadDriftEvents();
}
2026-11-03 01:16:12 +01:00
function openIgnoreDeviceModal(eventId, deviceName, sourceName) {
document.getElementById('driftIgnoreEventId').value = Number(eventId || 0);
document.getElementById('driftIgnoreDeviceText').textContent = `${deviceName || 'Ukendt enhed'} fra ${sourceName || 'ukendt kilde'} fjernes fra nuværende og fremtidige Drift-alarmer.`;
document.getElementById('driftIgnoreReason').value = 'noisy';
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).show();
}
2026-06-26 14:15:46 +02:00
2026-11-03 01:16:12 +01:00
async function confirmIgnoreDevice() {
const eventId = Number(document.getElementById('driftIgnoreEventId').value || 0);
const reason = document.getElementById('driftIgnoreReason').value || 'other';
if (!eventId) return;
2026-06-26 14:15:46 +02:00
const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
2026-11-03 01:16:12 +01:00
body: JSON.stringify({ reason })
2026-06-26 14:15:46 +02:00
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
2026-11-03 01:16:12 +01:00
alert(data.detail || 'Kunne ikke ignorere enheden');
2026-06-26 14:15:46 +02:00
return;
}
2026-11-03 01:16:12 +01:00
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).hide();
await loadDriftSummary();
await loadDriftEvents();
await loadIgnoreRules();
showNotification('Enheden ignoreres ved fremtidige sync', 'success');
}
async function loadIgnoreRules() {
const body = document.getElementById('drift-ignore-rules-body');
if (!body) return;
try {
const res = await fetch('/api/v1/drift/ignore-rules', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rules = await res.json();
body.innerHTML = (Array.isArray(rules) ? rules : []).map(rule => `
< tr >
< td > < strong > ${escapeDriftHtml(rule.device_name || rule.external_id)}< / strong > < div class = "small text-muted" > ${escapeDriftHtml(rule.external_id)}< / div > < / td >
< td > ${escapeDriftHtml(rule.source_name || '-')}< / td >
< td > ${escapeDriftHtml(driftReasonLabel(rule.reason))}< / td >
< td > ${escapeDriftHtml(rule.ignored_by_name || 'Ukendt')}< / td >
< td > ${rule.created_at ? new Date(rule.created_at).toLocaleString('da-DK') : '-'}< / td >
< td class = "text-end" > < button class = "btn btn-sm btn-outline-success" onclick = "reactivateIgnoredDevice(${Number(rule.id)})" > Overvåg igen< / button > < / td >
< / tr > `).join('') || '< tr > < td colspan = "6" class = "text-success" > Ingen enheder ignoreres.< / td > < / tr > ';
} catch (e) {
body.innerHTML = '< tr > < td colspan = "6" class = "text-danger" > Kunne ikke hente ignorerede enheder.< / td > < / tr > ';
}
}
2026-06-26 14:15:46 +02:00
2026-11-03 01:16:12 +01:00
async function reactivateIgnoredDevice(ruleId) {
const res = await fetch(`/api/v1/drift/ignore-rules/${Number(ruleId)}`, { method: 'DELETE', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke sætte enheden under overvågning igen');
return;
}
await loadIgnoreRules();
2026-06-26 14:15:46 +02:00
await loadDriftSummary();
await loadDriftEvents();
2026-11-03 01:16:12 +01:00
showNotification('Enheden overvåges igen fra næste sync', 'success');
2026-06-26 14:15:46 +02:00
}
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';
2026-06-26 14:15:46 +02:00
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 })
2026-06-26 14:15:46 +02:00
});
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') {
2026-06-26 14:15:46 +02:00
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';
2026-06-26 14:15:46 +02:00
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, '< ')}< / 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' })
2026-06-26 14:15:46 +02:00
});
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();
2026-11-03 01:16:12 +01:00
loadIgnoreRules();
document.getElementById('drift-search-filter')?.addEventListener('keydown', event => {
if (event.key === 'Enter') loadDriftEvents();
});
2026-06-26 14:15:46 +02:00
});
< / script >
{% endblock %}