From 8b863a3b68673eb69e8036f9e0f1b0ee58e27fa8 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Mar 2026 22:11:45 +0100 Subject: [PATCH] feat(mission): add Mission Control MVP with realtime webhooks and fullscreen dashboard --- RELEASE_NOTES_v2.2.39.md | 45 ++ VERSION | 2 +- app/core/config.py | 3 + app/dashboard/backend/mission_router.py | 341 ++++++++++++ app/dashboard/backend/mission_service.py | 201 +++++++ app/dashboard/backend/mission_ws.py | 45 ++ app/dashboard/backend/views.py | 10 + app/dashboard/frontend/mission_control.html | 576 ++++++++++++++++++++ main.py | 6 + migrations/142_mission_control.sql | 59 ++ 10 files changed, 1287 insertions(+), 1 deletion(-) create mode 100644 RELEASE_NOTES_v2.2.39.md create mode 100644 app/dashboard/backend/mission_router.py create mode 100644 app/dashboard/backend/mission_service.py create mode 100644 app/dashboard/backend/mission_ws.py create mode 100644 app/dashboard/frontend/mission_control.html create mode 100644 migrations/142_mission_control.sql 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 %} +
+
+
+
Ingen aktive driftsalarmer
+
+ + + Forbinder... +
+
+
+ +
+
+

Opgave-overblik

+
+
+
Indgående opkald
+
+
+
+
+ +
+

Aktive opkald

+
+
+
+ +
+
+

Deadlines pr. medarbejder

+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+ +
+

Live aktivitetsfeed

+
+
+
+
+ + +{% endblock %} diff --git a/main.py b/main.py index ac38736..455bc8e 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,7 @@ from app.system.backend import router as system_api from app.system.backend import sync_router from app.dashboard.backend import views as dashboard_views from app.dashboard.backend import router as dashboard_api +from app.dashboard.backend import mission_router as mission_api from app.prepaid.backend import router as prepaid_api from app.prepaid.backend import views as prepaid_views from app.fixed_price.backend import router as fixed_price_api @@ -208,6 +209,10 @@ async def auth_middleware(request: Request, call_next): # Yealink Action URL callbacks (secured inside telefoni module by token/IP) public_paths.add("/api/v1/telefoni/established") public_paths.add("/api/v1/telefoni/terminated") + public_paths.add("/api/v1/mission/webhook/telefoni/ringing") + public_paths.add("/api/v1/mission/webhook/telefoni/answered") + public_paths.add("/api/v1/mission/webhook/telefoni/hangup") + public_paths.add("/api/v1/mission/webhook/uptime") if settings.DEV_ALLOW_ARCHIVED_IMPORT: public_paths.add("/api/v1/ticket/archived/simply/import") @@ -279,6 +284,7 @@ app.include_router(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"]) app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"]) +app.include_router(mission_api.router, prefix="/api/v1", tags=["Mission"]) app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"]) diff --git a/migrations/142_mission_control.sql b/migrations/142_mission_control.sql new file mode 100644 index 0000000..8873f8f --- /dev/null +++ b/migrations/142_mission_control.sql @@ -0,0 +1,59 @@ +-- Migration 142: Mission Control dashboard foundation + +CREATE TABLE IF NOT EXISTS mission_events ( + id BIGSERIAL PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'info', + title VARCHAR(255) NOT NULL, + source VARCHAR(64), + customer_name VARCHAR(255), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_mission_events_created_at ON mission_events(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mission_events_event_type ON mission_events(event_type); + +CREATE TABLE IF NOT EXISTS mission_call_state ( + call_id VARCHAR(128) PRIMARY KEY, + queue_name VARCHAR(128), + caller_number VARCHAR(64), + contact_name VARCHAR(255), + company_name VARCHAR(255), + customer_tag VARCHAR(64), + state VARCHAR(32) NOT NULL, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + answered_at TIMESTAMP, + ended_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_payload JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_mission_call_state_state ON mission_call_state(state); +CREATE INDEX IF NOT EXISTS idx_mission_call_state_updated_at ON mission_call_state(updated_at DESC); + +CREATE TABLE IF NOT EXISTS mission_uptime_alerts ( + alert_key VARCHAR(255) PRIMARY KEY, + service_name VARCHAR(255) NOT NULL, + customer_name VARCHAR(255), + status VARCHAR(32) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + started_at TIMESTAMP, + resolved_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_mission_uptime_active ON mission_uptime_alerts(is_active, updated_at DESC); + +INSERT INTO settings (key, value, category, description, value_type, is_public) +VALUES + ('mission_webhook_token', '', 'mission', 'Shared token for mission webhooks', 'string', false), + ('mission_display_queues', '[]', 'mission', 'JSON array of queue names shown on Mission Control', 'json', true), + ('mission_sound_enabled', 'true', 'mission', 'Enable sound notifications on Mission Control', 'boolean', true), + ('mission_sound_volume', '70', 'mission', 'Mission Control sound volume (0-100)', 'integer', true), + ('mission_sound_events', '["incoming_call","uptime_down","critical_event"]', 'mission', 'JSON array of event types that trigger sound', 'json', true), + ('mission_kpi_visible', '["open_cases","new_cases","unassigned_cases","deadlines_today","overdue_deadlines"]', 'mission', 'JSON array of KPI keys shown on Mission Control', 'json', true), + ('mission_customer_filter', '', 'mission', 'Optional customer filter for Mission Control', 'string', true) +ON CONFLICT (key) DO NOTHING;