diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index af6dcf8..b8c5182 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -517,6 +517,11 @@ Samtaler + @@ -1200,6 +1205,49 @@ + + +
+
+
Drift historik
+ +
+ +
+
+
Aktive
0
+
+
+
Kritiske
0
+
+
+
Godkendte
0
+
+
+
Løste
0
+
+
+ +
+ + + + + + + + + + + + + + +
StatusSeverityEnhedBeskedStartKilde
Åbn fanen for at hente drift-data...
+
+
@@ -1733,6 +1781,14 @@ document.addEventListener('DOMContentLoaded', () => { }, { once: false }); } + // Load drift when tab is shown + const driftTab = document.querySelector('a[href="#drift"]'); + if (driftTab) { + driftTab.addEventListener('shown.bs.tab', () => { + loadCustomerDrift(); + }, { once: false }); + } + // Load Nextcloud status when tab is shown const nextcloudTab = document.querySelector('a[href="#nextcloud"]'); if (nextcloudTab) { @@ -4053,6 +4109,61 @@ async function loadActivity() { }, 500); } +async function loadCustomerDrift() { + const body = document.getElementById('customerDriftEventsBody'); + if (!body) return; + + body.innerHTML = 'Indlæser drift-data...'; + + 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 = 'Ingen drift-hændelser fundet for kunden.'; + 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 + ? `` + : '-'; + + return ` + + ${status} + ${severity} + ${event.device || '-'} + ${event.message || '-'} + ${startText} + ${sourceLink} + + `; + }).join(''); + } catch (error) { + console.error('Failed to load customer drift:', error); + body.innerHTML = 'Kunne ikke indlæse drift-data.'; + } +} + async function loadCustomerKontakt() { const container = document.getElementById('customerKontaktContainer'); if (!container) return; diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index 20f6116..0c70137 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional from app.core.database import execute_query, execute_query_single +from app.modules.drift.backend.router import _event_blacklist_candidates, _get_drift_device_blacklist logger = logging.getLogger(__name__) @@ -64,6 +65,54 @@ def _table_columns(table_name: str) -> List[str]: return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")] +def get_drift_status(limit: int = 5) -> Dict[str, Any]: + rel_row = execute_query_single( + """ + SELECT COALESCE( + to_regclass('drift_events')::text, + to_regclass('public.drift_events')::text + ) AS rel + """ + ) or {} + rel_name = str(rel_row.get("rel") or "").strip() + if not rel_name: + return {"down": 0, "list": []} + + rows = execute_query( + f""" + SELECT device_name, message, source_event_id, raw_json + FROM {rel_name} + WHERE ( + COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new') + OR ( + resolved_at IS NULL + AND COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') NOT IN ('resolved', 'acknowledged') + ) + ) + ORDER BY COALESCE(started_at, created_at) DESC NULLS LAST, id DESC + """, + ) or [] + + blacklist = _get_drift_device_blacklist() + blacklist_set = set(blacklist) + filtered_rows: List[Dict[str, Any]] = [] + for row in rows: + candidates = set(_event_blacklist_candidates(row)) + if any(item in candidates for item in blacklist_set): + continue + filtered_rows.append(row) + + top_items = filtered_rows[: max(1, int(limit or 5))] + + return { + "down": len(filtered_rows), + "list": [ + str((r.get("device_name") or r.get("message") or "Ukendt alarm")).strip() + for r in top_items + ], + } + + def _get_user_group_names(user_id: Optional[int]) -> List[str]: if user_id is None: return [] @@ -107,8 +156,12 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool: def is_bottom_bar_enabled(user_id: Optional[int]) -> bool: setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",)) + if not setting: + # Default to enabled if the setting row is missing on older hubs. + return True + setting_value = str((setting or {}).get("value") or "").strip().lower() - if setting_value not in {"1", "true", "yes", "on"}: + if setting_value in {"0", "false", "no", "off"}: return False if user_id is None: @@ -191,11 +244,14 @@ def get_dashboard_status() -> Dict[str, int]: ) ) + drift_active = int(get_drift_status(limit=1).get("down") or 0) + return { "mails_unread": mails_unread, "sager_open": sager_open, "sager_urgent": sager_urgent, "sager_unassigned": sager_unassigned, + "drift_active": drift_active, } @@ -675,6 +731,7 @@ def build_bottom_bar_state( timer = get_active_timer(user_id) own_timers = get_own_timer_snapshot(user_id, paused_limit=10) notifications = get_notifications(user_id, limit=10) + drift_status = get_drift_status(limit=5) unassigned_open_cases = get_unassigned_open_cases(limit=8) recent_cases = _get_recent_cases(user_id, limit=10) notes_summary = get_user_notes_summary(user_id, limit=10) @@ -887,8 +944,12 @@ def build_bottom_bar_state( }, }, "kuma": { - "down": 0, - "list": [], + "down": int(drift_status.get("down") or 0), + "list": drift_status.get("list") or [], + }, + "drift": { + "down": int(drift_status.get("down") or 0), + "list": drift_status.get("list") or [], }, "eset": { "incidents": 0, diff --git a/app/modules/drift/backend/__init__.py b/app/modules/drift/backend/__init__.py new file mode 100644 index 0000000..2378043 --- /dev/null +++ b/app/modules/drift/backend/__init__.py @@ -0,0 +1 @@ +from .router import router diff --git a/app/modules/drift/backend/router.py b/app/modules/drift/backend/router.py new file mode 100644 index 0000000..27e0b92 --- /dev/null +++ b/app/modules/drift/backend/router.py @@ -0,0 +1,1714 @@ +import logging +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Set +from urllib.parse import urlparse + +import httpx +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class DriftTicketPayload(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + customer_id: Optional[int] = None + + +class DriftTicketLinkPayload(BaseModel): + sag_id: int + + +class DriftSyncPayload(BaseModel): + source: Optional[str] = "uptime-kuma" + + +class DriftCustomerMappingPayload(BaseModel): + monitor_key: Optional[str] = None + monitor_name: Optional[str] = None + customer_id: int + + +class DriftBlacklistPayload(BaseModel): + value: Optional[str] = None + + +def run_uptime_kuma_sync() -> Dict[str, Any]: + """Run a live Uptime Kuma sync and return summary data for jobs/endpoints.""" + _ensure_schema() + _purge_demo_events() + source = _ensure_source() + config = _get_drift_connector_config() + base_url = config.get("base_url") or "" + api_key = config.get("api_key") or "" + + if not base_url or not api_key: + logger.info("⚠️ Drift Uptime Kuma sync skipped: connector not configured") + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "Uptime Kuma connector mangler base_url eller api_key. Ingen demo-data oprettes.", + } + + metrics_text = _fetch_uptime_kuma_metrics(base_url, api_key) + events = _build_events_from_metrics(metrics_text, source.get("id")) + + if not events: + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "Ingen monitor-metrics fundet i /metrics respons.", + } + + for item in events: + _upsert_event_from_payload(source.get("id"), item) + + return { + "synced": len(events), + "source": source.get("name"), + "mode": "live", + } + + +def _normalize_status(value: Optional[str]) -> str: + if not value: + return "new" + value = str(value).strip().lower() + mapping = { + "down": "active", + "up": "resolved", + "active": "active", + "resolved": "resolved", + "acknowledged": "acknowledged", + "warning": "warning", + "critical": "critical", + "info": "info", + } + return mapping.get(value, value) + + +def _normalize_severity(value: Optional[str]) -> str: + if not value: + return "info" + value = str(value).strip().lower() + mapping = {"critical": "critical", "high": "critical", "warning": "warning", "warn": "warning", "info": "info", "down": "critical"} + return mapping.get(value, value) + + +def _normalize_external_link(value: Optional[str], base_url: Optional[str] = None) -> Optional[str]: + raw = str(value or "").strip() + if not raw or raw.lower() == "null": + return None + if raw.startswith(("http://", "https://")): + return raw + if not base_url: + return None + base = str(base_url or "").strip().rstrip("/") + if not base: + return None + if raw.startswith("/"): + return f"{base}{raw}" + return f"{base}/{raw.lstrip('/')}" + + +def _extract_device_link(item: Dict[str, Any], base_url: Optional[str] = None, external_id: Optional[str] = None) -> Optional[str]: + if not isinstance(item, dict): + return None + + candidates: List[Optional[str]] = [] + for key in ("url", "href", "link"): + candidates.append(item.get(key)) + + nested = item.get("identification") if isinstance(item.get("identification"), dict) else {} + for key in ("url", "href", "link"): + candidates.append(nested.get(key)) + + overview = item.get("overview") if isinstance(item.get("overview"), dict) else {} + for key in ("url", "href", "link"): + candidates.append(overview.get(key)) + + for candidate in candidates: + normalized = _normalize_external_link(candidate, base_url) + if normalized: + return normalized + + if base_url and external_id: + for suffix in (f"/nms/devices/{external_id}", f"/devices/{external_id}", f"/nms/devices/{external_id}/"): + normalized = _normalize_external_link(suffix, base_url) + if normalized: + return normalized + + return None + + +def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]: + def _coerce_dt(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + raw_value = str(value or "").strip() + if not raw_value: + return None + try: + return datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + return None + + started = row.get("started_at") + updated = row.get("updated_at") + resolved = row.get("resolved_at") + raw = row.get("raw_json") if isinstance(row.get("raw_json"), dict) else {} + raw_item = raw.get("raw_item") if isinstance(raw.get("raw_item"), dict) else {} + raw_overview = raw_item.get("overview") if isinstance(raw_item.get("overview"), dict) else {} + nested_overview = raw.get("overview") if isinstance(raw.get("overview"), dict) else {} + last_seen = ( + raw.get("last_seen") + or raw_overview.get("lastSeen") + or raw_overview.get("last_seen") + or nested_overview.get("lastSeen") + or nested_overview.get("last_seen") + or updated + ) + duration_minutes = row.get("duration_minutes") + if duration_minutes is None: + started_dt = _coerce_dt(started) + end_dt = _coerce_dt(resolved) or _coerce_dt(updated) or datetime.now(timezone.utc) + if started_dt: + duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0) + + source_event_id = row.get("source_event_id") or "" + monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id + + raw_source_link = raw.get("source_link") or raw.get("device_link") + if not raw_source_link and isinstance(raw.get("raw_item"), dict): + raw_source_link = _extract_device_link(raw.get("raw_item"), None, source_event_id[5:] if source_event_id.startswith(("uisp-", "kuma-")) else None) + + return { + "id": row.get("id"), + "source": row.get("source") or row.get("source_name") or "Ukendt", + "source_event_id": source_event_id, + "monitor_key": monitor_key, + "severity": row.get("severity"), + "status": row.get("status"), + "customer": row.get("customer_name"), + "customer_id": raw.get("customer_id"), + "site": row.get("site_name"), + "device": row.get("device_name"), + "service": row.get("service_name"), + "source_link": raw_source_link, + "device_link": raw_source_link, + "message": row.get("message"), + "started": started, + "updated": updated, + "resolved": resolved, + "last_seen": last_seen, + "duration_minutes": duration_minutes, + "raw_json": row.get("raw_json"), + "ticket_id": row.get("ticket_id"), + "created_at": row.get("created_at"), + "updated_at": row.get("updated_at"), + "tags": row.get("tags") or [], + "history": row.get("history") or [], + } + + +def _ensure_schema() -> None: + execute_query( + """ + 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 + ) + """ + ) + execute_query( + """ + 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) + ) + """ + ) + execute_query( + """ + 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, + 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, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + execute_query( + """ + 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 + ) + """ + ) + execute_query( + """ + 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) + ) + """ + ) + execute_query( + """ + 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) + ) + """ + ) + + +def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]: + row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,)) + if not row: + return default + value = row.get("value") + if value is None: + return default + return str(value) + + +def _get_drift_connector_config() -> Dict[str, str]: + return { + "base_url": (_get_setting_value("drift_uptime_kuma_base_url") or "").strip(), + "api_key": (_get_setting_value("drift_uptime_kuma_api_key") or "").strip(), + } + + +def _get_uisp_connector_config() -> Dict[str, str]: + return { + "base_url": (_get_setting_value("drift_uisp_base_url") or "").strip(), + "api_token": (_get_setting_value("drift_uisp_api_token") or "").strip(), + } + + +def _parse_blacklist_value(raw_value: Optional[str]) -> List[str]: + if raw_value is None: + return [] + raw_text = str(raw_value).strip() + if not raw_text: + return [] + + values: List[str] = [] + try: + parsed = json.loads(raw_text) + if isinstance(parsed, list): + values = [str(item or "") for item in parsed] + elif isinstance(parsed, str): + values = [parsed] + except (TypeError, ValueError, json.JSONDecodeError): + values = [chunk for chunk in raw_text.replace(";", "\n").replace(",", "\n").splitlines()] + + cleaned: List[str] = [] + seen: Set[str] = set() + for value in values: + normalized = str(value or "").strip().lower() + if not normalized or normalized in seen: + continue + seen.add(normalized) + cleaned.append(normalized) + return cleaned + + +def _get_drift_device_blacklist() -> List[str]: + return _parse_blacklist_value(_get_setting_value("drift_device_blacklist", "")) + + +def _save_drift_device_blacklist(values: List[str]) -> None: + normalized = _parse_blacklist_value(json.dumps(values, ensure_ascii=False)) + value_json = json.dumps(normalized, ensure_ascii=False) + 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 + """, + ( + "drift_device_blacklist", + value_json, + "integrations", + "JSON array of Drift device identifiers/names to ignore during sync", + "text", + False, + ), + ) + + +def _event_blacklist_candidates(row: Dict[str, Any]) -> List[str]: + raw_json = row.get("raw_json") if isinstance(row.get("raw_json"), dict) else {} + raw_item = raw_json.get("raw_item") if isinstance(raw_json.get("raw_item"), dict) else {} + identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {} + + candidates = [ + row.get("device_name"), + row.get("source_event_id"), + identification.get("id"), + identification.get("name"), + identification.get("hostname"), + identification.get("mac"), + ] + + values: List[str] = [] + seen: set[str] = set() + for candidate in candidates: + normalized = str(candidate or "").strip().lower() + if not normalized or normalized in seen: + continue + if normalized.startswith("uisp-"): + seen.add(normalized[5:]) + values.append(normalized[5:]) + if normalized.startswith("kuma-"): + seen.add(normalized[5:]) + values.append(normalized[5:]) + seen.add(normalized) + values.append(normalized) + return values + + +def _event_is_blacklisted(payload: Dict[str, Any], blacklist: List[str]) -> bool: + if not blacklist: + return False + + token_set: Set[str] = set() + + def _add(value: Any) -> None: + normalized = str(value or "").strip().lower() + if not normalized: + return + token_set.add(normalized) + + source_event_id = str(payload.get("source_event_id") or "").strip() + _add(source_event_id) + if source_event_id.startswith("kuma-"): + _add(source_event_id[5:]) + if source_event_id.startswith("uisp-"): + _add(source_event_id[5:]) + + _add(payload.get("device")) + + raw_json = payload.get("raw_json") if isinstance(payload.get("raw_json"), dict) else {} + labels = raw_json.get("labels") if isinstance(raw_json.get("labels"), dict) else {} + for key in ("monitor_name", "name", "monitor_id", "id", "monitor_hostname"): + _add(labels.get(key)) + + raw_item = raw_json.get("raw_item") if isinstance(raw_json.get("raw_item"), dict) else {} + identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {} + for key in ("id", "name", "hostname", "mac"): + _add(identification.get(key)) + + return any(item in token_set for item in blacklist) + + +def _filter_out_blacklisted_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + blacklist = _get_drift_device_blacklist() + if not blacklist: + return rows + + filtered: List[Dict[str, Any]] = [] + blacklist_set = set(blacklist) + for row in rows: + candidates = set(_event_blacklist_candidates(row)) + if any(item in candidates for item in blacklist_set): + continue + filtered.append(row) + return filtered + + +def _is_uptime_kuma_configured() -> bool: + cfg = _get_drift_connector_config() + return bool(cfg.get("base_url") and cfg.get("api_key")) + + +def _ensure_source(connector_type: str = "uptime-kuma") -> Dict[str, Any]: + config = _get_drift_connector_config() if connector_type == "uptime-kuma" else _get_uisp_connector_config() + row = execute_query_single("SELECT id, name, config_json FROM drift_sources WHERE connector_type = %s LIMIT 1", (connector_type,)) + if row: + current_config = row.get("config_json") or {} + if str(current_config.get("base_url") or "") != config.get("base_url", "") or str(current_config.get("api_key") or current_config.get("api_token") or "") != config.get("api_key") or str(current_config.get("api_token") or "") != config.get("api_token", ""): + execute_query( + "UPDATE drift_sources SET config_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (json.dumps(config, ensure_ascii=False), row.get("id")), + ) + return {"id": row.get("id"), "name": row.get("name") or ("UISP" if connector_type == "uisp" else "Uptime Kuma")} + + result = execute_query( + """ + INSERT INTO drift_sources (name, connector_type, config_json, enabled) + VALUES (%s, %s, %s::jsonb, %s) + RETURNING id, name + """, + (("UISP" if connector_type == "uisp" else "Uptime Kuma"), connector_type, json.dumps(config, ensure_ascii=False), True), + ) + return result[0] if result else {"id": None, "name": "UISP" if connector_type == "uisp" else "Uptime Kuma"} + + +def _upsert_event_from_payload(source_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]: + def _parse_dt(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + raw = str(value or "").strip() + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + + def _duration_from_times(started_value: Any, end_value: Any) -> Optional[int]: + started_dt = _parse_dt(started_value) + end_dt = _parse_dt(end_value) or datetime.now(timezone.utc) + if not started_dt: + return None + minutes = int((end_dt - started_dt).total_seconds() // 60) + return max(minutes, 0) + + existing = execute_query_single( + """ + SELECT id, started_at, status FROM drift_events + WHERE source_id = %s AND source_event_id = %s + LIMIT 1 + """, + (source_id, payload.get("source_event_id")), + ) + now = payload.get("updated") or payload.get("started") or datetime.now(timezone.utc).isoformat() + row = None + + incoming_status = str(payload.get("status") or "").strip().lower() + incoming_started = payload.get("started") + incoming_resolved = payload.get("resolved") + + started_for_db = incoming_started + if existing: + existing_status = str(existing.get("status") or "").strip().lower() + if incoming_status == "active" and existing_status == "active" and existing.get("started_at"): + started_for_db = existing.get("started_at") + + duration_value = payload.get("duration_minutes") + if duration_value is None: + duration_value = _duration_from_times(started_for_db, incoming_resolved or now) + + if existing: + execute_query( + """ + UPDATE drift_events + SET severity = %s, + status = %s, + customer_name = %s, + site_name = %s, + device_name = %s, + service_name = %s, + message = %s, + started_at = %s, + updated_at = CURRENT_TIMESTAMP, + resolved_at = %s, + duration_minutes = %s, + raw_json = %s::jsonb, + tags = %s::jsonb + WHERE id = %s + """, + ( + payload.get("severity", "info"), + payload.get("status", "new"), + payload.get("customer"), + payload.get("site"), + payload.get("device"), + payload.get("service"), + payload.get("message"), + started_for_db, + incoming_resolved, + duration_value, + json.dumps(payload.get("raw_json") or payload, ensure_ascii=False), + json.dumps(payload.get("tags") or [], ensure_ascii=False), + existing["id"], + ), + ) + row = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (existing["id"],)) + else: + row = execute_query_single( + """ + INSERT INTO drift_events ( + source_id, source_event_id, severity, status, customer_name, site_name, device_name, service_name, + message, started_at, updated_at, resolved_at, duration_minutes, raw_json, tags + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb) + RETURNING * + """, + ( + source_id, + payload.get("source_event_id"), + payload.get("severity", "info"), + payload.get("status", "new"), + payload.get("customer"), + payload.get("site"), + payload.get("device"), + payload.get("service"), + payload.get("message"), + started_for_db, + now, + incoming_resolved, + duration_value, + json.dumps(payload.get("raw_json") or payload, ensure_ascii=False), + json.dumps(payload.get("tags") or [], ensure_ascii=False), + ), + ) + return row or {} + + +def _purge_demo_events() -> None: + # Remove legacy demo/sample rows created by earlier drift versions. + execute_query( + """ + DELETE FROM drift_events + WHERE source_event_id LIKE 'demo-%' + OR source_event_id LIKE 'sample-%' + OR COALESCE(raw_json->>'mode', '') = 'demo' + OR (tags::text ILIKE '%"demo"%') + """ + ) + + +def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str]) -> Any: + normalized = base_url.rstrip('/') + headers = {"Accept": "application/json"} + if token: + headers.update({ + "Authorization": f"Bearer {token}", + "X-Auth-Token": token, + "X-API-Key": token, + }) + + for candidate in candidates: + if candidate.startswith("http"): + url = candidate + else: + url = f"{normalized}/{candidate.lstrip('/')}" + try: + response = httpx.get(url, headers=headers, timeout=15.0, follow_redirects=True) + if response.status_code == 404: + continue + response.raise_for_status() + payload = response.json() + return payload + except (httpx.HTTPError, ValueError, TypeError): + continue + return [] + + +def _parse_prometheus_labels(raw: str) -> Dict[str, str]: + labels: Dict[str, str] = {} + if not raw.strip(): + return labels + for part in raw.split(','): + if '=' not in part: + continue + key, value = part.split('=', 1) + key = key.strip() + value = value.strip().strip('"') + labels[key] = value + return labels + + +def _parse_prometheus_line(line: str) -> Optional[Dict[str, Any]]: + line = line.strip() + if not line or line.startswith('#') or ' ' not in line: + return None + + left, raw_value = line.split(None, 1) + left = left.strip() + raw_value = raw_value.strip() + + labels: Dict[str, str] = {} + metric_name = left + if '{' in left and left.endswith('}'): + metric_name = left[: left.index('{')] + label_str = left[left.index('{') + 1 : -1] + labels = _parse_prometheus_labels(label_str) + + try: + value = float(raw_value) + except ValueError: + return None + + return { + "name": metric_name, + "labels": labels, + "value": value, + } + + +def _monitor_key(labels: Dict[str, str]) -> str: + return ( + labels.get("monitor_id") + or labels.get("id") + or labels.get("monitor_name") + or labels.get("name") + or labels.get("monitor_url") + or "unknown" + ) + + +def _fetch_uptime_kuma_metrics(base_url: str, api_key: str) -> str: + metrics_url = f"{base_url.rstrip('/')}/metrics" + with httpx.Client(timeout=15.0, follow_redirects=True) as client: + response = client.get(metrics_url, auth=("", api_key)) + response.raise_for_status() + return response.text + + +_customer_column_cache: Dict[str, bool] = {} + + +def _customers_column_exists(column_name: str) -> bool: + if column_name in _customer_column_cache: + return _customer_column_cache[column_name] + + result = execute_query_single( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'customers' + AND column_name = %s + LIMIT 1 + """, + (column_name,), + ) + exists = bool(result) + _customer_column_cache[column_name] = exists + return exists + + +def _safe_host_from_url(value: str) -> str: + raw = str(value or "").strip() + if not raw or raw.lower() == "null": + return "" + + candidate = raw if "://" in raw else f"https://{raw}" + try: + host = (urlparse(candidate).hostname or "").strip().lower() + except Exception: + host = "" + return host + + +def _extract_email_domain(value: str) -> str: + raw = str(value or "").strip().lower() + if "@" not in raw: + return "" + return raw.split("@", 1)[1].strip() + + +def _load_customer_match_index() -> List[Dict[str, Any]]: + select_parts = [ + "c.id", + "c.name", + "COALESCE(NULLIF(TRIM(c.email), ''), '') AS email_value", + ] + if _customers_column_exists("email_domain"): + select_parts.append("COALESCE(NULLIF(TRIM(c.email_domain), ''), '') AS email_domain_value") + else: + select_parts.append("'' AS email_domain_value") + + if _customers_column_exists("website"): + select_parts.append("COALESCE(NULLIF(TRIM(c.website), ''), '') AS website_value") + else: + select_parts.append("'' AS website_value") + + query = """ + SELECT {select_parts} + FROM customers c + WHERE c.deleted_at IS NULL + ORDER BY c.name ASC + """.format(select_parts=", ".join(select_parts)) + + rows = execute_query(query) or [] + result: List[Dict[str, Any]] = [] + for row in rows: + raw_domains = [ + row.get("email_domain_value"), + row.get("website_value"), + row.get("email_value"), + ] + domains: List[str] = [] + for entry in raw_domains: + as_host = _safe_host_from_url(str(entry or "")) + if as_host: + domains.append(as_host) + else: + email_domain = _extract_email_domain(str(entry or "")) + if email_domain: + domains.append(email_domain) + + result.append( + { + "id": row.get("id"), + "name": row.get("name") or "", + "domains": sorted(set(d for d in domains if d)), + } + ) + return result + + +def _resolve_customer_for_monitor(labels: Dict[str, str], monitor_name: str, customers: List[Dict[str, Any]]) -> str: + host_candidates = [ + _safe_host_from_url(labels.get("monitor_hostname") or ""), + _safe_host_from_url(labels.get("monitor_url") or ""), + ] + host_candidates = [h for h in host_candidates if h] + + best_name = "" + best_score = -1 + + for customer in customers: + cname = str(customer.get("name") or "") + for domain in customer.get("domains") or []: + for host in host_candidates: + if host == domain or host.endswith(f".{domain}"): + score = len(domain) + if score > best_score: + best_score = score + best_name = cname + + if best_name: + return best_name + + monitor_name_lower = str(monitor_name or "").strip().lower() + if monitor_name_lower: + for customer in customers: + cname = str(customer.get("name") or "").strip() + ckey = cname.lower() + if len(ckey) >= 4 and ckey in monitor_name_lower: + return cname + + return "Uptime Kuma" + + +def _build_source_link(labels: Dict[str, str]) -> Optional[str]: + monitor_url = str(labels.get("monitor_url") or labels.get("url") or "").strip() + if monitor_url and _safe_host_from_url(monitor_url): + return monitor_url if "://" in monitor_url else f"https://{monitor_url}" + + monitor_hostname = str(labels.get("monitor_hostname") or "").strip() + if monitor_hostname and monitor_hostname.lower() != "null": + return f"https://{monitor_hostname}" + + return None + + +def _load_manual_customer_mappings(source_id: Optional[int]) -> Dict[str, Dict[str, Any]]: + if not source_id: + return {"by_key": {}, "by_name": {}} + + rows = execute_query( + """ + SELECT + m.monitor_key, + m.monitor_name, + m.customer_id, + c.name AS customer_name + FROM drift_customer_mappings m + JOIN customers c ON c.id = m.customer_id + WHERE m.source_id = %s + AND c.deleted_at IS NULL + """, + (source_id,), + ) or [] + + by_key: Dict[str, Dict[str, Any]] = {} + by_name: Dict[str, Dict[str, Any]] = {} + for row in rows: + monitor_key = str(row.get("monitor_key") or "").strip().lower() + monitor_name = str(row.get("monitor_name") or "").strip().lower() + info = { + "customer_id": row.get("customer_id"), + "customer_name": row.get("customer_name") or "", + } + if monitor_key: + by_key[monitor_key] = info + if monitor_name: + by_name[monitor_name] = info + + return {"by_key": by_key, "by_name": by_name} + + +def _build_events_from_metrics(metrics_text: str, source_id: Optional[int]) -> List[Dict[str, Any]]: + parsed = [_parse_prometheus_line(line) for line in metrics_text.splitlines()] + parsed = [entry for entry in parsed if entry is not None] + + monitors: Dict[str, Dict[str, Any]] = {} + + for entry in parsed: + name = str(entry.get("name") or "") + labels = entry.get("labels") or {} + value = entry.get("value") + key = _monitor_key(labels) + monitor = monitors.setdefault(key, {"labels": labels}) + + if name == "monitor_status": + monitor["status_value"] = value + elif name == "monitor_response_time": + monitor["response_time"] = value + + now_iso = datetime.now(timezone.utc).isoformat() + events: List[Dict[str, Any]] = [] + customers = _load_customer_match_index() + manual_mappings = _load_manual_customer_mappings(source_id) + + for key, monitor in monitors.items(): + labels = monitor.get("labels") or {} + status_value = monitor.get("status_value") + if status_value is None: + continue + + is_up = float(status_value) >= 1.0 + status = "resolved" if is_up else "active" + severity = "info" if is_up else "critical" + monitor_name = labels.get("monitor_name") or labels.get("name") or key + monitor_url = labels.get("monitor_url") or labels.get("url") or "" + source_link = _build_source_link(labels) + + monitor_key_lower = str(key or "").strip().lower() + monitor_name_lower = str(monitor_name or "").strip().lower() + manual = manual_mappings["by_key"].get(monitor_key_lower) or manual_mappings["by_name"].get(monitor_name_lower) + if manual: + customer_name = str(manual.get("customer_name") or "Uptime Kuma") + customer_id = manual.get("customer_id") + else: + customer_name = _resolve_customer_for_monitor(labels, monitor_name, customers) + customer_id = None + + response_time = monitor.get("response_time") + response_text = "" + if response_time is not None: + response_text = f" Response: {int(float(response_time))} ms." + + events.append( + { + "source_event_id": f"kuma-{key}", + "severity": severity, + "status": status, + "customer": customer_name, + "site": "Monitoring", + "device": monitor_name, + "service": monitor_url or "HTTP", + "message": f"Monitor '{monitor_name}' is {'UP' if is_up else 'DOWN'}.{response_text}".strip(), + "started": now_iso, + "updated": now_iso, + "resolved": now_iso if is_up else None, + "duration_minutes": None, + "tags": ["uptime-kuma", "metrics"], + "raw_json": { + "labels": labels, + "status_value": status_value, + "response_time": response_time, + "source_link": source_link, + "customer_id": customer_id, + }, + } + ) + + return events + + +def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + for key in ("data", "items", "devices", "results", "sites"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + return [value] + return [payload] + return [] + + +def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]: + items = _parse_uisp_payload(payload) + events: List[Dict[str, Any]] = [] + for item in items: + identification = item.get("identification") if isinstance(item, dict) else {} + identification = identification if isinstance(identification, dict) else {} + overview = item.get("overview") if isinstance(item, dict) else {} + overview = overview if isinstance(overview, dict) else {} + site_obj = identification.get("site") if isinstance(identification, dict) else {} + site_obj = site_obj if isinstance(site_obj, dict) else {} + + external_id = ( + identification.get("id") + or item.get("id") + or item.get("device_id") + or item.get("mac") + or identification.get("name") + or item.get("name") + or "uisp-device" + ) + name = ( + identification.get("name") + or item.get("name") + or item.get("hostname") + or item.get("device_name") + or str(external_id) + ) + site = site_obj.get("name") or item.get("site_name") or item.get("site") or item.get("siteName") or "UISP" + state_value = ( + overview.get("status") + or identification.get("status") + or item.get("state") + or item.get("status") + or item.get("statusText") + or item.get("connectionState") + or "unknown" + ) + state_text = str(state_value).strip().lower() + online = any(token in state_text for token in ("online", "connected", "up", "active", "ok")) and not any( + token in state_text for token in ("disconnected", "offline", "down", "inactive") + ) + status = "resolved" if online else "active" + severity = "info" if online else "critical" + last_seen = overview.get("lastSeen") + now_dt = datetime.now(timezone.utc) + started_iso = now_dt.isoformat() + if not online and last_seen: + started_iso = str(last_seen) + resolved_iso = now_dt.isoformat() if online else None + duration_minutes: Optional[int] = None + if started_iso: + try: + started_dt = datetime.fromisoformat(str(started_iso).replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(str(resolved_iso).replace("Z", "+00:00")) if resolved_iso else now_dt + duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0) + except ValueError: + duration_minutes = None + + message = f"UISP device '{name}' is {'ONLINE' if online else 'OFFLINE'}" + device_link = _extract_device_link(item, base_url, external_id) + events.append( + { + "source_event_id": f"uisp-{external_id}", + "severity": severity, + "status": status, + "customer": "UISP", + "site": site, + "device": name, + "service": "UISP", + "message": message, + "started": started_iso, + "updated": now_dt.isoformat(), + "resolved": resolved_iso, + "duration_minutes": duration_minutes, + "tags": ["uisp", "monitoring"], + "raw_json": { + "source_link": device_link, + "device_link": device_link, + "state": state_value, + "overview_status": overview.get("status"), + "last_seen": last_seen, + "site": site, + "raw_item": item, + }, + } + ) + return events + + +def _run_uptime_kuma_sync_internal() -> Dict[str, Any]: + _ensure_schema() + _purge_demo_events() + source = _ensure_source("uptime-kuma") + config = _get_drift_connector_config() + base_url = config.get("base_url") or "" + api_key = config.get("api_key") or "" + + if not base_url or not api_key: + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "Uptime Kuma connector mangler base_url eller api_key.", + } + + try: + metrics_text = _fetch_uptime_kuma_metrics(base_url, api_key) + events = _build_events_from_metrics(metrics_text, source.get("id")) + except httpx.HTTPError as exc: + logger.warning("⚠️ Drift Uptime Kuma sync failed: %s", exc) + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": str(exc), + } + + if not events: + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "Ingen monitor-metrics fundet i /metrics respons.", + } + + blacklist = _get_drift_device_blacklist() + filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)] + skipped = len(events) - len(filtered_events) + + for item in filtered_events: + _upsert_event_from_payload(source.get("id"), item) + + return { + "synced": len(filtered_events), + "source": source.get("name"), + "mode": "live", + "blacklisted_skipped": skipped, + } + + +def _run_uisp_sync_internal() -> Dict[str, Any]: + _ensure_schema() + _purge_demo_events() + source = _ensure_source("uisp") + config = _get_uisp_connector_config() + base_url = config.get("base_url") or "" + token = config.get("api_token") or "" + + if not base_url or not token: + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "UISP connector mangler base_url eller api_token.", + } + + try: + payload = _fetch_json_from_candidates( + base_url, + token, + [ + "nms/api/v2.1/devices", + "nms/api/v2.1/sites", + "nms/api/v2/devices", + "nms/api/v2/sites", + "api/v2.1/devices", + "api/v2/devices", + "api/v2.1/sites", + "api/v2/sites", + ], + ) + events = _build_events_from_uisp_payload(payload, source.get("id"), base_url) + except httpx.HTTPError as exc: + logger.warning("⚠️ Drift UISP sync failed: %s", exc) + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": str(exc), + } + + if not events: + return { + "synced": 0, + "source": source.get("name"), + "mode": "live", + "warning": "Ingen UISP-enheder fundet i API-responsen.", + } + + blacklist = _get_drift_device_blacklist() + filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)] + skipped = len(events) - len(filtered_events) + + for item in filtered_events: + _upsert_event_from_payload(source.get("id"), item) + + return { + "synced": len(filtered_events), + "source": source.get("name"), + "mode": "live", + "blacklisted_skipped": skipped, + } + + +def run_uptime_kuma_sync() -> Dict[str, Any]: + kuma_result = _run_uptime_kuma_sync_internal() + uisp_result = _run_uisp_sync_internal() + return { + "synced": int(kuma_result.get("synced") or 0) + int(uisp_result.get("synced") or 0), + "source": "Uptime Kuma + UISP", + "mode": "live", + "connectors": { + "uptime_kuma": kuma_result, + "uisp": uisp_result, + }, + } + + +@router.get("/drift/summary") +async def get_drift_summary(): + _ensure_schema() + _purge_demo_events() + source = _ensure_source() + + def _coerce_dt(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value + raw_value = str(value or "").strip() + if not raw_value: + return None + try: + return datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + return None + + rows = execute_query( + """ + SELECT + id, + source_event_id, + status, + severity, + ticket_id, + resolved_at, + updated_at, + device_name, + raw_json + FROM drift_events + """ + ) or [] + filtered_rows = _filter_out_blacklisted_rows(rows) + + active_statuses = {"active", "warning", "critical", "new"} + today = datetime.now(timezone.utc).date() + + active_alerts = 0 + critical = 0 + warnings = 0 + resolved_today = 0 + unassigned = 0 + uisp_total = 0 + uisp_active = 0 + uisp_last_sync: Optional[datetime] = None + + for row in filtered_rows: + status_value = str(row.get("status") or "").strip().lower() + severity_value = str(row.get("severity") or "").strip().lower() + source_event_id = str(row.get("source_event_id") or "").strip().lower() + + if status_value in active_statuses: + active_alerts += 1 + if row.get("ticket_id") is None: + unassigned += 1 + + if severity_value == "critical": + critical += 1 + if severity_value == "warning": + warnings += 1 + + resolved_at = _coerce_dt(row.get("resolved_at")) + if resolved_at and resolved_at.date() == today: + resolved_today += 1 + + if source_event_id.startswith("uisp-"): + uisp_total += 1 + if status_value in active_statuses: + uisp_active += 1 + updated_at = _coerce_dt(row.get("updated_at")) + if updated_at and (uisp_last_sync is None or updated_at > uisp_last_sync): + uisp_last_sync = updated_at + + uisp_config = _get_uisp_connector_config() + + return { + "source": source.get("name"), + "active_alerts": active_alerts, + "critical": critical, + "warnings": warnings, + "resolved_today": resolved_today, + "unassigned": unassigned, + "uisp": { + "configured": bool((uisp_config.get("base_url") or "").strip() and (uisp_config.get("api_token") or "").strip()), + "total_events": uisp_total, + "active_events": uisp_active, + "last_sync": uisp_last_sync, + }, + } + + +@router.get("/drift/events") +async def list_events( + status: Optional[str] = Query(None), + severity: Optional[str] = Query(None), + source: Optional[str] = Query(None), + customer: Optional[str] = Query(None), + device: Optional[str] = Query(None), + limit: int = Query(default=100, ge=1, le=200), +): + _ensure_schema() + _purge_demo_events() + query = """ + SELECT + e.*, + src.name AS source_name, + COALESCE(c.name, e.customer_name) AS customer_name, + COALESCE(s.name, e.site_name) AS site_name, + COALESCE(d.name, e.device_name) AS device_name, + COALESCE(svc.name, e.service_name) AS service_name + FROM drift_events e + LEFT JOIN drift_sources src ON src.id = e.source_id + LEFT JOIN drift_devices d ON d.source_id = e.source_id AND d.external_id = e.device_name + LEFT JOIN drift_devices c ON c.source_id = e.source_id AND c.external_id = e.customer_name + LEFT JOIN drift_devices s ON s.source_id = e.source_id AND s.external_id = e.site_name + LEFT JOIN drift_devices svc ON svc.source_id = e.source_id AND svc.external_id = e.service_name + WHERE 1=1 + """ + params: List[Any] = [] + filters = [] + if status: + filters.append("e.status = %s") + params.append(status) + if severity: + filters.append("e.severity = %s") + params.append(severity) + if source: + filters.append("e.source_id IN (SELECT id FROM drift_sources WHERE LOWER(name) LIKE %s)") + params.append(f"%{source.lower()}%") + if customer: + filters.append("LOWER(COALESCE(e.customer_name, '')) LIKE %s") + params.append(f"%{customer.lower()}%") + if device: + filters.append("LOWER(COALESCE(e.device_name, '')) LIKE %s") + params.append(f"%{device.lower()}%") + if filters: + query += " AND " + " AND ".join(filters) + query += " ORDER BY e.started_at DESC NULLS LAST, e.id DESC LIMIT %s" + params.append(limit) + rows = execute_query(query, tuple(params)) or [] + rows = _filter_out_blacklisted_rows(rows) + return [_row_to_event(row) for row in rows] + + +@router.get("/drift/events/{event_id:int}") +async def get_event(event_id: int): + _ensure_schema() + row = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,)) + if not row: + raise HTTPException(status_code=404, detail="Event not found") + return _row_to_event(row) + + +@router.get("/drift/customer-mappings") +async def list_customer_mappings(): + _ensure_schema() + rows = execute_query( + """ + SELECT + m.id, + m.source_id, + m.monitor_key, + m.monitor_name, + m.customer_id, + c.name AS customer_name, + m.updated_at + FROM drift_customer_mappings m + JOIN customers c ON c.id = m.customer_id + WHERE c.deleted_at IS NULL + ORDER BY m.updated_at DESC + """ + ) or [] + return rows + + +@router.get("/drift/customers-lite") +async def list_customers_lite(): + rows = execute_query( + """ + SELECT id, name + FROM customers + WHERE deleted_at IS NULL + ORDER BY name ASC + """ + ) or [] + return rows + + +@router.get("/drift/unmapped-monitors") +async def list_unmapped_monitors(limit: int = Query(default=200, ge=1, le=1000)): + _ensure_schema() + rows = execute_query( + """ + SELECT + REPLACE(e.source_event_id, 'kuma-', '') AS monitor_key, + COALESCE(NULLIF(TRIM(e.device_name), ''), REPLACE(e.source_event_id, 'kuma-', '')) AS monitor_name, + COALESCE(e.raw_json->'labels'->>'monitor_url', e.raw_json->'labels'->>'url', '') AS monitor_url, + COALESCE(e.raw_json->'labels'->>'monitor_hostname', '') AS monitor_hostname, + COALESCE(e.customer_name, '') AS inferred_customer + FROM drift_events e + WHERE e.source_event_id LIKE %s + AND NOT EXISTS ( + SELECT 1 + FROM drift_customer_mappings m + WHERE m.source_id = e.source_id + AND LOWER(COALESCE(m.monitor_key, '')) = LOWER(REPLACE(e.source_event_id, 'kuma-', '')) + ) + ORDER BY COALESCE(e.started_at, e.created_at) DESC NULLS LAST, e.id DESC + LIMIT %s + """, + ("kuma-%", limit), + ) or [] + + seen: Dict[str, bool] = {} + unique_rows: List[Dict[str, Any]] = [] + for row in rows: + key = str(row.get("monitor_key") or "").strip().lower() + if not key or key in seen: + continue + seen[key] = True + unique_rows.append(row) + return unique_rows + + +def _customer_drift_filter_sql() -> str: + return """ + ( + COALESCE(e.raw_json->>'customer_id', '') = %s + OR LOWER(COALESCE(e.customer_name, '')) = LOWER(%s) + ) + """ + + +@router.get("/drift/customers/{customer_id:int}/summary") +async def get_customer_drift_summary(customer_id: int): + _ensure_schema() + customer = execute_query_single( + "SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL", + (customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + customer_name = str(customer.get("name") or "") + cid_str = str(customer_id) + where_sql = _customer_drift_filter_sql() + + active = execute_query_single( + f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) IN ('active','warning','critical','new')", + (cid_str, customer_name), + ) or {} + resolved = execute_query_single( + f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) = 'resolved'", + (cid_str, customer_name), + ) or {} + acknowledged = execute_query_single( + f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.status, '')) = 'acknowledged'", + (cid_str, customer_name), + ) or {} + critical = execute_query_single( + f"SELECT COUNT(*) AS count FROM drift_events e WHERE {where_sql} AND LOWER(COALESCE(e.severity, '')) = 'critical'", + (cid_str, customer_name), + ) or {} + + def _to_int(row: Dict[str, Any]) -> int: + return int(row.get("count") or 0) + + return { + "customer_id": customer_id, + "customer_name": customer_name, + "active": _to_int(active), + "resolved": _to_int(resolved), + "acknowledged": _to_int(acknowledged), + "critical": _to_int(critical), + } + + +@router.get("/drift/customers/{customer_id:int}/events") +async def list_customer_drift_events(customer_id: int, limit: int = Query(default=50, ge=1, le=500)): + _ensure_schema() + customer = execute_query_single( + "SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL", + (customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + customer_name = str(customer.get("name") or "") + cid_str = str(customer_id) + where_sql = _customer_drift_filter_sql() + rows = execute_query( + f""" + SELECT e.*, src.name AS source_name + FROM drift_events e + LEFT JOIN drift_sources src ON src.id = e.source_id + WHERE {where_sql} + ORDER BY COALESCE(e.started_at, e.created_at) DESC NULLS LAST, e.id DESC + LIMIT %s + """, + (cid_str, customer_name, limit), + ) or [] + rows = _filter_out_blacklisted_rows(rows) + return [_row_to_event(row) for row in rows] + + +@router.put("/drift/customer-mappings") +async def upsert_customer_mapping(payload: DriftCustomerMappingPayload): + _ensure_schema() + source = _ensure_source() + source_id = source.get("id") + + if not source_id: + raise HTTPException(status_code=500, detail="Drift source kunne ikke initialiseres") + + monitor_key = str(payload.monitor_key or "").strip() + monitor_name = str(payload.monitor_name or "").strip() + if not monitor_key and not monitor_name: + raise HTTPException(status_code=400, detail="monitor_key eller monitor_name er paakraevet") + + customer = execute_query_single( + "SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL", + (payload.customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + result = execute_query( + """ + INSERT INTO drift_customer_mappings (source_id, monitor_key, monitor_name, customer_id) + VALUES (%s, %s, %s, %s) + ON CONFLICT (source_id, monitor_key) + DO UPDATE SET + monitor_name = EXCLUDED.monitor_name, + customer_id = EXCLUDED.customer_id, + updated_at = CURRENT_TIMESTAMP + RETURNING id, source_id, monitor_key, monitor_name, customer_id, updated_at + """, + ( + source_id, + monitor_key or monitor_name, + monitor_name or monitor_key, + payload.customer_id, + ), + ) + + if not result: + raise HTTPException(status_code=500, detail="Failed to save mapping") + + # Apply the mapping to existing events right away so the UI reflects the new customer + # immediately without waiting for the next sync cycle. + mapped_key = monitor_key or monitor_name + mapped_name = monitor_name or monitor_key + execute_query( + """ + UPDATE drift_events + SET customer_name = %s, + raw_json = jsonb_set(COALESCE(raw_json, '{}'::jsonb), '{customer_id}', to_jsonb(%s::int), true), + updated_at = CURRENT_TIMESTAMP + WHERE source_id = %s + AND ( + LOWER(REPLACE(COALESCE(source_event_id, ''), 'kuma-', '')) = LOWER(%s) + OR LOWER(COALESCE(device_name, '')) = LOWER(%s) + ) + """, + ( + customer.get("name"), + int(payload.customer_id), + source_id, + mapped_key, + mapped_name, + ), + ) + + row = result[0] + row["customer_name"] = customer.get("name") + return row + + +@router.post("/drift/events/{event_id:int}/ticket") +async def create_ticket_for_event(event_id: int, payload: DriftTicketPayload): + _ensure_schema() + event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,)) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + title = payload.title or f"Drift: {event.get('device_name') or 'Ukendt enhed'}" + description = payload.description or ( + f"Kilde: {event.get('source_name') or 'Uptime Kuma'}\n" + f"Device: {event.get('device_name') or '-'}\n" + f"Status: {event.get('status') or '-'}\n" + f"Start: {event.get('started_at') or '-'}\n" + f"Automatisk oprettet fra Drift." + ) + created = execute_query( + """ + INSERT INTO sag_sager (titel, beskrivelse, status, created_by_user_id, customer_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, titel, beskrivelse, status + """, + (title, description, "open", 1, None), + ) + if not created: + raise HTTPException(status_code=500, detail="Failed to create case") + sag = created[0] + execute_query("UPDATE drift_events SET ticket_id = %s WHERE id = %s", (int(sag["id"]), event_id)) + execute_query( + "INSERT INTO drift_ticket_links (event_id, sag_id, link_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", + (event_id, int(sag["id"]), "created"), + ) + return {"sag_id": int(sag["id"]), "title": title, "description": description} + + +@router.post("/drift/events/{event_id:int}/ticket/link") +async def link_existing_ticket(event_id: int, payload: DriftTicketLinkPayload): + _ensure_schema() + event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,)) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + execute_query("UPDATE drift_events SET ticket_id = %s WHERE id = %s", (payload.sag_id, event_id)) + execute_query( + "INSERT INTO drift_ticket_links (event_id, sag_id, link_type) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", + (event_id, payload.sag_id, "linked"), + ) + return {"sag_id": payload.sag_id} + + +@router.post("/drift/events/{event_id:int}/acknowledge") +async def acknowledge_event(event_id: int): + _ensure_schema() + event = execute_query_single("SELECT id, status FROM drift_events WHERE id = %s", (event_id,)) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + current_status = str(event.get("status") or "").strip().lower() + if current_status in {"resolved", "acknowledged"}: + return {"id": event_id, "status": current_status} + + execute_query( + """ + UPDATE drift_events + SET status = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ("acknowledged", event_id), + ) + + execute_query( + """ + INSERT INTO drift_event_history (event_id, status, message) + VALUES (%s, %s, %s) + """, + (event_id, "acknowledged", "Alarm godkendt af tekniker"), + ) + + return {"id": event_id, "status": "acknowledged"} + + +@router.post("/drift/events/{event_id:int}/blacklist") +async def blacklist_event(event_id: int, payload: Optional[DriftBlacklistPayload] = None): + _ensure_schema() + event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,)) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + current = _get_drift_device_blacklist() + explicit = str((payload.value if payload else "") or "").strip().lower() + to_add = [explicit] if explicit else _event_blacklist_candidates(event) + + merged = list(current) + for item in to_add: + if item and item not in merged: + merged.append(item) + + _save_drift_device_blacklist(merged) + + current_status = str(event.get("status") or "").strip().lower() + auto_acknowledged = False + if current_status in {"active", "warning", "critical", "new"}: + execute_query( + """ + UPDATE drift_events + SET status = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ("acknowledged", event_id), + ) + execute_query( + """ + INSERT INTO drift_event_history (event_id, status, message) + VALUES (%s, %s, %s) + """, + (event_id, "acknowledged", "Alarm auto-godkendt via blacklist"), + ) + auto_acknowledged = True + + return { + "id": event_id, + "blacklisted": to_add, + "blacklist_size": len(merged), + "status": "acknowledged" if auto_acknowledged else current_status, + "auto_acknowledged": auto_acknowledged, + } + + +@router.post("/drift/sync/uptime-kuma") +async def sync_uptime_kuma(payload: DriftSyncPayload): + try: + result = run_uptime_kuma_sync() + except Exception as exc: + logger.error("❌ Drift sync failed: %s", exc) + raise HTTPException(status_code=502, detail=f"Drift sync failed: {exc}") + + return result diff --git a/app/modules/drift/frontend/__init__.py b/app/modules/drift/frontend/__init__.py new file mode 100644 index 0000000..c95fafc --- /dev/null +++ b/app/modules/drift/frontend/__init__.py @@ -0,0 +1 @@ +from .views import router diff --git a/app/modules/drift/frontend/views.py b/app/modules/drift/frontend/views.py new file mode 100644 index 0000000..df89ecb --- /dev/null +++ b/app/modules/drift/frontend/views.py @@ -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}) diff --git a/app/modules/drift/templates/drift.html b/app/modules/drift/templates/drift.html new file mode 100644 index 0000000..f871f69 --- /dev/null +++ b/app/modules/drift/templates/drift.html @@ -0,0 +1,608 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Drift - BMC Hub{% endblock %} + +{% block content %} +
+
+
+

Drift

+

Operations Center med aktive alarmer og historik.

+
+ +
+ +
+
+
+
+
Aktive alarmer
+
0
+
+
+
+
+
+
+
Kritiske
+
0
+
+
+
+
+
+
+
Warnings
+
0
+
+
+
+
+
+
+
Løst i dag
+
0
+
+
+
+
+ +
+
+
+
+
Bulk Mapping
+
Par monitorer til Hub-kunder via dropdown i stedet for customer_id.
+
+ +
+
+ + + + + + + + + + + + + +
MonitorKildeForeslået kundeHub-kunde
Klik "Indlæs unmapped monitorer".
+
+
+
+ +
+
+
UISP Health
+ Ikke konfigureret + Aktive: 0 + Total: 0 + Sidste sync: - + +
+
+ +
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
StatusSeverityKildeKundeDeviceStartLast seenVarighedSag
Indlæser...
+
+
+
+ +
+
+
Historik
+
+
+
+
+ + + + +{% endblock %} diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py index feb9036..f93a130 100644 --- a/app/modules/telefoni/backend/service.py +++ b/app/modules/telefoni/backend/service.py @@ -157,21 +157,26 @@ class TelefoniService: def terminate_call(callid: str, duration_sec: Optional[int]) -> bool: if not callid: return False + rows = execute_query( """ - UPDATE telefoni_opkald - SET ended_at = NOW(), + INSERT INTO telefoni_opkald + (callid, direction, started_at, ended_at, duration_sec, raw_payload) + VALUES + (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb) + ON CONFLICT (callid) + DO UPDATE SET + ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), duration_sec = COALESCE( - %s, + EXCLUDED.duration_sec, CASE - WHEN started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - started_at))::int, 0) + WHEN telefoni_opkald.started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int, 0) ELSE NULL END ) - WHERE callid = %s RETURNING id """, - (duration_sec, callid), + (callid, duration_sec), ) return bool(rows) diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index 61d024d..1d3d36a 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -42,6 +42,15 @@ class SettingUpdate(BaseModel): value: str +class SettingCreate(BaseModel): + key: str + value: Optional[str] = None + category: Optional[str] = "general" + description: Optional[str] = None + value_type: Optional[str] = "string" + is_public: Optional[bool] = False + + class User(BaseModel): id: int username: str @@ -105,6 +114,41 @@ async def get_settings(category: Optional[str] = None): return result or [] +@router.post("/settings", response_model=Setting, tags=["Settings"]) +async def create_setting(payload: SettingCreate): + """Create a setting row if it does not exist yet.""" + if not payload.key or not payload.key.strip(): + raise HTTPException(status_code=400, detail="Setting key is required") + + query = """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) + DO UPDATE SET + value = EXCLUDED.value, + category = EXCLUDED.category, + description = EXCLUDED.description, + value_type = EXCLUDED.value_type, + is_public = EXCLUDED.is_public, + updated_at = CURRENT_TIMESTAMP + RETURNING * + """ + result = execute_query( + query, + ( + payload.key.strip(), + payload.value if payload.value is not None else "", + payload.category or "general", + payload.description, + payload.value_type or "string", + bool(payload.is_public), + ), + ) + if not result: + raise HTTPException(status_code=500, detail="Failed to create setting") + return result[0] + + @router.get("/settings/{key}", response_model=Setting, tags=["Settings"]) async def get_setting(key: str): """Get a specific setting by key""" @@ -262,6 +306,39 @@ async def update_setting(key: str, setting: SettingUpdate): (key, setting.value, category, description, value_type, is_public), ) + _drift_connector_keys = { + "drift_uptime_kuma_base_url": ("integrations", "Base URL for the Uptime Kuma drift connector", "string", True), + "drift_uptime_kuma_api_key": ("integrations", "API token for the Uptime Kuma drift connector", "string", False), + "drift_uisp_base_url": ("integrations", "Base URL for the UISP drift connector", "string", True), + "drift_uisp_api_token": ("integrations", "API token for the UISP drift connector", "string", False), + "drift_device_blacklist": ("integrations", "JSON array of Drift device identifiers/names to ignore during sync", "text", False), + } + if not result and key in _drift_connector_keys: + category, description, value_type, is_public = _drift_connector_keys[key] + result = execute_query( + """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + RETURNING * + """, + (key, setting.value, category, description, value_type, is_public), + ) + + if not result and key in _label_printer_keys: + category, description, value_type, is_public = _label_printer_keys[key] + result = execute_query( + """ + INSERT INTO settings (key, value, category, description, value_type, is_public) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + RETURNING * + """, + (key, setting.value, category, description, value_type, is_public), + ) + # Mission camera settings may not exist on older hubs before migration. if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}: defaults = { diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index 70052aa..6bf9281 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -80,6 +80,9 @@ Firma + + Drift + Integrationer @@ -147,6 +150,20 @@ + +
+
+
+
+
Drift connectorer
+

Opsæt forbindelser til forskellige overvågnings- og alarmsystemer for Drift-modulet.

+
+
+ +
+
+
+
@@ -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: ` +
+
+ + +
Eksempel: https://status.bmcnetworks.dk
+
+
+ + +
+
+ Disse værdier gemmes i systemindstillingerne og kan bruges af Drift-modulet til at hente alarmsdata. +
+
+
+ + +
+ ` + }, + { + 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: ` +
+
+ + +
+
+ + +
+
+ Disse værdier gemmes i systemindstillingerne og bruges af Drift-modulet til at hente UISP-enhedsstatus. +
+
+ +
+ + +
+
Matcher device-navn, source_event_id eller UISP device-id. Blacklisted devices ignoreres ved fremtidige syncs.
+
+
+
+
+ + +
+ ` + } + ]; + + container.innerHTML = connectors.map(connector => ` +
+
+
+
${escapeHtml(connector.title)}
+

${escapeHtml(connector.description)}

+
+ ${escapeHtml(connector.badge)} +
+ ${connector.body} +
+ `).join(''); +} + async function loadSettings() { try { - const response = await fetch('/api/v1/settings'); - allSettings = await response.json(); + const response = await fetch('/api/v1/settings', { credentials: 'include' }); + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger')); + } + const payload = await response.json(); + allSettings = Array.isArray(payload) ? payload : []; displaySettingsByCategory(); loadTimeMultiplierPresets(); renderTelefoniSettings(); @@ -2368,6 +2474,9 @@ async function loadSettings() { await loadTagsManagement(); await loadNextcloudInstances(); await loadAnydeskSettings(); + renderDriftConnectors(); + await loadUptimeKumaSettings(); + await loadUISPSettings(); await loadLabelPrinterSettings(); } catch (error) { console.error('Error loading settings:', error); @@ -2604,6 +2713,218 @@ async function saveAnydeskSettings() { } } +async function loadUptimeKumaSettings() { + const keys = ['drift_uptime_kuma_base_url', 'drift_uptime_kuma_api_key']; + try { + const results = await Promise.allSettled( + keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null)) + ); + const vals = {}; + results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; }); + + document.getElementById('uptimeKumaBaseUrl').value = vals.drift_uptime_kuma_base_url || ''; + document.getElementById('uptimeKumaApiKey').value = vals.drift_uptime_kuma_api_key || ''; + } catch (e) { + console.warn('Uptime Kuma settings load failed:', e); + } +} + +async function saveUptimeKumaSettings() { + const baseUrl = (document.getElementById('uptimeKumaBaseUrl').value || '').trim(); + const apiKey = (document.getElementById('uptimeKumaApiKey').value || '').trim(); + const statusEl = document.getElementById('uptimeKumaSaveStatus'); + + statusEl.textContent = 'Gemmer...'; + statusEl.className = 'small text-muted'; + + const upsertSettingStrict = async (key, value) => { + const response = await fetch(`/api/v1/settings/${key}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: String(value) }) + }); + + if (response.status === 404 || response.status === 405) { + const createResponse = await fetch('/api/v1/settings', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key, + value: String(value), + category: 'integrations', + description: key === 'drift_uptime_kuma_base_url' ? 'Base URL for the Uptime Kuma drift connector' : 'API token for the Uptime Kuma drift connector', + value_type: 'string', + is_public: key === 'drift_uptime_kuma_base_url' + }) + }); + if (!createResponse.ok) { + throw new Error(await getErrorMessage(createResponse, `Kunne ikke gemme ${key}`)); + } + return; + } + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`)); + } + }; + + try { + await Promise.all([ + upsertSettingStrict('drift_uptime_kuma_base_url', baseUrl), + upsertSettingStrict('drift_uptime_kuma_api_key', apiKey), + ]); + statusEl.textContent = '✅ Gemt'; + statusEl.className = 'small text-success'; + setTimeout(() => { statusEl.textContent = ''; }, 3000); + showNotification('Uptime Kuma indstillinger gemt', 'success'); + } catch (error) { + statusEl.textContent = '❌ Kunne ikke gemme'; + statusEl.className = 'small text-danger'; + showNotification('Kunne ikke gemme Uptime Kuma indstillinger', 'error'); + } +} + +async function loadUISPSettings() { + const keys = ['drift_uisp_base_url', 'drift_uisp_api_token', 'drift_device_blacklist']; + try { + const results = await Promise.allSettled( + keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null)) + ); + const vals = {}; + results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; }); + + document.getElementById('uispBaseUrl').value = vals.drift_uisp_base_url || ''; + document.getElementById('uispApiToken').value = vals.drift_uisp_api_token || ''; + driftBlacklistItems = parseDriftBlacklistValue(vals.drift_device_blacklist || '[]'); + renderDriftBlacklistList(); + } catch (e) { + console.warn('UISP settings load failed:', e); + } +} + +let driftBlacklistItems = []; + +function parseDriftBlacklistValue(rawValue) { + const raw = String(rawValue || '').trim(); + if (!raw) return []; + let values = []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + values = parsed; + } else if (typeof parsed === 'string') { + values = [parsed]; + } + } catch (e) { + values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n'); + } + + const seen = new Set(); + const cleaned = []; + values.forEach(item => { + const normalized = String(item || '').trim().toLowerCase(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + cleaned.push(normalized); + }); + return cleaned; +} + +function renderDriftBlacklistList() { + const list = document.getElementById('uispBlacklistList'); + if (!list) return; + if (!driftBlacklistItems.length) { + list.innerHTML = 'Ingen blacklist entries endnu.'; + return; + } + list.innerHTML = driftBlacklistItems.map(item => { + const safe = String(item).replace(//g, '>'); + return `${safe} `; + }).join(''); +} + +function addDriftBlacklistItem() { + const input = document.getElementById('uispBlacklistInput'); + if (!input) return; + const value = String(input.value || '').trim().toLowerCase(); + if (!value) return; + if (!driftBlacklistItems.includes(value)) { + driftBlacklistItems.push(value); + } + input.value = ''; + renderDriftBlacklistList(); +} + +function removeDriftBlacklistItem(item) { + driftBlacklistItems = driftBlacklistItems.filter(v => v !== item); + renderDriftBlacklistList(); +} + +async function saveUISPSettings() { + const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim(); + const apiToken = (document.getElementById('uispApiToken').value || '').trim(); + const statusEl = document.getElementById('uispSaveStatus'); + + statusEl.textContent = 'Gemmer...'; + statusEl.className = 'small text-muted'; + + const upsertSettingStrict = async (key, value) => { + const response = await fetch(`/api/v1/settings/${key}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: String(value) }) + }); + + if (response.status === 404 || response.status === 405) { + const createResponse = await fetch('/api/v1/settings', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key, + value: String(value), + category: 'integrations', + description: key === 'drift_uisp_base_url' + ? 'Base URL for the UISP drift connector' + : key === 'drift_uisp_api_token' + ? 'API token for the UISP drift connector' + : 'JSON array of Drift device identifiers/names to ignore during sync', + value_type: 'string', + is_public: key === 'drift_uisp_base_url' + }) + }); + if (!createResponse.ok) { + throw new Error(await getErrorMessage(createResponse, `Kunne ikke gemme ${key}`)); + } + return; + } + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`)); + } + }; + + try { + const blacklistJson = JSON.stringify(parseDriftBlacklistValue(driftBlacklistItems)); + await Promise.all([ + upsertSettingStrict('drift_uisp_base_url', baseUrl), + upsertSettingStrict('drift_uisp_api_token', apiToken), + upsertSettingStrict('drift_device_blacklist', blacklistJson), + ]); + statusEl.textContent = '✅ Gemt'; + statusEl.className = 'small text-success'; + setTimeout(() => { statusEl.textContent = ''; }, 3000); + showNotification('UISP indstillinger gemt', 'success'); + } catch (error) { + statusEl.textContent = '❌ Kunne ikke gemme'; + statusEl.className = 'small text-danger'; + showNotification('Kunne ikke gemme UISP indstillinger', 'error'); + } +} + async function loadLabelPrinterSettings() { const keys = [ 'label_printer_enabled', @@ -2653,6 +2974,7 @@ async function saveLabelPrinterSettings() { const putSettingStrict = async (key, value) => { const response = await fetch(`/api/v1/settings/${key}`, { method: 'PUT', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: String(value) }) }); @@ -2964,17 +3286,29 @@ async function updateSetting(key, value) { try { const response = await fetch(`/api/v1/settings/${key}`, { method: 'PUT', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value }) }); - - if (response.ok) { - // Show success toast + + if (response.status === 404 || response.status === 405) { + const createResponse = await fetch('/api/v1/settings', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value, category: 'general', value_type: 'string', is_public: false }) + }); + if (!createResponse.ok) { + throw new Error(await getErrorMessage(createResponse, 'Kunne ikke opdatere indstilling')); + } + } else if (response.ok) { console.log(`✅ Updated ${key}`); + } else { + throw new Error(await getErrorMessage(response, 'Kunne ikke opdatere indstilling')); } } catch (error) { console.error('Error updating setting:', error); - alert('Kunne ikke opdatere indstilling'); + alert(error.message || 'Kunne ikke opdatere indstilling'); } } @@ -5387,11 +5721,17 @@ function createToastContainer() { async function loadPipelineStages() { try { const response = await fetch('/api/v1/pipeline/stages'); - const stages = await response.json(); - pipelineStagesCache = stages || []; + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese pipeline stages')); + } + const payload = await response.json(); + const stages = Array.isArray(payload) ? payload : []; + pipelineStagesCache = stages; renderPipelineStages(pipelineStagesCache); } catch (error) { console.error('Error loading pipeline stages:', error); + pipelineStagesCache = []; + renderPipelineStages(pipelineStagesCache); } } @@ -5932,6 +6272,15 @@ document.addEventListener('DOMContentLoaded', () => {
- + - +