609 lines
27 KiB
HTML
609 lines
27 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>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
<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>
|
||
|
|
<th>Start</th>
|
||
|
|
<th>Last seen</th>
|
||
|
|
<th>Varighed</th>
|
||
|
|
<th>Sag</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="drift-events-body">
|
||
|
|
<tr><td colspan="9" 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">
|
||
|
|
<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, '<')}</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) {
|
||
|
|
body.innerHTML = '<tr><td colspan="9" 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>
|
||
|
|
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')})' 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>
|
||
|
|
<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([]);
|
||
|
|
body.innerHTML = '<tr><td colspan="9" 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
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 })
|
||
|
|
});
|
||
|
|
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) {
|
||
|
|
await loadDriftCustomers();
|
||
|
|
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
|
||
|
|
document.getElementById('driftMapMonitorName').value = monitorName || '';
|
||
|
|
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' },
|
||
|
|
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId })
|
||
|
|
});
|
||
|
|
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 %}
|