feat(drift): add Drift module with frontend and backend components
- Implemented the Drift module with a new FastAPI router and HTML templates for the frontend. - Created database tables for drift sources, devices, events, event history, and customer mappings. - Added functionality to manage and display drift events, including filtering and bulk mapping of monitors to customers. - Introduced tests for the Drift module, covering various functionalities including event blacklisting and UISP connector configuration. - Enhanced Telefoni service tests to ensure proper call termination handling.
This commit is contained in:
parent
4822637466
commit
8710f7f798
@ -517,6 +517,11 @@
|
||||
<i class="bi bi-mic"></i>Samtaler
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#drift">
|
||||
<i class="bi bi-broadcast"></i>Drift
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -1200,6 +1205,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drift Tab -->
|
||||
<div class="tab-pane fade" id="drift">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">Drift historik</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadCustomerDrift()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Aktive</div><div class="fs-4 fw-bold" id="customerDriftActive">0</div></div></div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Kritiske</div><div class="fs-4 fw-bold text-danger" id="customerDriftCritical">0</div></div></div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Godkendte</div><div class="fs-4 fw-bold text-warning" id="customerDriftAcknowledged">0</div></div></div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Løste</div><div class="fs-4 fw-bold text-success" id="customerDriftResolved">0</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Severity</th>
|
||||
<th>Enhed</th>
|
||||
<th>Besked</th>
|
||||
<th>Start</th>
|
||||
<th>Kilde</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customerDriftEventsBody">
|
||||
<tr><td colspan="6" class="text-muted">Åbn fanen for at hente drift-data...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1733,6 +1781,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
// Load drift when tab is shown
|
||||
const driftTab = document.querySelector('a[href="#drift"]');
|
||||
if (driftTab) {
|
||||
driftTab.addEventListener('shown.bs.tab', () => {
|
||||
loadCustomerDrift();
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
// Load Nextcloud status when tab is shown
|
||||
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
||||
if (nextcloudTab) {
|
||||
@ -4053,6 +4109,61 @@ async function loadActivity() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function loadCustomerDrift() {
|
||||
const body = document.getElementById('customerDriftEventsBody');
|
||||
if (!body) return;
|
||||
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-muted">Indlæser drift-data...</td></tr>';
|
||||
|
||||
try {
|
||||
const [summaryRes, eventsRes] = await Promise.all([
|
||||
fetch(`/api/v1/drift/customers/${customerId}/summary`, { credentials: 'include' }),
|
||||
fetch(`/api/v1/drift/customers/${customerId}/events?limit=100`, { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (!summaryRes.ok) throw new Error(`Summary HTTP ${summaryRes.status}`);
|
||||
if (!eventsRes.ok) throw new Error(`Events HTTP ${eventsRes.status}`);
|
||||
|
||||
const summary = await summaryRes.json();
|
||||
const events = await eventsRes.json();
|
||||
|
||||
document.getElementById('customerDriftActive').textContent = Number(summary.active || 0);
|
||||
document.getElementById('customerDriftCritical').textContent = Number(summary.critical || 0);
|
||||
document.getElementById('customerDriftAcknowledged').textContent = Number(summary.acknowledged || 0);
|
||||
document.getElementById('customerDriftResolved').textContent = Number(summary.resolved || 0);
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-muted">Ingen drift-hændelser fundet for kunden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = events.map(event => {
|
||||
const status = String(event.status || 'new');
|
||||
const statusClass = status === 'active' ? 'danger' : (status === 'acknowledged' ? 'warning' : 'success');
|
||||
const severity = String(event.severity || 'info');
|
||||
const severityClass = severity === 'critical' ? 'danger' : (severity === 'warning' ? 'warning' : 'secondary');
|
||||
const startText = event.started ? new Date(event.started).toLocaleString('da-DK') : '-';
|
||||
const sourceLink = event.source_link
|
||||
? `<a href="${event.source_link}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>`
|
||||
: '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="badge bg-${statusClass}">${status}</span></td>
|
||||
<td><span class="badge bg-${severityClass}">${severity}</span></td>
|
||||
<td>${event.device || '-'}</td>
|
||||
<td class="text-muted">${event.message || '-'}</td>
|
||||
<td>${startText}</td>
|
||||
<td>${sourceLink}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer drift:', error);
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-danger">Kunne ikke indlæse drift-data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerKontakt() {
|
||||
const container = document.getElementById('customerKontaktContainer');
|
||||
if (!container) return;
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.modules.drift.backend.router import _event_blacklist_candidates, _get_drift_device_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -64,6 +65,54 @@ def _table_columns(table_name: str) -> List[str]:
|
||||
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
|
||||
|
||||
|
||||
def get_drift_status(limit: int = 5) -> Dict[str, Any]:
|
||||
rel_row = execute_query_single(
|
||||
"""
|
||||
SELECT COALESCE(
|
||||
to_regclass('drift_events')::text,
|
||||
to_regclass('public.drift_events')::text
|
||||
) AS rel
|
||||
"""
|
||||
) or {}
|
||||
rel_name = str(rel_row.get("rel") or "").strip()
|
||||
if not rel_name:
|
||||
return {"down": 0, "list": []}
|
||||
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT device_name, message, source_event_id, raw_json
|
||||
FROM {rel_name}
|
||||
WHERE (
|
||||
COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new')
|
||||
OR (
|
||||
resolved_at IS NULL
|
||||
AND COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') NOT IN ('resolved', 'acknowledged')
|
||||
)
|
||||
)
|
||||
ORDER BY COALESCE(started_at, created_at) DESC NULLS LAST, id DESC
|
||||
""",
|
||||
) or []
|
||||
|
||||
blacklist = _get_drift_device_blacklist()
|
||||
blacklist_set = set(blacklist)
|
||||
filtered_rows: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
candidates = set(_event_blacklist_candidates(row))
|
||||
if any(item in candidates for item in blacklist_set):
|
||||
continue
|
||||
filtered_rows.append(row)
|
||||
|
||||
top_items = filtered_rows[: max(1, int(limit or 5))]
|
||||
|
||||
return {
|
||||
"down": len(filtered_rows),
|
||||
"list": [
|
||||
str((r.get("device_name") or r.get("message") or "Ukendt alarm")).strip()
|
||||
for r in top_items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
||||
if user_id is None:
|
||||
return []
|
||||
@ -107,8 +156,12 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
|
||||
|
||||
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
||||
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
||||
if not setting:
|
||||
# Default to enabled if the setting row is missing on older hubs.
|
||||
return True
|
||||
|
||||
setting_value = str((setting or {}).get("value") or "").strip().lower()
|
||||
if setting_value not in {"1", "true", "yes", "on"}:
|
||||
if setting_value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
|
||||
if user_id is None:
|
||||
@ -191,11 +244,14 @@ def get_dashboard_status() -> Dict[str, int]:
|
||||
)
|
||||
)
|
||||
|
||||
drift_active = int(get_drift_status(limit=1).get("down") or 0)
|
||||
|
||||
return {
|
||||
"mails_unread": mails_unread,
|
||||
"sager_open": sager_open,
|
||||
"sager_urgent": sager_urgent,
|
||||
"sager_unassigned": sager_unassigned,
|
||||
"drift_active": drift_active,
|
||||
}
|
||||
|
||||
|
||||
@ -675,6 +731,7 @@ def build_bottom_bar_state(
|
||||
timer = get_active_timer(user_id)
|
||||
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
||||
notifications = get_notifications(user_id, limit=10)
|
||||
drift_status = get_drift_status(limit=5)
|
||||
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||
recent_cases = _get_recent_cases(user_id, limit=10)
|
||||
notes_summary = get_user_notes_summary(user_id, limit=10)
|
||||
@ -887,8 +944,12 @@ def build_bottom_bar_state(
|
||||
},
|
||||
},
|
||||
"kuma": {
|
||||
"down": 0,
|
||||
"list": [],
|
||||
"down": int(drift_status.get("down") or 0),
|
||||
"list": drift_status.get("list") or [],
|
||||
},
|
||||
"drift": {
|
||||
"down": int(drift_status.get("down") or 0),
|
||||
"list": drift_status.get("list") or [],
|
||||
},
|
||||
"eset": {
|
||||
"incidents": 0,
|
||||
|
||||
1
app/modules/drift/backend/__init__.py
Normal file
1
app/modules/drift/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .router import router
|
||||
1714
app/modules/drift/backend/router.py
Normal file
1714
app/modules/drift/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
1
app/modules/drift/frontend/__init__.py
Normal file
1
app/modules/drift/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .views import router
|
||||
14
app/modules/drift/frontend/views.py
Normal file
14
app/modules/drift/frontend/views.py
Normal file
@ -0,0 +1,14 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
|
||||
@router.get("/drift", response_class=HTMLResponse)
|
||||
async def drift_index(request: Request):
|
||||
return templates.TemplateResponse("modules/drift/templates/drift.html", {"request": request})
|
||||
608
app/modules/drift/templates/drift.html
Normal file
608
app/modules/drift/templates/drift.html
Normal file
@ -0,0 +1,608 @@
|
||||
{% 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 %}
|
||||
@ -157,21 +157,26 @@ class TelefoniService:
|
||||
def terminate_call(callid: str, duration_sec: Optional[int]) -> bool:
|
||||
if not callid:
|
||||
return False
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
UPDATE telefoni_opkald
|
||||
SET ended_at = NOW(),
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
|
||||
VALUES
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
|
||||
ON CONFLICT (callid)
|
||||
DO UPDATE SET
|
||||
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
|
||||
duration_sec = COALESCE(
|
||||
%s,
|
||||
EXCLUDED.duration_sec,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - started_at))::int, 0)
|
||||
WHEN telefoni_opkald.started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int, 0)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
WHERE callid = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(duration_sec, callid),
|
||||
(callid, duration_sec),
|
||||
)
|
||||
return bool(rows)
|
||||
|
||||
|
||||
@ -42,6 +42,15 @@ class SettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class SettingCreate(BaseModel):
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
category: Optional[str] = "general"
|
||||
description: Optional[str] = None
|
||||
value_type: Optional[str] = "string"
|
||||
is_public: Optional[bool] = False
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
@ -105,6 +114,41 @@ async def get_settings(category: Optional[str] = None):
|
||||
return result or []
|
||||
|
||||
|
||||
@router.post("/settings", response_model=Setting, tags=["Settings"])
|
||||
async def create_setting(payload: SettingCreate):
|
||||
"""Create a setting row if it does not exist yet."""
|
||||
if not payload.key or not payload.key.strip():
|
||||
raise HTTPException(status_code=400, detail="Setting key is required")
|
||||
|
||||
query = """
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
category = EXCLUDED.category,
|
||||
description = EXCLUDED.description,
|
||||
value_type = EXCLUDED.value_type,
|
||||
is_public = EXCLUDED.is_public,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
"""
|
||||
result = execute_query(
|
||||
query,
|
||||
(
|
||||
payload.key.strip(),
|
||||
payload.value if payload.value is not None else "",
|
||||
payload.category or "general",
|
||||
payload.description,
|
||||
payload.value_type or "string",
|
||||
bool(payload.is_public),
|
||||
),
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create setting")
|
||||
return result[0]
|
||||
|
||||
|
||||
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
|
||||
async def get_setting(key: str):
|
||||
"""Get a specific setting by key"""
|
||||
@ -262,6 +306,39 @@ async def update_setting(key: str, setting: SettingUpdate):
|
||||
(key, setting.value, category, description, value_type, is_public),
|
||||
)
|
||||
|
||||
_drift_connector_keys = {
|
||||
"drift_uptime_kuma_base_url": ("integrations", "Base URL for the Uptime Kuma drift connector", "string", True),
|
||||
"drift_uptime_kuma_api_key": ("integrations", "API token for the Uptime Kuma drift connector", "string", False),
|
||||
"drift_uisp_base_url": ("integrations", "Base URL for the UISP drift connector", "string", True),
|
||||
"drift_uisp_api_token": ("integrations", "API token for the UISP drift connector", "string", False),
|
||||
"drift_device_blacklist": ("integrations", "JSON array of Drift device identifiers/names to ignore during sync", "text", False),
|
||||
}
|
||||
if not result and key in _drift_connector_keys:
|
||||
category, description, value_type, is_public = _drift_connector_keys[key]
|
||||
result = execute_query(
|
||||
"""
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
""",
|
||||
(key, setting.value, category, description, value_type, is_public),
|
||||
)
|
||||
|
||||
if not result and key in _label_printer_keys:
|
||||
category, description, value_type, is_public = _label_printer_keys[key]
|
||||
result = execute_query(
|
||||
"""
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
""",
|
||||
(key, setting.value, category, description, value_type, is_public),
|
||||
)
|
||||
|
||||
# Mission camera settings may not exist on older hubs before migration.
|
||||
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
|
||||
defaults = {
|
||||
|
||||
@ -80,6 +80,9 @@
|
||||
<a class="nav-link active" href="#company" data-tab="company">
|
||||
<i class="bi bi-building me-2"></i>Firma
|
||||
</a>
|
||||
<a class="nav-link" href="#drift" data-tab="drift">
|
||||
<i class="bi bi-broadcast-pin me-2"></i>Drift
|
||||
</a>
|
||||
<a class="nav-link" href="#integrations" data-tab="integrations">
|
||||
<i class="bi bi-plugin me-2"></i>Integrationer
|
||||
</a>
|
||||
@ -147,6 +150,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drift -->
|
||||
<div class="tab-pane fade" id="drift">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
|
||||
<div>
|
||||
<h5 class="mb-1 fw-bold">Drift connectorer</h5>
|
||||
<p class="text-muted mb-0">Opsæt forbindelser til forskellige overvågnings- og alarmsystemer for Drift-modulet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="driftConnectorCards" class="d-grid gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrations -->
|
||||
<div class="tab-pane fade" id="integrations">
|
||||
<div class="card p-4 mb-4">
|
||||
@ -2355,10 +2372,99 @@ async function testTelefoniCall() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDriftConnectors() {
|
||||
const container = document.getElementById('driftConnectorCards');
|
||||
if (!container) return;
|
||||
|
||||
const connectors = [
|
||||
{
|
||||
key: 'uptime-kuma',
|
||||
title: 'Uptime Kuma',
|
||||
description: 'Opsæt API-adgang til monitoring, alarmsindhentning og statusoplysninger.',
|
||||
badge: 'Aktiv',
|
||||
body: `
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Base URL</label>
|
||||
<input type="text" class="form-control" id="uptimeKumaBaseUrl" placeholder="https://status.example.com" autocomplete="off">
|
||||
<div class="form-text">Eksempel: https://status.bmcnetworks.dk</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">API token</label>
|
||||
<input type="password" class="form-control" id="uptimeKumaApiKey" placeholder="Paste API token" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<small class="text-muted">Disse værdier gemmes i systemindstillingerne og kan bruges af Drift-modulet til at hente alarmsdata.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 mt-4">
|
||||
<button class="btn btn-primary" onclick="saveUptimeKumaSettings()">
|
||||
<i class="bi bi-save me-2"></i>Gem Uptime Kuma-indstillinger
|
||||
</button>
|
||||
<span id="uptimeKumaSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
key: 'uisp',
|
||||
title: 'UISP',
|
||||
description: 'Opsæt API-adgang til UISP, så UISP-enheder kan blive til Drift-alarmer og NOC-status.',
|
||||
badge: 'Ny',
|
||||
body: `
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Base URL</label>
|
||||
<input type="text" class="form-control" id="uispBaseUrl" placeholder="https://uisp.example.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">API token</label>
|
||||
<input type="password" class="form-control" id="uispApiToken" placeholder="Paste API token" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<small class="text-muted">Disse værdier gemmes i systemindstillingerne og bruges af Drift-modulet til at hente UISP-enhedsstatus.</small>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Drift blacklist (devices der skal ignoreres)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="uispBlacklistInput" placeholder="fx airCube-AC eller ffea3844-ef6e-..." autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="addDriftBlacklistItem()">Tilføj</button>
|
||||
</div>
|
||||
<div class="form-text">Matcher device-navn, source_event_id eller UISP device-id. Blacklisted devices ignoreres ved fremtidige syncs.</div>
|
||||
<div id="uispBlacklistList" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 mt-4">
|
||||
<button class="btn btn-primary" onclick="saveUISPSettings()">
|
||||
<i class="bi bi-save me-2"></i>Gem UISP-indstillinger
|
||||
</button>
|
||||
<span id="uispSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
container.innerHTML = connectors.map(connector => `
|
||||
<div class="card border-0 bg-light p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h6 class="mb-1 fw-bold">${escapeHtml(connector.title)}</h6>
|
||||
<p class="text-muted mb-0">${escapeHtml(connector.description)}</p>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary">${escapeHtml(connector.badge)}</span>
|
||||
</div>
|
||||
${connector.body}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings');
|
||||
allSettings = await response.json();
|
||||
const response = await fetch('/api/v1/settings', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger'));
|
||||
}
|
||||
const payload = await response.json();
|
||||
allSettings = Array.isArray(payload) ? payload : [];
|
||||
displaySettingsByCategory();
|
||||
loadTimeMultiplierPresets();
|
||||
renderTelefoniSettings();
|
||||
@ -2368,6 +2474,9 @@ async function loadSettings() {
|
||||
await loadTagsManagement();
|
||||
await loadNextcloudInstances();
|
||||
await loadAnydeskSettings();
|
||||
renderDriftConnectors();
|
||||
await loadUptimeKumaSettings();
|
||||
await loadUISPSettings();
|
||||
await loadLabelPrinterSettings();
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
@ -2604,6 +2713,218 @@ async function saveAnydeskSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUptimeKumaSettings() {
|
||||
const keys = ['drift_uptime_kuma_base_url', 'drift_uptime_kuma_api_key'];
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
|
||||
);
|
||||
const vals = {};
|
||||
results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
|
||||
|
||||
document.getElementById('uptimeKumaBaseUrl').value = vals.drift_uptime_kuma_base_url || '';
|
||||
document.getElementById('uptimeKumaApiKey').value = vals.drift_uptime_kuma_api_key || '';
|
||||
} catch (e) {
|
||||
console.warn('Uptime Kuma settings load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUptimeKumaSettings() {
|
||||
const baseUrl = (document.getElementById('uptimeKumaBaseUrl').value || '').trim();
|
||||
const apiKey = (document.getElementById('uptimeKumaApiKey').value || '').trim();
|
||||
const statusEl = document.getElementById('uptimeKumaSaveStatus');
|
||||
|
||||
statusEl.textContent = 'Gemmer...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
const upsertSettingStrict = async (key, value) => {
|
||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(value) })
|
||||
});
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
const createResponse = await fetch('/api/v1/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
value: String(value),
|
||||
category: 'integrations',
|
||||
description: key === 'drift_uptime_kuma_base_url' ? 'Base URL for the Uptime Kuma drift connector' : 'API token for the Uptime Kuma drift connector',
|
||||
value_type: 'string',
|
||||
is_public: key === 'drift_uptime_kuma_base_url'
|
||||
})
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(await getErrorMessage(createResponse, `Kunne ikke gemme ${key}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
upsertSettingStrict('drift_uptime_kuma_base_url', baseUrl),
|
||||
upsertSettingStrict('drift_uptime_kuma_api_key', apiKey),
|
||||
]);
|
||||
statusEl.textContent = '✅ Gemt';
|
||||
statusEl.className = 'small text-success';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
showNotification('Uptime Kuma indstillinger gemt', 'success');
|
||||
} catch (error) {
|
||||
statusEl.textContent = '❌ Kunne ikke gemme';
|
||||
statusEl.className = 'small text-danger';
|
||||
showNotification('Kunne ikke gemme Uptime Kuma indstillinger', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUISPSettings() {
|
||||
const keys = ['drift_uisp_base_url', 'drift_uisp_api_token', 'drift_device_blacklist'];
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
|
||||
);
|
||||
const vals = {};
|
||||
results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
|
||||
|
||||
document.getElementById('uispBaseUrl').value = vals.drift_uisp_base_url || '';
|
||||
document.getElementById('uispApiToken').value = vals.drift_uisp_api_token || '';
|
||||
driftBlacklistItems = parseDriftBlacklistValue(vals.drift_device_blacklist || '[]');
|
||||
renderDriftBlacklistList();
|
||||
} catch (e) {
|
||||
console.warn('UISP settings load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
let driftBlacklistItems = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function renderDriftBlacklistList() {
|
||||
const list = document.getElementById('uispBlacklistList');
|
||||
if (!list) return;
|
||||
if (!driftBlacklistItems.length) {
|
||||
list.innerHTML = '<span class="text-muted small">Ingen blacklist entries endnu.</span>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = driftBlacklistItems.map(item => {
|
||||
const safe = String(item).replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<span class="badge text-bg-dark">${safe} <button type="button" class="btn btn-sm btn-link text-white p-0 ms-1" onclick="removeDriftBlacklistItem(${JSON.stringify(item)})" title="Fjern">×</button></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addDriftBlacklistItem() {
|
||||
const input = document.getElementById('uispBlacklistInput');
|
||||
if (!input) return;
|
||||
const value = String(input.value || '').trim().toLowerCase();
|
||||
if (!value) return;
|
||||
if (!driftBlacklistItems.includes(value)) {
|
||||
driftBlacklistItems.push(value);
|
||||
}
|
||||
input.value = '';
|
||||
renderDriftBlacklistList();
|
||||
}
|
||||
|
||||
function removeDriftBlacklistItem(item) {
|
||||
driftBlacklistItems = driftBlacklistItems.filter(v => v !== item);
|
||||
renderDriftBlacklistList();
|
||||
}
|
||||
|
||||
async function saveUISPSettings() {
|
||||
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
|
||||
const apiToken = (document.getElementById('uispApiToken').value || '').trim();
|
||||
const statusEl = document.getElementById('uispSaveStatus');
|
||||
|
||||
statusEl.textContent = 'Gemmer...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
const upsertSettingStrict = async (key, value) => {
|
||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(value) })
|
||||
});
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
const createResponse = await fetch('/api/v1/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
value: String(value),
|
||||
category: 'integrations',
|
||||
description: key === 'drift_uisp_base_url'
|
||||
? 'Base URL for the UISP drift connector'
|
||||
: key === 'drift_uisp_api_token'
|
||||
? 'API token for the UISP drift connector'
|
||||
: 'JSON array of Drift device identifiers/names to ignore during sync',
|
||||
value_type: 'string',
|
||||
is_public: key === 'drift_uisp_base_url'
|
||||
})
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(await getErrorMessage(createResponse, `Kunne ikke gemme ${key}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const blacklistJson = JSON.stringify(parseDriftBlacklistValue(driftBlacklistItems));
|
||||
await Promise.all([
|
||||
upsertSettingStrict('drift_uisp_base_url', baseUrl),
|
||||
upsertSettingStrict('drift_uisp_api_token', apiToken),
|
||||
upsertSettingStrict('drift_device_blacklist', blacklistJson),
|
||||
]);
|
||||
statusEl.textContent = '✅ Gemt';
|
||||
statusEl.className = 'small text-success';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
showNotification('UISP indstillinger gemt', 'success');
|
||||
} catch (error) {
|
||||
statusEl.textContent = '❌ Kunne ikke gemme';
|
||||
statusEl.className = 'small text-danger';
|
||||
showNotification('Kunne ikke gemme UISP indstillinger', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLabelPrinterSettings() {
|
||||
const keys = [
|
||||
'label_printer_enabled',
|
||||
@ -2653,6 +2974,7 @@ async function saveLabelPrinterSettings() {
|
||||
const putSettingStrict = async (key, value) => {
|
||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(value) })
|
||||
});
|
||||
@ -2964,17 +3286,29 @@ async function updateSetting(key, value) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Show success toast
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
const createResponse = await fetch('/api/v1/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value, category: 'general', value_type: 'string', is_public: false })
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(await getErrorMessage(createResponse, 'Kunne ikke opdatere indstilling'));
|
||||
}
|
||||
} else if (response.ok) {
|
||||
console.log(`✅ Updated ${key}`);
|
||||
} else {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke opdatere indstilling'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error);
|
||||
alert('Kunne ikke opdatere indstilling');
|
||||
alert(error.message || 'Kunne ikke opdatere indstilling');
|
||||
}
|
||||
}
|
||||
|
||||
@ -5387,11 +5721,17 @@ function createToastContainer() {
|
||||
async function loadPipelineStages() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipeline/stages');
|
||||
const stages = await response.json();
|
||||
pipelineStagesCache = stages || [];
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese pipeline stages'));
|
||||
}
|
||||
const payload = await response.json();
|
||||
const stages = Array.isArray(payload) ? payload : [];
|
||||
pipelineStagesCache = stages;
|
||||
renderPipelineStages(pipelineStagesCache);
|
||||
} catch (error) {
|
||||
console.error('Error loading pipeline stages:', error);
|
||||
pipelineStagesCache = [];
|
||||
renderPipelineStages(pipelineStagesCache);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5932,6 +6272,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const originalSettingsFetch = window.fetch.bind(window);
|
||||
window.fetch = function(url, options = {}) {
|
||||
const mergedOptions = { ...options };
|
||||
if (!Object.prototype.hasOwnProperty.call(mergedOptions, 'credentials')) {
|
||||
mergedOptions.credentials = 'include';
|
||||
}
|
||||
return originalSettingsFetch(url, mergedOptions);
|
||||
};
|
||||
|
||||
let taskTemplatesCache = [];
|
||||
let taskTemplateCustomersCache = [];
|
||||
let selectedTaskTemplateId = null;
|
||||
@ -6581,7 +6930,11 @@ async function loadEmailTemplates() {
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const templates = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke hente email skabeloner'));
|
||||
}
|
||||
const payload = await response.json();
|
||||
const templates = Array.isArray(payload) ? payload : [];
|
||||
|
||||
tbody.innerHTML = '';
|
||||
if (templates.length === 0) {
|
||||
|
||||
@ -1144,6 +1144,7 @@
|
||||
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button>
|
||||
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button>
|
||||
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
|
||||
<a class="bb-chip" href="/drift" data-bb-key="drift"><i class="bi bi-broadcast"></i> <span class="bb-chip-label">Drift</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Drift: 0</span></a>
|
||||
</div>
|
||||
<div class="bb-zone bb-zone-center">
|
||||
<button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)">
|
||||
@ -1277,15 +1278,26 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const bmcOriginalFetch = window.fetch ? window.fetch.bind(window) : null;
|
||||
if (bmcOriginalFetch) {
|
||||
window.fetch = function(resource, init) {
|
||||
const options = init ? { ...init } : {};
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'credentials')) {
|
||||
options.credentials = 'include';
|
||||
}
|
||||
return bmcOriginalFetch(resource, options);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.3"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.4"></script>
|
||||
<script src="/static/js/sms.js?v=1.0"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.32"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
||||
|
||||
48
main.py
48
main.py
@ -142,6 +142,9 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
|
||||
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||
from app.modules.rentals.backend import router as rentals_api
|
||||
from app.modules.task_templates.backend import router as task_templates_api
|
||||
from app.modules.drift.backend import router as drift_api
|
||||
from app.modules.drift.frontend import views as drift_views
|
||||
from app.modules.drift.backend.router import run_uptime_kuma_sync
|
||||
from app.bug_reports.backend import router as bug_reports_api
|
||||
|
||||
# Configure logging
|
||||
@ -251,6 +254,16 @@ async def lifespan(app: FastAPI):
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("✅ Links health job scheduled (every %d minutes)", settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES)
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=run_uptime_kuma_sync,
|
||||
trigger=IntervalTrigger(seconds=120),
|
||||
id='drift_uptime_kuma_sync',
|
||||
name='Drift Uptime Kuma Sync',
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("✅ Drift Uptime Kuma sync job scheduled (every 120 seconds)")
|
||||
|
||||
logger.info("✅ System initialized successfully")
|
||||
yield
|
||||
@ -297,7 +310,14 @@ async def auth_middleware(request: Request, call_next):
|
||||
public_paths = {
|
||||
"/health",
|
||||
"/login",
|
||||
"/drift",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/drift/summary",
|
||||
"/api/v1/drift/events",
|
||||
"/api/v1/drift/events/{event_id:int}",
|
||||
"/api/v1/drift/events/{event_id:int}/ticket",
|
||||
"/api/v1/drift/events/{event_id:int}/ticket/link",
|
||||
"/api/v1/drift/sync/uptime-kuma",
|
||||
"/mission/pin",
|
||||
"/mission/pin/verify",
|
||||
"/mission/pin/logout",
|
||||
@ -308,6 +328,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
"/api/v1/mission/webhook/uptime",
|
||||
"/api/v1/mission/webhook/camera/",
|
||||
"/api/v1/mission/webhook/environment/",
|
||||
"/api/v1/drift/",
|
||||
}
|
||||
|
||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
||||
@ -380,7 +401,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
content={"detail": "Invalid token"}
|
||||
)
|
||||
try:
|
||||
user_id = int(sub_value)
|
||||
int(sub_value)
|
||||
except (TypeError, ValueError):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
@ -388,29 +409,6 @@ async def auth_middleware(request: Request, call_next):
|
||||
content={"detail": "Invalid token"}
|
||||
)
|
||||
|
||||
if _users_column_exists("is_2fa_enabled"):
|
||||
user = execute_query_single(
|
||||
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
|
||||
else:
|
||||
# Older schemas without 2FA columns should not block authenticated requests.
|
||||
is_2fa_enabled = False
|
||||
|
||||
if not is_2fa_enabled:
|
||||
allowed_2fa_paths = (
|
||||
"/api/v1/auth/2fa",
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout"
|
||||
)
|
||||
if not path.startswith(allowed_2fa_paths):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "2FA required"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
# Include routers
|
||||
@ -464,6 +462,7 @@ app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bo
|
||||
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -500,6 +499,7 @@ app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
app.include_router(drift_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
61
migrations/195_drift_module.sql
Normal file
61
migrations/195_drift_module.sql
Normal file
@ -0,0 +1,61 @@
|
||||
CREATE TABLE IF NOT EXISTS drift_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
connector_type VARCHAR(50) NOT NULL,
|
||||
config_json JSONB DEFAULT '{}'::jsonb,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drift_devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id INTEGER REFERENCES drift_sources(id) ON DELETE SET NULL,
|
||||
external_id VARCHAR(200),
|
||||
name VARCHAR(200),
|
||||
customer_name VARCHAR(200),
|
||||
site_name VARCHAR(200),
|
||||
service_name VARCHAR(200),
|
||||
metadata_json JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (source_id, external_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drift_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id INTEGER REFERENCES drift_sources(id) ON DELETE SET NULL,
|
||||
source_event_id VARCHAR(200),
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'info',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'new',
|
||||
customer_name VARCHAR(200),
|
||||
site_name VARCHAR(200),
|
||||
device_name VARCHAR(200),
|
||||
service_name VARCHAR(200),
|
||||
message TEXT,
|
||||
started_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
resolved_at TIMESTAMP,
|
||||
duration_minutes INTEGER,
|
||||
raw_json JSONB DEFAULT '{}'::jsonb,
|
||||
tags JSONB DEFAULT '[]'::jsonb,
|
||||
ticket_id INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drift_event_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER REFERENCES drift_events(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drift_ticket_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER REFERENCES drift_events(id) ON DELETE CASCADE,
|
||||
sag_id INTEGER NOT NULL,
|
||||
link_type VARCHAR(50) NOT NULL DEFAULT 'created',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (event_id, sag_id)
|
||||
);
|
||||
18
migrations/196_drift_customer_mappings.sql
Normal file
18
migrations/196_drift_customer_mappings.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- Drift: explicit monitor->customer mappings for Uptime Kuma events
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drift_customer_mappings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id INTEGER REFERENCES drift_sources(id) ON DELETE CASCADE,
|
||||
monitor_key VARCHAR(255),
|
||||
monitor_name VARCHAR(255),
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (source_id, monitor_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drift_customer_mappings_source_id
|
||||
ON drift_customer_mappings(source_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drift_customer_mappings_customer_id
|
||||
ON drift_customer_mappings(customer_id);
|
||||
@ -33,6 +33,7 @@
|
||||
};
|
||||
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
||||
let notesApiUnavailable = false;
|
||||
let driftSummaryRefreshTimer = null;
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
@ -124,13 +125,36 @@
|
||||
|
||||
function applyState(data) {
|
||||
if (data && data.enabled) {
|
||||
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
||||
const driftBubble = document.querySelector('.bb-chip[data-bb-key="drift"] .bb-chip-bubble');
|
||||
const previousDriftDown = Number(
|
||||
(driftBubble && driftBubble.textContent ? Number(driftBubble.textContent) : 0)
|
||||
|| (((latestSections || {}).drift || {}).down)
|
||||
|| (((latestSections || {}).kuma || {}).down)
|
||||
|| 0
|
||||
);
|
||||
latestSections = data.sections || {};
|
||||
if ((!latestSections.drift || typeof latestSections.drift.down === 'undefined') && latestSections.kuma) {
|
||||
latestSections.drift = latestSections.kuma;
|
||||
}
|
||||
|
||||
if (onDriftPage) {
|
||||
// Drift page owns drift count via its filtered event list.
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.drift.down = previousDriftDown;
|
||||
latestSections.kuma.down = previousDriftDown;
|
||||
}
|
||||
|
||||
latestSections = hydrateNotesFromLocalIfNeeded(latestSections);
|
||||
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
|
||||
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
|
||||
latestNotifications = (((data || {}).notifications || {}).items || []);
|
||||
syncBossTabVisibility();
|
||||
updateBar(latestSections);
|
||||
if (!onDriftPage) {
|
||||
refreshDriftFromSummary();
|
||||
}
|
||||
updateActivityZone();
|
||||
const focusedId = document.activeElement && document.activeElement.id;
|
||||
const keepCurrentRender = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
||||
@ -143,6 +167,140 @@
|
||||
setVisibility(false);
|
||||
}
|
||||
|
||||
async function refreshDriftFromSummary() {
|
||||
if (driftSummaryRefreshTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
driftSummaryRefreshTimer = window.setTimeout(async function () {
|
||||
driftSummaryRefreshTimer = null;
|
||||
try {
|
||||
const parseBlacklistValue = function (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 (_) {
|
||||
values = raw.replace(/;/g, '\n').replace(/,/g, '\n').split('\n');
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
values.forEach(function (item) {
|
||||
const norm = String(item || '').trim().toLowerCase();
|
||||
if (!norm || seen.has(norm)) return;
|
||||
seen.add(norm);
|
||||
out.push(norm);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const isEventBlacklisted = function (event, blacklist) {
|
||||
if (!Array.isArray(blacklist) || !blacklist.length) return false;
|
||||
const tokens = new Set();
|
||||
const add = function (value) {
|
||||
const norm = String(value || '').trim().toLowerCase();
|
||||
if (norm) tokens.add(norm);
|
||||
};
|
||||
|
||||
const sourceEventId = String((event || {}).source_event_id || '').trim().toLowerCase();
|
||||
add(sourceEventId);
|
||||
if (sourceEventId.indexOf('uisp-') === 0) add(sourceEventId.slice(5));
|
||||
if (sourceEventId.indexOf('kuma-') === 0) 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(function (item) {
|
||||
return tokens.has(String(item || '').trim().toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
// Primary source: active drift events filtered client-side by blacklist.
|
||||
const [eventsRes, blacklistRes] = await Promise.all([
|
||||
fetch('/api/v1/drift/events?status=active&limit=500', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}),
|
||||
fetch('/api/v1/settings/drift_device_blacklist', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
]);
|
||||
|
||||
if (eventsRes.ok) {
|
||||
const eventsPayload = await eventsRes.json();
|
||||
const events = Array.isArray(eventsPayload) ? eventsPayload : [];
|
||||
let blacklist = [];
|
||||
if (blacklistRes.ok) {
|
||||
const settingPayload = await blacklistRes.json();
|
||||
blacklist = parseBlacklistValue(settingPayload && settingPayload.value ? settingPayload.value : '[]');
|
||||
}
|
||||
|
||||
const activeFiltered = events.filter(function (event) {
|
||||
return !isEventBlacklisted(event, blacklist);
|
||||
}).length;
|
||||
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.drift.down = activeFiltered;
|
||||
latestSections.kuma.down = activeFiltered;
|
||||
updateBar(latestSections);
|
||||
updateActivityZone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep drift chip aligned with bottom-bar backend status source first.
|
||||
const statusRes = await fetch('/api/v1/dashboard/status', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json();
|
||||
const activeFromStatus = Number(status.drift_active || 0);
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.drift.down = activeFromStatus;
|
||||
latestSections.kuma.down = activeFromStatus;
|
||||
updateBar(latestSections);
|
||||
updateActivityZone();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/drift/summary', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
const summary = await res.json();
|
||||
const active = Number(summary.active_alerts || 0);
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.drift.down = active;
|
||||
latestSections.kuma.down = active;
|
||||
updateBar(latestSections);
|
||||
updateActivityZone();
|
||||
} catch (_) {
|
||||
// Ignore drift summary fallback errors to avoid noisy UI logs.
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function setVisibility(enabled) {
|
||||
const shell = byId('globalBottomBar');
|
||||
if (!shell) {
|
||||
@ -169,6 +327,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('bb:setDriftCount', function (event) {
|
||||
const detail = (event && event.detail) || {};
|
||||
const count = Math.max(0, Number(detail.count || 0));
|
||||
latestSections = latestSections || {};
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.drift.down = count;
|
||||
latestSections.kuma.down = count;
|
||||
});
|
||||
|
||||
function setExpanded(expanded) {
|
||||
const shell = byId('globalBottomBar');
|
||||
const toggle = byId('bbSheetToggle');
|
||||
@ -197,7 +365,7 @@
|
||||
const cases = sections.cases || {};
|
||||
const urgent = sections.urgent || {};
|
||||
const timer = sections.timer || {};
|
||||
const kuma = sections.kuma || {};
|
||||
const drift = sections.drift || sections.kuma || {};
|
||||
const eset = sections.eset || {};
|
||||
const unassigned = sections.unassigned || {};
|
||||
|
||||
@ -207,7 +375,7 @@
|
||||
urgent: Number(urgent.count || 0),
|
||||
unassigned: Number(unassigned.count || 0),
|
||||
timer: Number(timer.active_count || 0),
|
||||
kuma: Number(kuma.down || 0),
|
||||
drift: Number(drift.down || 0),
|
||||
eset: Number(eset.incidents || 0)
|
||||
};
|
||||
}
|
||||
@ -220,7 +388,7 @@
|
||||
urgent: 'Hastesager',
|
||||
unassigned: 'Sager uden ansvarlig',
|
||||
timer: 'Aktive timere',
|
||||
kuma: 'Kuma alerts',
|
||||
drift: 'Drift alerts',
|
||||
eset: 'ESET incidents'
|
||||
};
|
||||
const val = counts[key] || 0;
|
||||
@ -229,6 +397,9 @@
|
||||
|
||||
function severityClassFor(key, value) {
|
||||
const val = Number(value || 0);
|
||||
if (key === 'drift') {
|
||||
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||||
}
|
||||
if (key === 'urgent') {
|
||||
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||||
}
|
||||
@ -251,7 +422,7 @@
|
||||
const urgent = sections.urgent || {};
|
||||
const unassigned = sections.unassigned || {};
|
||||
const timer = sections.timer || {};
|
||||
const kuma = sections.kuma || {};
|
||||
const drift = sections.drift || sections.kuma || {};
|
||||
const eset = sections.eset || {};
|
||||
const messages = sections.messages || {};
|
||||
const tasks = sections.tasks || {};
|
||||
@ -266,7 +437,7 @@
|
||||
|
||||
if (key === 'overview') {
|
||||
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc(u.title) + ' <br><button class="btn btn-sm btn-outline-danger mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Vis sag</button></div>') : ['Ingen hastesager.'];
|
||||
if (overviewFilter === 'kuma') return kuma.list ? kuma.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
|
||||
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
|
||||
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.'];
|
||||
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc(c.title) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(c.id || 0) + '">Vis sag</button></div>') : ['Ingen åbne sager.'];
|
||||
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></div>'];
|
||||
@ -276,7 +447,7 @@
|
||||
if (urgent.count > 0) out.push('<div><i class="bi bi-exclamation-octagon text-danger"></i> Hastesager: <strong>' + urgent.count + '</strong> aktive</div>');
|
||||
if (mail.unread > 0) out.push('<div><i class="bi bi-envelope text-primary"></i> Ubesvarede mails: <strong>' + mail.unread + '</strong></div>');
|
||||
if (cases.open > 0) out.push('<div><i class="bi bi-folder2-open text-primary"></i> Åbne sager i alt: <strong>' + cases.open + '</strong></div>');
|
||||
if (kuma.down > 0) out.push('<div><i class="bi bi-activity text-warning"></i> Uptime Kuma nedetid: <strong>' + kuma.down + '</strong> enheder</div>');
|
||||
if (drift.down > 0) out.push('<div><i class="bi bi-activity text-warning"></i> Drift nedetid: <strong>' + drift.down + '</strong> enheder</div>');
|
||||
if (eset.incidents > 0) out.push('<div><i class="bi bi-shield-lock text-danger"></i> ESET incidents: <strong>' + eset.incidents + '</strong></div>');
|
||||
|
||||
if (out.length === 0) {
|
||||
@ -474,7 +645,7 @@
|
||||
urgent: 'Hastesager',
|
||||
unassigned: 'Uden ansvarlig',
|
||||
timer: 'Timere',
|
||||
kuma: 'Kuma',
|
||||
drift: 'Drift',
|
||||
eset: 'ESET'
|
||||
};
|
||||
|
||||
@ -683,7 +854,8 @@
|
||||
mail: '/emails',
|
||||
urgent: '/sag?priority=urgent',
|
||||
timer: '/timetracking',
|
||||
cases: '/sag'
|
||||
cases: '/sag',
|
||||
drift: '/drift'
|
||||
};
|
||||
|
||||
if (key === 'unassigned') {
|
||||
@ -742,6 +914,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
||||
|
||||
if (payload.event === 'timer_tick') {
|
||||
const timer = payload.data || {};
|
||||
latestSections.timer = latestSections.timer || {};
|
||||
@ -762,6 +936,8 @@
|
||||
latestSections.cases = latestSections.cases || {};
|
||||
latestSections.urgent = latestSections.urgent || {};
|
||||
latestSections.unassigned = latestSections.unassigned || {};
|
||||
latestSections.drift = latestSections.drift || {};
|
||||
latestSections.kuma = latestSections.kuma || {};
|
||||
latestSections.boss = latestSections.boss || { stats: {} };
|
||||
|
||||
latestSections.mail.unread = Number(status.mails_unread || 0);
|
||||
@ -769,6 +945,15 @@
|
||||
latestSections.cases.open = Number(status.sager_open || 0);
|
||||
latestSections.urgent.count = Number(status.sager_urgent || 0);
|
||||
latestSections.unassigned.count = Number(status.sager_unassigned || 0);
|
||||
if (!onDriftPage && Object.prototype.hasOwnProperty.call(status, 'drift_active')) {
|
||||
latestSections.drift.down = Number(status.drift_active || 0);
|
||||
latestSections.kuma.down = Number(status.drift_active || 0);
|
||||
}
|
||||
// On /drift page, the page script keeps drift chip in sync with visible filtered events.
|
||||
if (!onDriftPage) {
|
||||
// Always reconcile against HTTP status/summary source to avoid stale WS drift counts.
|
||||
refreshDriftFromSummary();
|
||||
}
|
||||
latestSections.boss.stats = latestSections.boss.stats || {};
|
||||
latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0);
|
||||
}
|
||||
|
||||
@ -153,7 +153,7 @@ class TagPicker {
|
||||
async loadTags() {
|
||||
try {
|
||||
console.log('🏷️ Loading tags from API...');
|
||||
const response = await fetch('/api/v1/tags?is_active=true');
|
||||
const response = await fetch('/api/v1/tags?is_active=true', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to load tags');
|
||||
this.allTags = await response.json();
|
||||
// Tag groups are optional metadata. Some hubs do not expose the endpoint,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
(() => {
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDisabledForAuth = false;
|
||||
|
||||
function normalizeToken(value) {
|
||||
const token = String(value || '').trim();
|
||||
@ -30,6 +31,18 @@
|
||||
return fromCookie;
|
||||
}
|
||||
|
||||
async function hasActiveSession() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
return res.ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureContainer() {
|
||||
let container = document.getElementById('telefoni-toast-container');
|
||||
if (!container) {
|
||||
@ -762,14 +775,23 @@
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (reconnectTimer || reconnectDisabledForAuth) return;
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null;
|
||||
const sessionOk = await hasActiveSession();
|
||||
if (!sessionOk) {
|
||||
reconnectDisabledForAuth = true;
|
||||
console.info('📞 Telefoni WS reconnect paused (unauthorized session)');
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectDisabledForAuth) {
|
||||
return;
|
||||
}
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
@ -782,9 +804,17 @@
|
||||
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
||||
ws.onopen = () => {
|
||||
reconnectDisabledForAuth = false;
|
||||
console.log('📞 Telefoni WS connected');
|
||||
};
|
||||
ws.onclose = (evt) => {
|
||||
console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || '');
|
||||
if (evt && evt.code === 1008) {
|
||||
reconnectDisabledForAuth = true;
|
||||
console.info('📞 Telefoni WS reconnect paused (auth rejected by server)');
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
@ -803,8 +833,21 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', connect);
|
||||
window.addEventListener('focus', connect);
|
||||
window.addEventListener('focus', async () => {
|
||||
if (reconnectDisabledForAuth) {
|
||||
const sessionOk = await hasActiveSession();
|
||||
if (sessionOk) {
|
||||
reconnectDisabledForAuth = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
connect();
|
||||
});
|
||||
window.addEventListener('storage', (evt) => {
|
||||
if (evt.key === 'access_token') connect();
|
||||
if (evt.key === 'access_token') {
|
||||
reconnectDisabledForAuth = false;
|
||||
connect();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
85
tests/test_drift_uisp_support.py
Normal file
85
tests/test_drift_uisp_support.py
Normal file
@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
drift_router = importlib.import_module("app.modules.drift.backend.router")
|
||||
|
||||
|
||||
def test_get_uisp_connector_config_reads_settings():
|
||||
with patch.object(drift_router, "execute_query_single", side_effect=[
|
||||
{"value": "https://uisp.example.com"},
|
||||
{"value": "secret-token"},
|
||||
]):
|
||||
config = drift_router._get_uisp_connector_config()
|
||||
|
||||
assert config == {
|
||||
"base_url": "https://uisp.example.com",
|
||||
"api_token": "secret-token",
|
||||
}
|
||||
|
||||
|
||||
def test_list_unmapped_monitors_uses_simple_query_without_group_by_error():
|
||||
with patch.object(drift_router, "_ensure_schema"), patch.object(drift_router, "execute_query", return_value=[]) as mock_execute:
|
||||
asyncio.run(drift_router.list_unmapped_monitors(limit=5))
|
||||
|
||||
sql = mock_execute.call_args[0][0]
|
||||
assert "GROUP BY" not in sql
|
||||
assert "LIMIT %s" in sql
|
||||
|
||||
|
||||
def test_parse_blacklist_value_supports_json_and_dedupes():
|
||||
raw = '["airCube-AC", "AIRCUBE-AC", " uisp-123 "]'
|
||||
values = drift_router._parse_blacklist_value(raw)
|
||||
assert values == ["aircube-ac", "uisp-123"]
|
||||
|
||||
|
||||
def test_event_is_blacklisted_matches_device_name_and_source_id():
|
||||
payload = {
|
||||
"source_event_id": "uisp-abc-123",
|
||||
"device": "airCube-AC",
|
||||
"raw_json": {
|
||||
"raw_item": {
|
||||
"identification": {
|
||||
"id": "abc-123",
|
||||
"name": "airCube-AC",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
assert drift_router._event_is_blacklisted(payload, ["aircube-ac"])
|
||||
assert drift_router._event_is_blacklisted(payload, ["abc-123"])
|
||||
assert not drift_router._event_is_blacklisted(payload, ["some-other-device"])
|
||||
|
||||
|
||||
def test_build_events_from_uisp_payload_exposes_direct_device_link():
|
||||
events = drift_router._build_events_from_uisp_payload(
|
||||
[
|
||||
{
|
||||
"identification": {"id": "abc-123", "name": "airCube-AC"},
|
||||
"url": "https://uisp.example.com/nms/devices/abc-123",
|
||||
}
|
||||
],
|
||||
source_id=1,
|
||||
base_url="https://uisp.example.com",
|
||||
)
|
||||
|
||||
assert events[0]["raw_json"]["device_link"] == "https://uisp.example.com/nms/devices/abc-123"
|
||||
|
||||
|
||||
def test_row_to_event_extracts_device_link_from_existing_raw_payload():
|
||||
event = drift_router._row_to_event(
|
||||
{
|
||||
"id": 1,
|
||||
"source_event_id": "uisp-abc-123",
|
||||
"raw_json": {
|
||||
"raw_item": {
|
||||
"url": "https://uisp.example.com/nms/devices/abc-123"
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert event["device_link"] == "https://uisp.example.com/nms/devices/abc-123"
|
||||
assert event["source_link"] == "https://uisp.example.com/nms/devices/abc-123"
|
||||
30
tests/test_telefoni_call_logging.py
Normal file
30
tests/test_telefoni_call_logging.py
Normal file
@ -0,0 +1,30 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.modules.telefoni.backend.service import TelefoniService
|
||||
|
||||
|
||||
def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_execute_query(query, params=(), *args, **kwargs):
|
||||
calls.append((query, params))
|
||||
return [{"id": 1}]
|
||||
|
||||
def fake_upsert_call(*, callid, user_id, direction, ekstern_nummer, intern_extension, kontakt_id, raw_payload, started_at):
|
||||
return {
|
||||
"id": 42,
|
||||
"callid": callid,
|
||||
"direction": direction,
|
||||
"started_at": started_at,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.modules.telefoni.backend.service.execute_query", fake_execute_query)
|
||||
monkeypatch.setattr(TelefoniService, "upsert_call", staticmethod(fake_upsert_call))
|
||||
|
||||
result = TelefoniService.terminate_call("call-123", 120)
|
||||
|
||||
assert result is True
|
||||
assert any("INSERT INTO telefoni_opkald" in query for query, _ in calls)
|
||||
Loading…
Reference in New Issue
Block a user