bmc_hub/app/modules/drift/templates/drift.html
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

751 lines
37 KiB
HTML

{% 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>
<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">
<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>
<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>
</div>
</details>
<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" selected>Kræver handling</option>
<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">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>
<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…">
<button class="btn btn-sm btn-outline-primary" onclick="loadDriftEvents()">
<i class="bi bi-funnel me-1"></i>Opdater filtre
</button>
<span class="small text-muted align-self-center ms-auto" id="drift-result-count"></span>
</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>
<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">
<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="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>
<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">
<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 escapeDriftHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
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`;
}
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 = typeof events === 'number' ? Math.max(0, events) : (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();
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;
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 = uisp.active_events ?? 0;
document.getElementById('drift-uisp-total').textContent = uisp.total_events ?? 0;
document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync
? new Date(uisp.last_sync).toLocaleString('da-DK')
: '-';
syncBottomDriftCountFromVisibleEvents(Number(data.active_alerts || 0));
} 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 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`;
if (!visibleEvents.length) {
body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>';
return;
}
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 + `
<tr>
<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>
<td>
<div class="d-flex align-items-center gap-2">
<span>${escapeDriftHtml(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>${escapeDriftHtml(event.customer || '-')}</span>
${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>${escapeDriftHtml(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>
<td>${escapeDriftHtml(event.ip || '-')}<div class="mt-1">${quality}</div></td>
<td>${escapeDriftHtml(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>${formatDriftDuration(event.duration_minutes)}</td>
<td>
<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>
</div>
</td>
</tr>
`}).join('');
} catch (e) {
console.error(e);
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;
}
const statusFilter = document.getElementById('drift-status-filter');
if (statusFilter && (statusFilter.value === '' || statusFilter.value === 'active')) {
statusFilter.value = 'acknowledged';
}
await loadDriftSummary();
await loadDriftEvents();
}
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();
}
async function confirmIgnoreDevice() {
const eventId = Number(document.getElementById('driftIgnoreEventId').value || 0);
const reason = document.getElementById('driftIgnoreReason').value || 'other';
if (!eventId) return;
const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke ignorere enheden');
return;
}
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>';
}
}
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();
await loadDriftSummary();
await loadDriftEvents();
showNotification('Enheden overvåges igen fra næste sync', 'success');
}
async function mapDriftCustomer(monitorKey, monitorName) {
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
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' },
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;
}
async function openMapCustomerModal(monitorKey, monitorName, source = 'uptime-kuma') {
await loadDriftCustomers();
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
document.getElementById('driftMapMonitorName').value = monitorName || '';
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' },
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();
loadIgnoreRules();
document.getElementById('drift-search-filter')?.addEventListener('keydown', event => {
if (event.key === 'Enter') loadDriftEvents();
});
});
</script>
{% endblock %}