feat(mission): add Mission Control MVP with realtime webhooks and fullscreen dashboard

This commit is contained in:
Christian 2026-03-03 22:11:45 +01:00
parent b80f91fae1
commit 5a72c8392f
10 changed files with 1287 additions and 1 deletions

45
RELEASE_NOTES_v2.2.39.md Normal file
View File

@ -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`.

View File

@ -1 +1 @@
2.2.37 2.2.39

View File

@ -237,6 +237,9 @@ class Settings(BaseSettings):
# Telefoni (Yealink) Integration # Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=... 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" 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 Integration
ESET_ENABLED: bool = False ESET_ENABLED: bool = False

View File

@ -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}

View File

@ -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 "",
},
}

View File

@ -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()

View File

@ -344,3 +344,13 @@ async def clear_default_dashboard(
async def clear_default_dashboard_get_fallback(): async def clear_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303) 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,
}
)

View File

@ -0,0 +1,576 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Mission Control - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--mc-bg: #0b1320;
--mc-surface: #121d2f;
--mc-surface-2: #16243a;
--mc-border: #2c3c58;
--mc-text: #e9f1ff;
--mc-text-muted: #9fb3d1;
--mc-danger: #ef4444;
--mc-warning: #f59e0b;
--mc-success: #10b981;
--mc-info: #3b82f6;
}
body {
background: var(--mc-bg) !important;
color: var(--mc-text);
}
main.container-fluid {
max-width: 100% !important;
padding: 0.75rem 1rem 1rem 1rem !important;
}
.mc-grid {
display: grid;
gap: 0.75rem;
grid-template-rows: auto 1fr auto;
min-height: calc(100vh - 90px);
}
.mc-card {
background: linear-gradient(180deg, var(--mc-surface) 0%, var(--mc-surface-2) 100%);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 0.75rem 1rem;
}
.mc-top {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.mc-alert-bar {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.15rem;
font-weight: 700;
padding: 0.9rem 1rem;
border-radius: 12px;
}
.mc-alert-bar.down {
background: rgba(239, 68, 68, 0.18);
border: 1px solid rgba(239, 68, 68, 0.55);
color: #ffd6d6;
}
.mc-alert-empty {
color: var(--mc-text-muted);
font-size: 0.95rem;
}
.mc-middle {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 0.75rem;
min-height: 0;
}
.mc-kpi-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.65rem;
}
.mc-kpi {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--mc-border);
border-radius: 12px;
padding: 0.85rem;
}
.mc-kpi .label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mc-text-muted);
}
.mc-kpi .value {
font-size: 2rem;
line-height: 1;
font-weight: 800;
margin-top: 0.45rem;
}
.mc-kpi.warning { border-color: rgba(245, 158, 11, 0.55); }
.mc-kpi.danger { border-color: rgba(239, 68, 68, 0.55); }
.mc-call-overlay {
display: none;
margin-top: 0.75rem;
background: rgba(59, 130, 246, 0.14);
border: 2px solid rgba(59, 130, 246, 0.65);
border-radius: 14px;
padding: 1rem;
}
.mc-call-overlay.active {
display: block;
}
.mc-call-title {
font-size: 1.7rem;
font-weight: 800;
margin-bottom: 0.35rem;
}
.mc-call-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 1.05rem;
}
.mc-badge {
border-radius: 999px;
border: 1px solid var(--mc-border);
background: rgba(255, 255, 255, 0.05);
padding: 0.18rem 0.55rem;
font-size: 0.85rem;
color: var(--mc-text-muted);
}
.mc-bottom {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 0.75rem;
min-height: 0;
}
.mc-table,
.mc-feed {
max-height: 30vh;
overflow: auto;
}
.mc-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid rgba(159, 179, 209, 0.12);
font-size: 0.95rem;
}
.mc-row:last-child {
border-bottom: none;
}
.mc-feed-item {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(159, 179, 209, 0.12);
}
.mc-feed-item:last-child {
border-bottom: none;
}
.mc-feed-title {
font-weight: 600;
}
.mc-feed-meta {
color: var(--mc-text-muted);
font-size: 0.8rem;
}
.mc-controls {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.mc-controls label {
color: var(--mc-text-muted);
font-size: 0.85rem;
}
.mc-connection {
font-size: 0.8rem;
color: var(--mc-text-muted);
}
.mc-hidden {
display: none !important;
}
@media (max-width: 1300px) {
.mc-middle,
.mc-bottom {
grid-template-columns: 1fr;
}
.mc-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
{% endblock %}
{% block content %}
<div class="mc-grid">
<section class="mc-top">
<div class="mc-card">
<div id="alertContainer" class="mc-alert-empty">Ingen aktive driftsalarmer</div>
<div class="mc-controls">
<label><input type="checkbox" id="soundEnabledToggle" checked> Lyd aktiv</label>
<label>Lydniveau <input type="range" id="soundVolume" min="0" max="100" value="70"></label>
<span id="connectionState" class="mc-connection">Forbinder...</span>
</div>
</div>
</section>
<section class="mc-middle">
<div class="mc-card">
<h4 class="mb-3">Opgave-overblik</h4>
<div id="kpiGrid" class="mc-kpi-grid"></div>
<div id="callOverlay" class="mc-call-overlay">
<div class="mc-call-title">Indgående opkald</div>
<div id="callPrimary" style="font-size:1.35rem;font-weight:700;"></div>
<div id="callSecondary" class="mc-call-meta mt-2"></div>
</div>
</div>
<div class="mc-card">
<h4 class="mb-3">Aktive opkald</h4>
<div id="activeCallsList" class="mc-feed"></div>
</div>
</section>
<section class="mc-bottom">
<div class="mc-card">
<h4 class="mb-3">Deadlines pr. medarbejder</h4>
<div class="mc-row" style="font-weight:700;color:var(--mc-text-muted);text-transform:uppercase;font-size:0.75rem;">
<div>Medarbejder</div>
<div>I dag</div>
<div>Overskredet</div>
</div>
<div id="deadlineTable" class="mc-table"></div>
</div>
<div class="mc-card">
<h4 class="mb-3">Live aktivitetsfeed</h4>
<div id="liveFeed" class="mc-feed"></div>
</div>
</section>
</div>
<script>
(() => {
const kpiLabels = {
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
overdue_deadlines: 'Overskredne'
};
const state = {
ws: null,
reconnectAttempts: 0,
reconnectTimer: null,
failures: 0,
config: {
sound_enabled: true,
sound_volume: 70,
sound_events: ['incoming_call', 'uptime_down', 'critical_event'],
kpi_visible: Object.keys(kpiLabels),
display_queues: []
},
activeCalls: [],
activeAlerts: [],
liveFeed: []
};
function updateConnectionLabel(text) {
const el = document.getElementById('connectionState');
if (el) el.textContent = text;
}
function playTone(type) {
const soundEnabledToggle = document.getElementById('soundEnabledToggle');
if (!soundEnabledToggle || !soundEnabledToggle.checked) return;
if (!state.config.sound_events.includes(type)) return;
const volumeSlider = document.getElementById('soundVolume');
const volumePct = Number(volumeSlider?.value || state.config.sound_volume || 70);
const gainValue = Math.max(0, Math.min(1, volumePct / 100));
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
const context = new AudioCtx();
const oscillator = context.createOscillator();
const gainNode = context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = type === 'uptime_down' ? 260 : 620;
gainNode.gain.value = gainValue * 0.2;
oscillator.connect(gainNode);
gainNode.connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + (type === 'uptime_down' ? 0.35 : 0.15));
}
function escapeHtml(str) {
return String(str ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function formatDate(value) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('da-DK');
}
function renderKpis(kpis = {}) {
const container = document.getElementById('kpiGrid');
if (!container) return;
const visible = Array.isArray(state.config.kpi_visible) && state.config.kpi_visible.length
? state.config.kpi_visible
: Object.keys(kpiLabels);
container.innerHTML = visible.map((key) => {
const value = Number(kpis[key] ?? 0);
const variant = key === 'overdue_deadlines' && value > 0
? 'danger'
: key === 'deadlines_today' && value > 0
? 'warning'
: '';
return `
<div class="mc-kpi ${variant}">
<div class="label">${escapeHtml(kpiLabels[key] || key)}</div>
<div class="value">${value}</div>
</div>
`;
}).join('');
}
function renderActiveCalls() {
const list = document.getElementById('activeCallsList');
const overlay = document.getElementById('callOverlay');
const primary = document.getElementById('callPrimary');
const secondary = document.getElementById('callSecondary');
if (!list || !overlay || !primary || !secondary) return;
const queueFilter = Array.isArray(state.config.display_queues) ? state.config.display_queues : [];
const calls = state.activeCalls.filter(c => {
if (!queueFilter.length) return true;
return queueFilter.includes(c.queue_name);
});
if (!calls.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen aktive opkald</div>';
overlay.classList.remove('active');
return;
}
const call = calls[0];
overlay.classList.add('active');
primary.textContent = `${call.queue_name || 'Ukendt kø'} • ${call.caller_number || 'Ukendt nummer'}`;
secondary.innerHTML = [
call.contact_name ? `<span class="mc-badge">${escapeHtml(call.contact_name)}</span>` : '',
call.company_name ? `<span class="mc-badge">${escapeHtml(call.company_name)}</span>` : '',
call.customer_tag ? `<span class="mc-badge">${escapeHtml(call.customer_tag)}</span>` : '',
call.started_at ? `<span class="mc-badge">${escapeHtml(formatDate(call.started_at))}</span>` : ''
].join(' ');
list.innerHTML = calls.map((item) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(item.queue_name || 'Ukendt kø')} • ${escapeHtml(item.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(item.contact_name || 'Ukendt kontakt')}
${item.company_name ? ` • ${escapeHtml(item.company_name)}` : ''}
</div>
</div>
`).join('');
}
function renderAlerts() {
const container = document.getElementById('alertContainer');
if (!container) return;
if (!state.activeAlerts.length) {
container.className = 'mc-alert-empty';
container.textContent = 'Ingen aktive driftsalarmer';
return;
}
container.className = '';
container.innerHTML = state.activeAlerts.map((alert) => `
<div class="mc-alert-bar down mb-2">
<span>🚨</span>
<span>${escapeHtml(alert.service_name || 'Ukendt service')}</span>
${alert.customer_name ? `<span class="mc-badge">${escapeHtml(alert.customer_name)}</span>` : ''}
<span class="mc-badge">Start: ${escapeHtml(formatDate(alert.started_at))}</span>
</div>
`).join('');
}
function renderDeadlines(rows = []) {
const table = document.getElementById('deadlineTable');
if (!table) return;
if (!rows.length) {
table.innerHTML = '<div class="mc-feed-meta py-2">Ingen deadlines i dag eller overskredne</div>';
return;
}
table.innerHTML = rows.map((row) => `
<div class="mc-row">
<div>${escapeHtml(row.employee_name || 'Ukendt')}</div>
<div>${Number(row.deadlines_today || 0)}</div>
<div style="color:${Number(row.overdue_deadlines || 0) > 0 ? '#ff9d9d' : 'inherit'}">${Number(row.overdue_deadlines || 0)}</div>
</div>
`).join('');
}
function renderFeed() {
const feed = document.getElementById('liveFeed');
if (!feed) return;
if (!state.liveFeed.length) {
feed.innerHTML = '<div class="mc-feed-meta">Ingen events endnu</div>';
return;
}
feed.innerHTML = state.liveFeed.slice(0, 20).map((event) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(event.title || event.event_type || 'Event')}</div>
<div class="mc-feed-meta">${escapeHtml(event.event_type || 'event')} • ${escapeHtml(formatDate(event.created_at))}</div>
</div>
`).join('');
}
function renderState(payload) {
if (!payload) return;
state.config = { ...state.config, ...(payload.config || {}) };
state.activeCalls = Array.isArray(payload.active_calls) ? payload.active_calls : state.activeCalls;
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
const soundToggle = document.getElementById('soundEnabledToggle');
const volumeSlider = document.getElementById('soundVolume');
if (soundToggle) soundToggle.checked = !!state.config.sound_enabled;
if (volumeSlider) volumeSlider.value = String(state.config.sound_volume || 70);
renderKpis(payload.kpis || {});
renderActiveCalls();
renderAlerts();
renderDeadlines(Array.isArray(payload.employee_deadlines) ? payload.employee_deadlines : []);
renderFeed();
}
async function loadInitialState() {
const res = await fetch('/api/v1/mission/state', { credentials: 'include' });
if (!res.ok) throw new Error('Kunne ikke hente mission state');
const payload = await res.json();
renderState(payload);
}
function scheduleReconnect() {
if (state.reconnectTimer) return;
state.reconnectAttempts += 1;
const delay = Math.min(30000, 1500 * state.reconnectAttempts);
updateConnectionLabel(`Frakoblet • reconnect om ${Math.round(delay / 1000)}s`);
state.reconnectTimer = setTimeout(() => {
state.reconnectTimer = null;
connectWs();
}, delay);
}
function connectWs() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/v1/mission/ws`;
state.ws = new WebSocket(url);
state.ws.onopen = () => {
state.reconnectAttempts = 0;
updateConnectionLabel('Live forbindelse aktiv');
};
state.ws.onclose = () => {
state.failures += 1;
if (state.failures >= 12) {
window.location.reload();
return;
}
scheduleReconnect();
};
state.ws.onerror = () => {};
state.ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
const event = msg?.event;
const data = msg?.data || {};
if (event === 'mission_state') {
renderState(data);
return;
}
if (event === 'kpi_update') {
renderKpis(data);
return;
}
if (event === 'call_ringing') {
state.activeCalls = [data, ...state.activeCalls.filter(c => c.call_id !== data.call_id)];
renderActiveCalls();
playTone('incoming_call');
return;
}
if (event === 'call_answered' || event === 'call_hangup') {
const id = data.call_id;
state.activeCalls = state.activeCalls.filter(c => c.call_id !== id);
renderActiveCalls();
return;
}
if (event === 'uptime_alert') {
state.activeAlerts = Array.isArray(data.active_alerts) ? data.active_alerts : state.activeAlerts;
renderAlerts();
if ((data.status || '').toUpperCase() === 'DOWN') {
playTone('uptime_down');
}
return;
}
if (event === 'live_feed_event') {
state.liveFeed = [data, ...state.liveFeed.filter(item => item.id !== data.id)].slice(0, 20);
renderFeed();
}
} catch (error) {
console.error('Mission message parse failed', error);
}
};
}
document.addEventListener('DOMContentLoaded', async () => {
try {
await loadInitialState();
} catch (error) {
updateConnectionLabel('Fejl ved initial load');
console.error(error);
}
connectWs();
});
})();
</script>
{% endblock %}

View File

@ -36,6 +36,7 @@ from app.system.backend import router as system_api
from app.system.backend import sync_router from app.system.backend import sync_router
from app.dashboard.backend import views as dashboard_views from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api 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 router as prepaid_api
from app.prepaid.backend import views as prepaid_views from app.prepaid.backend import views as prepaid_views
from app.fixed_price.backend import router as fixed_price_api 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) # Yealink Action URL callbacks (secured inside telefoni module by token/IP)
public_paths.add("/api/v1/telefoni/established") public_paths.add("/api/v1/telefoni/established")
public_paths.add("/api/v1/telefoni/terminated") 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: if settings.DEV_ALLOW_ARCHIVED_IMPORT:
public_paths.add("/api/v1/ticket/archived/simply/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(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) 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(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(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(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"]) app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])

View File

@ -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;