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
|
<i class="bi bi-mic"></i>Samtaler
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1200,6 +1205,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1733,6 +1781,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { 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
|
// Load Nextcloud status when tab is shown
|
||||||
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
||||||
if (nextcloudTab) {
|
if (nextcloudTab) {
|
||||||
@ -4053,6 +4109,61 @@ async function loadActivity() {
|
|||||||
}, 500);
|
}, 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() {
|
async function loadCustomerKontakt() {
|
||||||
const container = document.getElementById('customerKontaktContainer');
|
const container = document.getElementById('customerKontaktContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single
|
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__)
|
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")]
|
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]:
|
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return []
|
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:
|
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
||||||
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
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()
|
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
|
return False
|
||||||
|
|
||||||
if user_id is None:
|
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 {
|
return {
|
||||||
"mails_unread": mails_unread,
|
"mails_unread": mails_unread,
|
||||||
"sager_open": sager_open,
|
"sager_open": sager_open,
|
||||||
"sager_urgent": sager_urgent,
|
"sager_urgent": sager_urgent,
|
||||||
"sager_unassigned": sager_unassigned,
|
"sager_unassigned": sager_unassigned,
|
||||||
|
"drift_active": drift_active,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -675,6 +731,7 @@ def build_bottom_bar_state(
|
|||||||
timer = get_active_timer(user_id)
|
timer = get_active_timer(user_id)
|
||||||
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
||||||
notifications = get_notifications(user_id, 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)
|
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||||
recent_cases = _get_recent_cases(user_id, limit=10)
|
recent_cases = _get_recent_cases(user_id, limit=10)
|
||||||
notes_summary = get_user_notes_summary(user_id, limit=10)
|
notes_summary = get_user_notes_summary(user_id, limit=10)
|
||||||
@ -887,8 +944,12 @@ def build_bottom_bar_state(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"kuma": {
|
"kuma": {
|
||||||
"down": 0,
|
"down": int(drift_status.get("down") or 0),
|
||||||
"list": [],
|
"list": drift_status.get("list") or [],
|
||||||
|
},
|
||||||
|
"drift": {
|
||||||
|
"down": int(drift_status.get("down") or 0),
|
||||||
|
"list": drift_status.get("list") or [],
|
||||||
},
|
},
|
||||||
"eset": {
|
"eset": {
|
||||||
"incidents": 0,
|
"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:
|
def terminate_call(callid: str, duration_sec: Optional[int]) -> bool:
|
||||||
if not callid:
|
if not callid:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""
|
"""
|
||||||
UPDATE telefoni_opkald
|
INSERT INTO telefoni_opkald
|
||||||
SET ended_at = NOW(),
|
(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(
|
duration_sec = COALESCE(
|
||||||
%s,
|
EXCLUDED.duration_sec,
|
||||||
CASE
|
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
|
ELSE NULL
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
WHERE callid = %s
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(duration_sec, callid),
|
(callid, duration_sec),
|
||||||
)
|
)
|
||||||
return bool(rows)
|
return bool(rows)
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,15 @@ class SettingUpdate(BaseModel):
|
|||||||
value: str
|
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):
|
class User(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
@ -105,6 +114,41 @@ async def get_settings(category: Optional[str] = None):
|
|||||||
return result or []
|
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"])
|
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
|
||||||
async def get_setting(key: str):
|
async def get_setting(key: str):
|
||||||
"""Get a specific setting by key"""
|
"""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),
|
(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.
|
# 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"}:
|
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
|
||||||
defaults = {
|
defaults = {
|
||||||
|
|||||||
@ -80,6 +80,9 @@
|
|||||||
<a class="nav-link active" href="#company" data-tab="company">
|
<a class="nav-link active" href="#company" data-tab="company">
|
||||||
<i class="bi bi-building me-2"></i>Firma
|
<i class="bi bi-building me-2"></i>Firma
|
||||||
</a>
|
</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">
|
<a class="nav-link" href="#integrations" data-tab="integrations">
|
||||||
<i class="bi bi-plugin me-2"></i>Integrationer
|
<i class="bi bi-plugin me-2"></i>Integrationer
|
||||||
</a>
|
</a>
|
||||||
@ -147,6 +150,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Integrations -->
|
||||||
<div class="tab-pane fade" id="integrations">
|
<div class="tab-pane fade" id="integrations">
|
||||||
<div class="card p-4 mb-4">
|
<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() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/settings');
|
const response = await fetch('/api/v1/settings', { credentials: 'include' });
|
||||||
allSettings = await response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger'));
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
allSettings = Array.isArray(payload) ? payload : [];
|
||||||
displaySettingsByCategory();
|
displaySettingsByCategory();
|
||||||
loadTimeMultiplierPresets();
|
loadTimeMultiplierPresets();
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
@ -2368,6 +2474,9 @@ async function loadSettings() {
|
|||||||
await loadTagsManagement();
|
await loadTagsManagement();
|
||||||
await loadNextcloudInstances();
|
await loadNextcloudInstances();
|
||||||
await loadAnydeskSettings();
|
await loadAnydeskSettings();
|
||||||
|
renderDriftConnectors();
|
||||||
|
await loadUptimeKumaSettings();
|
||||||
|
await loadUISPSettings();
|
||||||
await loadLabelPrinterSettings();
|
await loadLabelPrinterSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', 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() {
|
async function loadLabelPrinterSettings() {
|
||||||
const keys = [
|
const keys = [
|
||||||
'label_printer_enabled',
|
'label_printer_enabled',
|
||||||
@ -2653,6 +2974,7 @@ async function saveLabelPrinterSettings() {
|
|||||||
const putSettingStrict = async (key, value) => {
|
const putSettingStrict = async (key, value) => {
|
||||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ value: String(value) })
|
body: JSON.stringify({ value: String(value) })
|
||||||
});
|
});
|
||||||
@ -2964,17 +3286,29 @@ async function updateSetting(key, value) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ value })
|
body: JSON.stringify({ value })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.status === 404 || response.status === 405) {
|
||||||
// Show success toast
|
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}`);
|
console.log(`✅ Updated ${key}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(await getErrorMessage(response, 'Kunne ikke opdatere indstilling'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating setting:', 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() {
|
async function loadPipelineStages() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/pipeline/stages');
|
const response = await fetch('/api/v1/pipeline/stages');
|
||||||
const stages = await response.json();
|
if (!response.ok) {
|
||||||
pipelineStagesCache = stages || [];
|
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);
|
renderPipelineStages(pipelineStagesCache);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading pipeline stages:', error);
|
console.error('Error loading pipeline stages:', error);
|
||||||
|
pipelineStagesCache = [];
|
||||||
|
renderPipelineStages(pipelineStagesCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5932,6 +6272,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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 taskTemplatesCache = [];
|
||||||
let taskTemplateCustomersCache = [];
|
let taskTemplateCustomersCache = [];
|
||||||
let selectedTaskTemplateId = null;
|
let selectedTaskTemplateId = null;
|
||||||
@ -6581,7 +6930,11 @@ async function loadEmailTemplates() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
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 = '';
|
tbody.innerHTML = '';
|
||||||
if (templates.length === 0) {
|
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="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="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>
|
<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>
|
||||||
<div class="bb-zone bb-zone-center">
|
<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)">
|
<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();
|
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>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></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/tag-picker.js?v=2.2"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></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/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/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.4"></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>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
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.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||||
from app.modules.rentals.backend import router as rentals_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.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
|
from app.bug_reports.backend import router as bug_reports_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@ -251,6 +254,16 @@ async def lifespan(app: FastAPI):
|
|||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
logger.info("✅ Links health job scheduled (every %d minutes)", settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES)
|
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")
|
logger.info("✅ System initialized successfully")
|
||||||
yield
|
yield
|
||||||
@ -297,7 +310,14 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
public_paths = {
|
public_paths = {
|
||||||
"/health",
|
"/health",
|
||||||
"/login",
|
"/login",
|
||||||
|
"/drift",
|
||||||
"/api/v1/auth/login",
|
"/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",
|
||||||
"/mission/pin/verify",
|
"/mission/pin/verify",
|
||||||
"/mission/pin/logout",
|
"/mission/pin/logout",
|
||||||
@ -308,6 +328,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
"/api/v1/mission/webhook/uptime",
|
"/api/v1/mission/webhook/uptime",
|
||||||
"/api/v1/mission/webhook/camera/",
|
"/api/v1/mission/webhook/camera/",
|
||||||
"/api/v1/mission/webhook/environment/",
|
"/api/v1/mission/webhook/environment/",
|
||||||
|
"/api/v1/drift/",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
# 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"}
|
content={"detail": "Invalid token"}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
user_id = int(sub_value)
|
int(sub_value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -388,29 +409,6 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
content={"detail": "Invalid token"}
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
# Include routers
|
# 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(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(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(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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
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(fedex_views.router, tags=["Frontend"])
|
||||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||||
app.include_router(manual_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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.frontend import views as links_views
|
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';
|
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
||||||
let notesApiUnavailable = false;
|
let notesApiUnavailable = false;
|
||||||
|
let driftSummaryRefreshTimer = null;
|
||||||
|
|
||||||
function byId(id) {
|
function byId(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@ -124,13 +125,36 @@
|
|||||||
|
|
||||||
function applyState(data) {
|
function applyState(data) {
|
||||||
if (data && data.enabled) {
|
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 || {};
|
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);
|
latestSections = hydrateNotesFromLocalIfNeeded(latestSections);
|
||||||
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
|
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
|
||||||
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
|
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
|
||||||
latestNotifications = (((data || {}).notifications || {}).items || []);
|
latestNotifications = (((data || {}).notifications || {}).items || []);
|
||||||
syncBossTabVisibility();
|
syncBossTabVisibility();
|
||||||
updateBar(latestSections);
|
updateBar(latestSections);
|
||||||
|
if (!onDriftPage) {
|
||||||
|
refreshDriftFromSummary();
|
||||||
|
}
|
||||||
updateActivityZone();
|
updateActivityZone();
|
||||||
const focusedId = document.activeElement && document.activeElement.id;
|
const focusedId = document.activeElement && document.activeElement.id;
|
||||||
const keepCurrentRender = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
const keepCurrentRender = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
||||||
@ -143,6 +167,140 @@
|
|||||||
setVisibility(false);
|
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) {
|
function setVisibility(enabled) {
|
||||||
const shell = byId('globalBottomBar');
|
const shell = byId('globalBottomBar');
|
||||||
if (!shell) {
|
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) {
|
function setExpanded(expanded) {
|
||||||
const shell = byId('globalBottomBar');
|
const shell = byId('globalBottomBar');
|
||||||
const toggle = byId('bbSheetToggle');
|
const toggle = byId('bbSheetToggle');
|
||||||
@ -197,7 +365,7 @@
|
|||||||
const cases = sections.cases || {};
|
const cases = sections.cases || {};
|
||||||
const urgent = sections.urgent || {};
|
const urgent = sections.urgent || {};
|
||||||
const timer = sections.timer || {};
|
const timer = sections.timer || {};
|
||||||
const kuma = sections.kuma || {};
|
const drift = sections.drift || sections.kuma || {};
|
||||||
const eset = sections.eset || {};
|
const eset = sections.eset || {};
|
||||||
const unassigned = sections.unassigned || {};
|
const unassigned = sections.unassigned || {};
|
||||||
|
|
||||||
@ -207,7 +375,7 @@
|
|||||||
urgent: Number(urgent.count || 0),
|
urgent: Number(urgent.count || 0),
|
||||||
unassigned: Number(unassigned.count || 0),
|
unassigned: Number(unassigned.count || 0),
|
||||||
timer: Number(timer.active_count || 0),
|
timer: Number(timer.active_count || 0),
|
||||||
kuma: Number(kuma.down || 0),
|
drift: Number(drift.down || 0),
|
||||||
eset: Number(eset.incidents || 0)
|
eset: Number(eset.incidents || 0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -220,7 +388,7 @@
|
|||||||
urgent: 'Hastesager',
|
urgent: 'Hastesager',
|
||||||
unassigned: 'Sager uden ansvarlig',
|
unassigned: 'Sager uden ansvarlig',
|
||||||
timer: 'Aktive timere',
|
timer: 'Aktive timere',
|
||||||
kuma: 'Kuma alerts',
|
drift: 'Drift alerts',
|
||||||
eset: 'ESET incidents'
|
eset: 'ESET incidents'
|
||||||
};
|
};
|
||||||
const val = counts[key] || 0;
|
const val = counts[key] || 0;
|
||||||
@ -229,6 +397,9 @@
|
|||||||
|
|
||||||
function severityClassFor(key, value) {
|
function severityClassFor(key, value) {
|
||||||
const val = Number(value || 0);
|
const val = Number(value || 0);
|
||||||
|
if (key === 'drift') {
|
||||||
|
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||||||
|
}
|
||||||
if (key === 'urgent') {
|
if (key === 'urgent') {
|
||||||
return val > 0 ? 'sev-critical' : 'sev-ok';
|
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||||||
}
|
}
|
||||||
@ -251,7 +422,7 @@
|
|||||||
const urgent = sections.urgent || {};
|
const urgent = sections.urgent || {};
|
||||||
const unassigned = sections.unassigned || {};
|
const unassigned = sections.unassigned || {};
|
||||||
const timer = sections.timer || {};
|
const timer = sections.timer || {};
|
||||||
const kuma = sections.kuma || {};
|
const drift = sections.drift || sections.kuma || {};
|
||||||
const eset = sections.eset || {};
|
const eset = sections.eset || {};
|
||||||
const messages = sections.messages || {};
|
const messages = sections.messages || {};
|
||||||
const tasks = sections.tasks || {};
|
const tasks = sections.tasks || {};
|
||||||
@ -266,7 +437,7 @@
|
|||||||
|
|
||||||
if (key === 'overview') {
|
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 === '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 === '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 === '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>'];
|
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 (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 (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 (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 (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) {
|
if (out.length === 0) {
|
||||||
@ -474,7 +645,7 @@
|
|||||||
urgent: 'Hastesager',
|
urgent: 'Hastesager',
|
||||||
unassigned: 'Uden ansvarlig',
|
unassigned: 'Uden ansvarlig',
|
||||||
timer: 'Timere',
|
timer: 'Timere',
|
||||||
kuma: 'Kuma',
|
drift: 'Drift',
|
||||||
eset: 'ESET'
|
eset: 'ESET'
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -683,7 +854,8 @@
|
|||||||
mail: '/emails',
|
mail: '/emails',
|
||||||
urgent: '/sag?priority=urgent',
|
urgent: '/sag?priority=urgent',
|
||||||
timer: '/timetracking',
|
timer: '/timetracking',
|
||||||
cases: '/sag'
|
cases: '/sag',
|
||||||
|
drift: '/drift'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (key === 'unassigned') {
|
if (key === 'unassigned') {
|
||||||
@ -742,6 +914,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
||||||
|
|
||||||
if (payload.event === 'timer_tick') {
|
if (payload.event === 'timer_tick') {
|
||||||
const timer = payload.data || {};
|
const timer = payload.data || {};
|
||||||
latestSections.timer = latestSections.timer || {};
|
latestSections.timer = latestSections.timer || {};
|
||||||
@ -762,6 +936,8 @@
|
|||||||
latestSections.cases = latestSections.cases || {};
|
latestSections.cases = latestSections.cases || {};
|
||||||
latestSections.urgent = latestSections.urgent || {};
|
latestSections.urgent = latestSections.urgent || {};
|
||||||
latestSections.unassigned = latestSections.unassigned || {};
|
latestSections.unassigned = latestSections.unassigned || {};
|
||||||
|
latestSections.drift = latestSections.drift || {};
|
||||||
|
latestSections.kuma = latestSections.kuma || {};
|
||||||
latestSections.boss = latestSections.boss || { stats: {} };
|
latestSections.boss = latestSections.boss || { stats: {} };
|
||||||
|
|
||||||
latestSections.mail.unread = Number(status.mails_unread || 0);
|
latestSections.mail.unread = Number(status.mails_unread || 0);
|
||||||
@ -769,6 +945,15 @@
|
|||||||
latestSections.cases.open = Number(status.sager_open || 0);
|
latestSections.cases.open = Number(status.sager_open || 0);
|
||||||
latestSections.urgent.count = Number(status.sager_urgent || 0);
|
latestSections.urgent.count = Number(status.sager_urgent || 0);
|
||||||
latestSections.unassigned.count = Number(status.sager_unassigned || 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 = latestSections.boss.stats || {};
|
||||||
latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0);
|
latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,7 +153,7 @@ class TagPicker {
|
|||||||
async loadTags() {
|
async loadTags() {
|
||||||
try {
|
try {
|
||||||
console.log('🏷️ Loading tags from API...');
|
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');
|
if (!response.ok) throw new Error('Failed to load tags');
|
||||||
this.allTags = await response.json();
|
this.allTags = await response.json();
|
||||||
// Tag groups are optional metadata. Some hubs do not expose the endpoint,
|
// Tag groups are optional metadata. Some hubs do not expose the endpoint,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
|
let reconnectDisabledForAuth = false;
|
||||||
|
|
||||||
function normalizeToken(value) {
|
function normalizeToken(value) {
|
||||||
const token = String(value || '').trim();
|
const token = String(value || '').trim();
|
||||||
@ -30,6 +31,18 @@
|
|||||||
return fromCookie;
|
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() {
|
function ensureContainer() {
|
||||||
let container = document.getElementById('telefoni-toast-container');
|
let container = document.getElementById('telefoni-toast-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@ -762,14 +775,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
if (reconnectTimer) return;
|
if (reconnectTimer || reconnectDisabledForAuth) return;
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(async () => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
|
const sessionOk = await hasActiveSession();
|
||||||
|
if (!sessionOk) {
|
||||||
|
reconnectDisabledForAuth = true;
|
||||||
|
console.info('📞 Telefoni WS reconnect paused (unauthorized session)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
connect();
|
connect();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
if (reconnectDisabledForAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -782,9 +804,17 @@
|
|||||||
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
|
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
ws.onopen = () => {
|
||||||
|
reconnectDisabledForAuth = false;
|
||||||
|
console.log('📞 Telefoni WS connected');
|
||||||
|
};
|
||||||
ws.onclose = (evt) => {
|
ws.onclose = (evt) => {
|
||||||
console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || '');
|
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();
|
scheduleReconnect();
|
||||||
};
|
};
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
@ -803,8 +833,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', connect);
|
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) => {
|
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