Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f684325146 |
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`.
|
||||
@ -238,6 +238,9 @@ class Settings(BaseSettings):
|
||||
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
||||
TELEFONI_IP_WHITELIST: str = "172.16.31.0/24" # CSV of IPs/CIDRs, e.g. "192.168.1.0/24,10.0.0.10"
|
||||
|
||||
# Mission Control webhooks
|
||||
MISSION_WEBHOOK_TOKEN: str = ""
|
||||
|
||||
# ESET Integration
|
||||
ESET_ENABLED: bool = False
|
||||
ESET_API_URL: str = "https://eu.device-management.eset.systems"
|
||||
|
||||
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():
|
||||
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.dashboard.backend import views as dashboard_views
|
||||
from app.dashboard.backend import router as dashboard_api
|
||||
from app.dashboard.backend import mission_router as mission_api
|
||||
from app.prepaid.backend import router as prepaid_api
|
||||
from app.prepaid.backend import views as prepaid_views
|
||||
from app.fixed_price.backend import router as fixed_price_api
|
||||
@ -208,6 +209,10 @@ async def auth_middleware(request: Request, call_next):
|
||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
||||
public_paths.add("/api/v1/telefoni/established")
|
||||
public_paths.add("/api/v1/telefoni/terminated")
|
||||
public_paths.add("/api/v1/mission/webhook/telefoni/ringing")
|
||||
public_paths.add("/api/v1/mission/webhook/telefoni/answered")
|
||||
public_paths.add("/api/v1/mission/webhook/telefoni/hangup")
|
||||
public_paths.add("/api/v1/mission/webhook/uptime")
|
||||
|
||||
if settings.DEV_ALLOW_ARCHIVED_IMPORT:
|
||||
public_paths.add("/api/v1/ticket/archived/simply/import")
|
||||
@ -279,6 +284,7 @@ app.include_router(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"])
|
||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
||||
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
||||
app.include_router(mission_api.router, prefix="/api/v1", tags=["Mission"])
|
||||
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
|
||||
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
||||
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
|
||||
|
||||
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