diff --git a/RELEASE_NOTES_v2.2.39.md b/RELEASE_NOTES_v2.2.39.md new file mode 100644 index 0000000..faed90c --- /dev/null +++ b/RELEASE_NOTES_v2.2.39.md @@ -0,0 +1,45 @@ +# Release Notes v2.2.39 + +Dato: 3. marts 2026 + +## Nyt: Mission Control (MVP) +- Nyt dedikeret fullscreen dashboard til operations-overblik på storskærm. +- Realtime-opdateringer via WebSocket (`/api/v1/mission/ws`). +- KPI-overblik for sager: + - Åbne sager + - Nye sager + - Sager uden ansvarlig + - Deadlines i dag + - Overskredne deadlines +- Aktivt opkaldsoverlay med deduplikering på `call_id`. +- Uptime-alerts (DOWN/UP/DEGRADED) med synlig aktive alarmer. +- Live aktivitetsfeed (seneste 20 events). +- Lydsystem med mute + volumenkontrol i dashboardet. + +## Nye endpoints +- `GET /api/v1/mission/state` +- `WS /api/v1/mission/ws` +- `POST /api/v1/mission/webhook/telefoni/ringing` +- `POST /api/v1/mission/webhook/telefoni/answered` +- `POST /api/v1/mission/webhook/telefoni/hangup` +- `POST /api/v1/mission/webhook/uptime` + +## Nye filer +- `migrations/142_mission_control.sql` +- `app/dashboard/backend/mission_router.py` +- `app/dashboard/backend/mission_service.py` +- `app/dashboard/backend/mission_ws.py` +- `app/dashboard/frontend/mission_control.html` + +## Opdaterede filer +- `main.py` +- `app/core/config.py` +- `app/dashboard/backend/views.py` +- `VERSION` + +## Drift/konfiguration +- Ny setting/env til webhook-sikring: `MISSION_WEBHOOK_TOKEN`. +- Nye settings-seeds til Mission Control lyd, KPI-visning, queue-filter og customer-filter. + +## Verificering +- Python syntaks-check kørt på ændrede backend-filer med `py_compile`. diff --git a/VERSION b/VERSION index 5a73918..fdaade6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.37 +2.2.39 diff --git a/app/core/config.py b/app/core/config.py index 1b675a7..80f2246 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -237,6 +237,9 @@ class Settings(BaseSettings): # Telefoni (Yealink) Integration TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=... TELEFONI_IP_WHITELIST: str = "172.16.31.0/24" # CSV of IPs/CIDRs, e.g. "192.168.1.0/24,10.0.0.10" + + # Mission Control webhooks + MISSION_WEBHOOK_TOKEN: str = "" # ESET Integration ESET_ENABLED: bool = False diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py new file mode 100644 index 0000000..3479172 --- /dev/null +++ b/app/dashboard/backend/mission_router.py @@ -0,0 +1,341 @@ +import json +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field + +from app.core.auth_service import AuthService +from app.core.config import settings +from app.core.database import execute_query, execute_query_single + +from .mission_service import MissionService +from .mission_ws import mission_ws_manager + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class MissionCallEvent(BaseModel): + call_id: str = Field(..., min_length=1, max_length=128) + caller_number: Optional[str] = None + queue_name: Optional[str] = None + timestamp: Optional[datetime] = None + + +class MissionUptimeWebhook(BaseModel): + status: Optional[str] = None + service_name: Optional[str] = None + customer_name: Optional[str] = None + timestamp: Optional[datetime] = None + payload: Dict[str, Any] = Field(default_factory=dict) + + +def _get_webhook_token() -> str: + db_token = MissionService.get_setting_value("mission_webhook_token", "") or "" + env_token = (getattr(settings, "MISSION_WEBHOOK_TOKEN", "") or "").strip() + return db_token.strip() or env_token + + +def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None: + configured = _get_webhook_token() + if not configured: + raise HTTPException(status_code=403, detail="Mission webhook token not configured") + + candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token") + if not candidate or candidate.strip() != configured: + raise HTTPException(status_code=403, detail="Forbidden") + + +def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]: + raw = dict(payload.payload or {}) + + status_candidate = payload.status or raw.get("status") or raw.get("event") + if not status_candidate and isinstance(raw.get("monitor"), dict): + status_candidate = raw.get("monitor", {}).get("status") + + service_name = payload.service_name or raw.get("service_name") or raw.get("monitor_name") + if not service_name and isinstance(raw.get("monitor"), dict): + service_name = raw.get("monitor", {}).get("name") + + customer_name = payload.customer_name or raw.get("customer_name") or raw.get("customer") + timestamp = payload.timestamp or raw.get("timestamp") + + status = str(status_candidate or "UNKNOWN").upper().strip() + if status not in {"UP", "DOWN", "DEGRADED"}: + status = "UNKNOWN" + + return { + "status": status, + "service_name": str(service_name or "Unknown Service"), + "customer_name": str(customer_name or "").strip() or None, + "timestamp": timestamp, + "raw": raw, + } + + +@router.get("/mission/state") +async def get_mission_state(): + return MissionService.get_state() + + +@router.websocket("/mission/ws") +async def mission_ws(websocket: WebSocket): + token = websocket.query_params.get("token") + auth_header = (websocket.headers.get("authorization") or "").strip() + if not token and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + if not token: + token = (websocket.cookies.get("access_token") or "").strip() or None + + payload = AuthService.verify_token(token) if token else None + if not payload: + await websocket.close(code=1008) + return + + await mission_ws_manager.connect(websocket) + try: + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + await mission_ws_manager.disconnect(websocket) + except Exception: + await mission_ws_manager.disconnect(websocket) + + +@router.post("/mission/webhook/telefoni/ringing") +async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)): + _validate_mission_webhook_token(request, token) + + timestamp = event.timestamp or datetime.utcnow() + context = MissionService.resolve_contact_context(event.caller_number) + queue_name = (event.queue_name or "Ukendt kø").strip() + + execute_query( + """ + INSERT INTO mission_call_state ( + call_id, queue_name, caller_number, contact_name, company_name, customer_tag, + state, started_at, answered_at, ended_at, updated_at, last_payload + ) + VALUES (%s, %s, %s, %s, %s, %s, 'ringing', %s, NULL, NULL, NOW(), %s::jsonb) + ON CONFLICT (call_id) + DO UPDATE SET + queue_name = EXCLUDED.queue_name, + caller_number = EXCLUDED.caller_number, + contact_name = EXCLUDED.contact_name, + company_name = EXCLUDED.company_name, + customer_tag = EXCLUDED.customer_tag, + state = 'ringing', + ended_at = NULL, + answered_at = NULL, + started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at), + updated_at = NOW(), + last_payload = EXCLUDED.last_payload + """, + ( + event.call_id, + queue_name, + event.caller_number, + context.get("contact_name"), + context.get("company_name"), + context.get("customer_tag"), + timestamp, + json.dumps(event.model_dump(mode="json")), + ), + ) + + event_row = MissionService.insert_event( + event_type="incoming_call", + title=f"Indgående opkald i {queue_name}", + severity="warning", + source="telefoni", + customer_name=context.get("company_name"), + payload={ + "call_id": event.call_id, + "queue_name": queue_name, + "caller_number": event.caller_number, + **context, + }, + ) + + call_payload = { + "call_id": event.call_id, + "queue_name": queue_name, + "caller_number": event.caller_number, + **context, + "timestamp": timestamp, + } + + await mission_ws_manager.broadcast("call_ringing", call_payload) + await mission_ws_manager.broadcast("live_feed_event", event_row) + await mission_ws_manager.broadcast("kpi_update", MissionService.get_kpis()) + + return {"status": "ok"} + + +@router.post("/mission/webhook/telefoni/answered") +async def mission_telefoni_answered(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)): + _validate_mission_webhook_token(request, token) + + execute_query( + """ + UPDATE mission_call_state + SET state = 'answered', + answered_at = COALESCE(answered_at, NOW()), + updated_at = NOW(), + last_payload = %s::jsonb + WHERE call_id = %s + """, + (json.dumps(event.model_dump(mode="json")), event.call_id), + ) + + event_row = MissionService.insert_event( + event_type="call_answered", + title="Opkald besvaret", + severity="info", + source="telefoni", + payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number}, + ) + + await mission_ws_manager.broadcast("call_answered", {"call_id": event.call_id}) + await mission_ws_manager.broadcast("live_feed_event", event_row) + return {"status": "ok"} + + +@router.post("/mission/webhook/telefoni/hangup") +async def mission_telefoni_hangup(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)): + _validate_mission_webhook_token(request, token) + + execute_query( + """ + UPDATE mission_call_state + SET state = 'hangup', + ended_at = NOW(), + updated_at = NOW(), + last_payload = %s::jsonb + WHERE call_id = %s + """, + (json.dumps(event.model_dump(mode="json")), event.call_id), + ) + + event_row = MissionService.insert_event( + event_type="call_ended", + title="Opkald afsluttet", + severity="info", + source="telefoni", + payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number}, + ) + + await mission_ws_manager.broadcast("call_hangup", {"call_id": event.call_id}) + await mission_ws_manager.broadcast("live_feed_event", event_row) + return {"status": "ok"} + + +@router.post("/mission/webhook/uptime") +async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request, token: Optional[str] = Query(None)): + _validate_mission_webhook_token(request, token) + + normalized = _normalize_uptime_payload(payload) + status = normalized["status"] + service_name = normalized["service_name"] + customer_name = normalized["customer_name"] + alert_key = MissionService.build_alert_key(service_name, customer_name) + + current = execute_query_single("SELECT is_active, started_at FROM mission_uptime_alerts WHERE alert_key = %s", (alert_key,)) + + if status in {"DOWN", "DEGRADED"}: + started_at = (current or {}).get("started_at") + is_active = bool((current or {}).get("is_active")) + if not started_at or not is_active: + started_at = datetime.utcnow() + + execute_query( + """ + INSERT INTO mission_uptime_alerts ( + alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, + updated_at, raw_payload, normalized_payload + ) + VALUES (%s, %s, %s, %s, TRUE, %s, NULL, NOW(), %s::jsonb, %s::jsonb) + ON CONFLICT (alert_key) + DO UPDATE SET + status = EXCLUDED.status, + is_active = TRUE, + started_at = COALESCE(mission_uptime_alerts.started_at, EXCLUDED.started_at), + resolved_at = NULL, + updated_at = NOW(), + raw_payload = EXCLUDED.raw_payload, + normalized_payload = EXCLUDED.normalized_payload + """, + ( + alert_key, + service_name, + customer_name, + status, + started_at, + json.dumps(payload.model_dump(mode="json")), + json.dumps(normalized, default=str), + ), + ) + + event_type = "uptime_down" if status == "DOWN" else "uptime_degraded" + severity = "critical" if status == "DOWN" else "warning" + title = f"{service_name} er {status}" + elif status == "UP": + execute_query( + """ + INSERT INTO mission_uptime_alerts ( + alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, + updated_at, raw_payload, normalized_payload + ) + VALUES (%s, %s, %s, %s, FALSE, NULL, NOW(), NOW(), %s::jsonb, %s::jsonb) + ON CONFLICT (alert_key) + DO UPDATE SET + status = EXCLUDED.status, + is_active = FALSE, + resolved_at = NOW(), + updated_at = NOW(), + raw_payload = EXCLUDED.raw_payload, + normalized_payload = EXCLUDED.normalized_payload + """, + ( + alert_key, + service_name, + customer_name, + status, + json.dumps(payload.model_dump(mode="json")), + json.dumps(normalized, default=str), + ), + ) + + event_type = "uptime_up" + severity = "success" + title = f"{service_name} er UP" + else: + event_type = "uptime_unknown" + severity = "info" + title = f"{service_name} status ukendt" + + event_row = MissionService.insert_event( + event_type=event_type, + title=title, + severity=severity, + source="uptime", + customer_name=customer_name, + payload={"alert_key": alert_key, **normalized}, + ) + + await mission_ws_manager.broadcast( + "uptime_alert", + { + "alert_key": alert_key, + "status": status, + "service_name": service_name, + "customer_name": customer_name, + "active_alerts": MissionService.get_active_alerts(), + }, + ) + await mission_ws_manager.broadcast("live_feed_event", event_row) + + return {"status": "ok", "normalized": normalized} diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py new file mode 100644 index 0000000..53b0ef1 --- /dev/null +++ b/app/dashboard/backend/mission_service.py @@ -0,0 +1,201 @@ +import json +from typing import Any, Dict, Optional + +from app.core.database import execute_query, execute_query_single + + +class MissionService: + @staticmethod + 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 or value == "": + return default + return str(value) + + @staticmethod + def parse_json_setting(key: str, default: Any) -> Any: + raw = MissionService.get_setting_value(key, None) + if raw is None: + return default + try: + return json.loads(raw) + except Exception: + return default + + @staticmethod + def build_alert_key(service_name: str, customer_name: Optional[str]) -> str: + customer_part = (customer_name or "").strip().lower() or "global" + return f"{service_name.strip().lower()}::{customer_part}" + + @staticmethod + def resolve_contact_context(caller_number: Optional[str]) -> Dict[str, Optional[str]]: + if not caller_number: + return {"contact_name": None, "company_name": None, "customer_tag": None} + + query = """ + SELECT + c.id, + c.first_name, + c.last_name, + ( + SELECT cu.name + FROM contact_companies cc + JOIN customers cu ON cu.id = cc.customer_id + WHERE cc.contact_id = c.id + ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC + LIMIT 1 + ) AS company_name, + ( + SELECT t.name + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'contact' + AND et.entity_id = c.id + AND LOWER(t.name) IN ('vip', 'serviceaftale', 'service agreement') + ORDER BY t.name + LIMIT 1 + ) AS customer_tag + FROM contacts c + WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8) + OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8) + LIMIT 1 + """ + row = execute_query_single(query, (caller_number, caller_number)) + if not row: + return {"contact_name": None, "company_name": None, "customer_tag": None} + + contact_name = f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip() or None + return { + "contact_name": contact_name, + "company_name": row.get("company_name"), + "customer_tag": row.get("customer_tag"), + } + + @staticmethod + def insert_event( + *, + event_type: str, + title: str, + severity: str = "info", + source: Optional[str] = None, + customer_name: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + rows = execute_query( + """ + INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload) + VALUES (%s, %s, %s, %s, %s, %s::jsonb) + RETURNING id, event_type, severity, title, source, customer_name, payload, created_at + """, + (event_type, severity, title, source, customer_name, json.dumps(payload or {})), + ) + return rows[0] if rows else {} + + @staticmethod + def get_kpis() -> Dict[str, int]: + query = """ + SELECT + COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet') AS open_cases, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) = 'åben' AND ansvarlig_bruger_id IS NULL) AS new_cases, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND ansvarlig_bruger_id IS NULL) AS unassigned_cases, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS deadlines_today, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_deadlines + FROM sag_sager + """ + row = execute_query_single(query) or {} + return { + "open_cases": int(row.get("open_cases") or 0), + "new_cases": int(row.get("new_cases") or 0), + "unassigned_cases": int(row.get("unassigned_cases") or 0), + "deadlines_today": int(row.get("deadlines_today") or 0), + "overdue_deadlines": int(row.get("overdue_deadlines") or 0), + } + + @staticmethod + def get_employee_deadlines() -> list[Dict[str, Any]]: + rows = execute_query( + """ + SELECT + COALESCE(u.full_name, u.username, 'Ukendt') AS employee_name, + COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) AS deadlines_today, + COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) AS overdue_deadlines + FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + WHERE s.deleted_at IS NULL + AND LOWER(s.status) <> 'afsluttet' + AND s.deadline IS NOT NULL + GROUP BY COALESCE(u.full_name, u.username, 'Ukendt') + HAVING COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) > 0 + OR COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) > 0 + ORDER BY overdue_deadlines DESC, deadlines_today DESC, employee_name ASC + """ + ) or [] + return [ + { + "employee_name": row.get("employee_name"), + "deadlines_today": int(row.get("deadlines_today") or 0), + "overdue_deadlines": int(row.get("overdue_deadlines") or 0), + } + for row in rows + ] + + @staticmethod + def get_active_calls() -> list[Dict[str, Any]]: + rows = execute_query( + """ + SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at + FROM mission_call_state + WHERE state = 'ringing' + ORDER BY started_at DESC + """ + ) + return rows or [] + + @staticmethod + def get_active_alerts() -> list[Dict[str, Any]]: + rows = execute_query( + """ + SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at + FROM mission_uptime_alerts + WHERE is_active = TRUE + ORDER BY started_at ASC NULLS LAST + """ + ) + return rows or [] + + @staticmethod + def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]: + rows = execute_query( + """ + SELECT id, event_type, severity, title, source, customer_name, payload, created_at + FROM mission_events + ORDER BY created_at DESC + LIMIT %s + """, + (limit,), + ) + return rows or [] + + @staticmethod + def get_state() -> Dict[str, Any]: + return { + "kpis": MissionService.get_kpis(), + "active_calls": MissionService.get_active_calls(), + "employee_deadlines": MissionService.get_employee_deadlines(), + "active_alerts": MissionService.get_active_alerts(), + "live_feed": MissionService.get_live_feed(20), + "config": { + "display_queues": MissionService.parse_json_setting("mission_display_queues", []), + "sound_enabled": str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true", + "sound_volume": int(MissionService.get_setting_value("mission_sound_volume", "70") or 70), + "sound_events": MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]), + "kpi_visible": MissionService.parse_json_setting( + "mission_kpi_visible", + ["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"], + ), + "customer_filter": MissionService.get_setting_value("mission_customer_filter", "") or "", + }, + } diff --git a/app/dashboard/backend/mission_ws.py b/app/dashboard/backend/mission_ws.py new file mode 100644 index 0000000..baa94f7 --- /dev/null +++ b/app/dashboard/backend/mission_ws.py @@ -0,0 +1,45 @@ +import asyncio +import json +import logging +from typing import Set + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class MissionConnectionManager: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self._connections: Set[WebSocket] = set() + + async def connect(self, websocket: WebSocket) -> None: + await websocket.accept() + async with self._lock: + self._connections.add(websocket) + logger.info("📡 Mission WS connected (%s active)", len(self._connections)) + + async def disconnect(self, websocket: WebSocket) -> None: + async with self._lock: + self._connections.discard(websocket) + logger.info("📡 Mission WS disconnected (%s active)", len(self._connections)) + + async def broadcast(self, event: str, payload: dict) -> None: + message = json.dumps({"event": event, "data": payload}, default=str) + async with self._lock: + targets = list(self._connections) + + dead: list[WebSocket] = [] + for websocket in targets: + try: + await websocket.send_text(message) + except Exception: + dead.append(websocket) + + if dead: + async with self._lock: + for websocket in dead: + self._connections.discard(websocket) + + +mission_ws_manager = MissionConnectionManager() diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py index 8b07c6b..f1fa441 100644 --- a/app/dashboard/backend/views.py +++ b/app/dashboard/backend/views.py @@ -344,3 +344,13 @@ async def clear_default_dashboard( async def clear_default_dashboard_get_fallback(): return RedirectResponse(url="/settings#system", status_code=303) + +@router.get("/dashboard/mission-control", response_class=HTMLResponse) +async def mission_control_dashboard(request: Request): + return templates.TemplateResponse( + "dashboard/frontend/mission_control.html", + { + "request": request, + } + ) + diff --git a/app/dashboard/frontend/mission_control.html b/app/dashboard/frontend/mission_control.html new file mode 100644 index 0000000..dfa8a47 --- /dev/null +++ b/app/dashboard/frontend/mission_control.html @@ -0,0 +1,576 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Mission Control - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +