feat(mission): add Mission Control MVP with realtime webhooks and fullscreen dashboard
This commit is contained in:
parent
827463d59e
commit
8b863a3b68
45
RELEASE_NOTES_v2.2.39.md
Normal file
45
RELEASE_NOTES_v2.2.39.md
Normal 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`.
|
||||||
@ -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
|
||||||
|
|||||||
341
app/dashboard/backend/mission_router.py
Normal file
341
app/dashboard/backend/mission_router.py
Normal 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}
|
||||||
201
app/dashboard/backend/mission_service.py
Normal file
201
app/dashboard/backend/mission_service.py
Normal 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 "",
|
||||||
|
},
|
||||||
|
}
|
||||||
45
app/dashboard/backend/mission_ws.py
Normal file
45
app/dashboard/backend/mission_ws.py
Normal 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()
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
576
app/dashboard/frontend/mission_control.html
Normal file
576
app/dashboard/frontend/mission_control.html
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
6
main.py
6
main.py
@ -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"])
|
||||||
|
|||||||
59
migrations/142_mission_control.sql
Normal file
59
migrations/142_mission_control.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user