1449 lines
46 KiB
HTML
1449 lines
46 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Mission Control - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
:root {
|
|
--mc-bg: #081422;
|
|
--mc-surface: #10243a;
|
|
--mc-surface-2: #14304d;
|
|
--mc-border: #2c4564;
|
|
--mc-text: #e7f2ff;
|
|
--mc-text-muted: #9db5d2;
|
|
--mc-accent: #0f4c75;
|
|
--mc-accent-soft: rgba(15, 76, 117, 0.3);
|
|
--mc-danger: #ef4444;
|
|
--mc-warning: #f59e0b;
|
|
--mc-success: #10b981;
|
|
}
|
|
|
|
body {
|
|
background: radial-gradient(circle at 85% -10%, #1b3f63 0%, var(--mc-bg) 55%) !important;
|
|
color: var(--mc-text);
|
|
}
|
|
|
|
main.container-fluid {
|
|
max-width: 100% !important;
|
|
padding: 0.8rem 1rem 1.1rem 1rem !important;
|
|
}
|
|
|
|
.mc-shell {
|
|
display: grid;
|
|
grid-template-rows: auto auto 1fr auto;
|
|
gap: 0.85rem;
|
|
min-height: calc(100vh - 95px);
|
|
}
|
|
|
|
.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.85rem 1rem;
|
|
}
|
|
|
|
.mc-header {
|
|
display: grid;
|
|
gap: 0.7rem;
|
|
}
|
|
|
|
.mc-title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.mc-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.mc-subtle {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.mc-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.mc-controls label {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.mc-nav {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.mc-nav-btn {
|
|
border: 1px solid var(--mc-border);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--mc-text);
|
|
border-radius: 12px;
|
|
min-height: 56px;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.01em;
|
|
transition: 0.15s ease;
|
|
}
|
|
|
|
.mc-nav-btn.active {
|
|
border-color: #6db5e5;
|
|
background: linear-gradient(180deg, rgba(31, 106, 157, 0.6), rgba(16, 55, 86, 0.7));
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.mc-nav-btn:focus-visible,
|
|
.mc-chip:focus-visible,
|
|
.mc-duration-btn:focus-visible {
|
|
outline: 2px solid #9ad8ff;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.mc-filter-row {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.mc-chip {
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--mc-text-muted);
|
|
padding: 0.33rem 0.8rem;
|
|
font-size: 0.84rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.mc-chip.active {
|
|
color: #dff3ff;
|
|
border-color: #77b6df;
|
|
background: var(--mc-accent-soft);
|
|
}
|
|
|
|
.mc-view {
|
|
display: none;
|
|
}
|
|
|
|
.mc-view.active {
|
|
display: block;
|
|
animation: mcFadeIn 0.2s ease;
|
|
}
|
|
|
|
@keyframes mcFadeIn {
|
|
from { opacity: 0; transform: translateY(3px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.mc-view-grid {
|
|
display: grid;
|
|
grid-template-columns: 1.3fr 1fr;
|
|
gap: 0.8rem;
|
|
}
|
|
|
|
.mc-alert-box {
|
|
display: grid;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.mc-alert {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(239, 68, 68, 0.58);
|
|
background: rgba(239, 68, 68, 0.18);
|
|
padding: 0.58rem 0.75rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.mc-alert-empty {
|
|
color: var(--mc-text-muted);
|
|
}
|
|
|
|
.mc-kpis {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 0.6rem;
|
|
margin-top: 0.8rem;
|
|
}
|
|
|
|
.mc-kpi {
|
|
border: 1px solid rgba(157, 181, 210, 0.25);
|
|
border-radius: 12px;
|
|
padding: 0.7rem;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.mc-kpi .label {
|
|
color: var(--mc-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.mc-kpi .value {
|
|
margin-top: 0.3rem;
|
|
font-size: 1.7rem;
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
}
|
|
|
|
.mc-kpi.warning {
|
|
border-color: rgba(245, 158, 11, 0.55);
|
|
}
|
|
|
|
.mc-kpi.danger {
|
|
border-color: rgba(239, 68, 68, 0.55);
|
|
}
|
|
|
|
.mc-call-hero {
|
|
margin-top: 0.75rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--mc-border);
|
|
padding: 0.75rem;
|
|
background: rgba(59, 130, 246, 0.11);
|
|
}
|
|
|
|
.mc-call-hero-title {
|
|
font-size: 1.15rem;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.mc-call-hero-sub {
|
|
color: var(--mc-text-muted);
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.mc-environment {
|
|
margin-top: 0.75rem;
|
|
border-top: 1px solid rgba(157, 181, 210, 0.18);
|
|
padding-top: 0.65rem;
|
|
}
|
|
|
|
.mc-environment-head {
|
|
font-size: 0.86rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--mc-text-muted);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.mc-env-list {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.mc-env-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 0.6rem;
|
|
border-bottom: 1px solid rgba(157, 181, 210, 0.12);
|
|
padding: 0.26rem 0;
|
|
}
|
|
|
|
.mc-env-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.mc-env-name {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.mc-env-value {
|
|
font-size: 1.05rem;
|
|
font-weight: 800;
|
|
color: #c8e9ff;
|
|
}
|
|
|
|
.mc-env-meta {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.mc-camera-card {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.mc-camera-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.6rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.mc-camera-status {
|
|
font-size: 0.78rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border);
|
|
padding: 0.2rem 0.56rem;
|
|
color: var(--mc-text-muted);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
}
|
|
|
|
.mc-camera-status.live {
|
|
color: #c0ffe1;
|
|
border-color: rgba(16, 185, 129, 0.6);
|
|
background: rgba(16, 185, 129, 0.14);
|
|
}
|
|
|
|
.mc-camera-status.connecting {
|
|
color: #ffe6b4;
|
|
border-color: rgba(245, 158, 11, 0.55);
|
|
background: rgba(245, 158, 11, 0.14);
|
|
}
|
|
|
|
.mc-camera-status.error {
|
|
color: #ffd0d0;
|
|
border-color: rgba(239, 68, 68, 0.55);
|
|
background: rgba(239, 68, 68, 0.14);
|
|
}
|
|
|
|
.mc-camera-preview {
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
background: #000;
|
|
min-height: 190px;
|
|
}
|
|
|
|
.mc-camera-preview img,
|
|
.mc-camera-preview iframe {
|
|
width: 100%;
|
|
min-height: 190px;
|
|
display: block;
|
|
border: 0;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.mc-camera-preview.large {
|
|
min-height: 53vh;
|
|
}
|
|
|
|
.mc-camera-preview.large img,
|
|
.mc-camera-preview.large iframe {
|
|
min-height: 53vh;
|
|
}
|
|
|
|
.mc-camera-preview.empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--mc-text-muted);
|
|
background: rgba(0, 0, 0, 0.35);
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.mc-motion-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
font-size: 0.78rem;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
|
background: rgba(245, 158, 11, 0.15);
|
|
color: #ffdb9f;
|
|
padding: 0.23rem 0.55rem;
|
|
}
|
|
|
|
.mc-motion-pill.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.mc-duration-row {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.mc-duration-label {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.84rem;
|
|
}
|
|
|
|
.mc-duration-btn {
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 9px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--mc-text);
|
|
padding: 0.32rem 0.62rem;
|
|
font-size: 0.84rem;
|
|
font-weight: 700;
|
|
min-width: 54px;
|
|
}
|
|
|
|
.mc-duration-btn.active {
|
|
border-color: #7ec2ef;
|
|
background: var(--mc-accent-soft);
|
|
}
|
|
|
|
.mc-table {
|
|
width: 100%;
|
|
}
|
|
|
|
.mc-row,
|
|
.mc-row-head {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
|
gap: 0.55rem;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid rgba(157, 181, 210, 0.15);
|
|
align-items: center;
|
|
}
|
|
|
|
.mc-row-head {
|
|
color: var(--mc-text-muted);
|
|
text-transform: uppercase;
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.05em;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.mc-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.mc-case-title {
|
|
font-weight: 700;
|
|
font-size: 0.93rem;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.mc-case-sub {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.mc-case-link,
|
|
.mc-email-link {
|
|
color: var(--mc-text);
|
|
text-decoration: none;
|
|
border-bottom: 1px solid transparent;
|
|
}
|
|
|
|
.mc-case-link:hover,
|
|
.mc-email-link:hover {
|
|
color: #d9eeff;
|
|
border-bottom-color: rgba(126, 194, 239, 0.6);
|
|
}
|
|
|
|
.mc-email-list {
|
|
margin-top: 1rem;
|
|
border-top: 1px solid rgba(157, 181, 210, 0.18);
|
|
padding-top: 0.8rem;
|
|
}
|
|
|
|
.mc-email-row {
|
|
display: grid;
|
|
grid-template-columns: 1.5fr 1fr 1fr;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
padding: 0.45rem 0;
|
|
border-bottom: 1px solid rgba(157, 181, 210, 0.14);
|
|
}
|
|
|
|
.mc-email-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.mc-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
padding: 0.2rem 0.55rem;
|
|
font-size: 0.75rem;
|
|
color: var(--mc-text-muted);
|
|
}
|
|
|
|
.mc-feed {
|
|
max-height: 25vh;
|
|
overflow: auto;
|
|
display: grid;
|
|
gap: 0.55rem;
|
|
}
|
|
|
|
.mc-feed-item {
|
|
border-bottom: 1px solid rgba(157, 181, 210, 0.14);
|
|
padding-bottom: 0.45rem;
|
|
}
|
|
|
|
.mc-feed-item:last-child {
|
|
border-bottom: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.mc-feed-title {
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.mc-feed-meta {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.79rem;
|
|
}
|
|
|
|
.mc-camera-stage.is-spotlight {
|
|
border: 2px solid rgba(245, 158, 11, 0.62);
|
|
border-radius: 14px;
|
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
|
padding: 0.5rem;
|
|
animation: mcPulse 0.8s ease;
|
|
}
|
|
|
|
.mc-camera-preview.mc-spotlight-active {
|
|
position: fixed;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: min(94vw, 1260px);
|
|
min-height: min(78vh, 900px);
|
|
z-index: 3200;
|
|
border: 2px solid rgba(245, 158, 11, 0.62);
|
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18), 0 20px 60px rgba(0, 0, 0, 0.55);
|
|
animation: mcPulse 0.2s ease;
|
|
}
|
|
|
|
.mc-camera-preview.mc-spotlight-active img,
|
|
.mc-camera-preview.mc-spotlight-active iframe {
|
|
min-height: min(78vh, 900px);
|
|
}
|
|
|
|
@keyframes mcPulse {
|
|
from { transform: scale(0.99); }
|
|
to { transform: scale(1); }
|
|
}
|
|
|
|
@media (max-width: 1300px) {
|
|
.mc-view-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.mc-kpis {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.mc-row,
|
|
.mc-row-head {
|
|
grid-template-columns: 1.7fr 1fr 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.mc-nav {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.mc-camera-preview.mc-spotlight-active {
|
|
width: 96vw;
|
|
min-height: min(70vh, 720px);
|
|
}
|
|
|
|
.mc-camera-preview.mc-spotlight-active img,
|
|
.mc-camera-preview.mc-spotlight-active iframe {
|
|
min-height: min(70vh, 720px);
|
|
}
|
|
|
|
.mc-email-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.mc-row,
|
|
.mc-row-head {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.mc-row-head {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mc-shell">
|
|
<section class="mc-card mc-header">
|
|
<div class="mc-title-row">
|
|
<div>
|
|
<div class="mc-title">Mission Control</div>
|
|
<div class="mc-subtle" id="connectionState">Forbinder...</div>
|
|
</div>
|
|
<div class="mc-subtle" id="idleState">Auto reset: 10s inaktivitet</div>
|
|
</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>
|
|
</div>
|
|
|
|
<div class="mc-nav" id="missionNav">
|
|
<button class="mc-nav-btn active" type="button" data-view="overview">Overblik</button>
|
|
<button class="mc-nav-btn" type="button" data-view="important">Vigtige sager</button>
|
|
<button class="mc-nav-btn" type="button" data-view="calls">Opkald</button>
|
|
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mc-card">
|
|
<div class="mc-filter-row" id="caseFilterChips"></div>
|
|
</section>
|
|
|
|
<section>
|
|
<div id="view-overview" class="mc-view active">
|
|
<div class="mc-view-grid">
|
|
<div class="mc-card">
|
|
<h4 class="mb-2">Driftsstatus</h4>
|
|
<div id="alertContainer" class="mc-alert-empty">Ingen aktive driftsalarmer</div>
|
|
|
|
<div class="mc-kpis" id="kpiGrid"></div>
|
|
|
|
<div class="mc-call-hero" id="callHero">
|
|
<div class="mc-call-hero-title" id="callHeroTitle">Ingen aktive opkald</div>
|
|
<div class="mc-call-hero-sub" id="callHeroMeta">Mission overvager opkald og opdaterer live.</div>
|
|
</div>
|
|
|
|
<div class="mc-environment">
|
|
<div class="mc-environment-head">Temperatur sensorer</div>
|
|
<div id="environmentReadings" class="mc-env-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mc-card mc-camera-card">
|
|
<div class="mc-camera-head">
|
|
<h5 class="mb-0">Kamera (startvisning)</h5>
|
|
<span id="cameraStreamStatusSmall" class="mc-camera-status">Ikke aktiv</span>
|
|
</div>
|
|
<div id="cameraMotionBadgeSmall" class="mc-motion-pill hidden"></div>
|
|
<div id="cameraPreviewSmall" class="mc-camera-preview empty">Feed er ikke aktiveret endnu.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="view-important" class="mc-view">
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Vigtige sager</h4>
|
|
<div class="mc-row-head">
|
|
<div>Sag</div>
|
|
<div>Type</div>
|
|
<div>Status</div>
|
|
<div>Deadline</div>
|
|
</div>
|
|
<div id="importantCasesList" class="mc-table"></div>
|
|
|
|
<div class="mc-email-list">
|
|
<h5 class="mb-2">Seneste emails</h5>
|
|
<div id="recentEmailsList" class="mc-table"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="view-calls" class="mc-view">
|
|
<div class="mc-view-grid">
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Aktive opkald</h4>
|
|
<div id="activeCallsList" class="mc-feed"></div>
|
|
</div>
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Deadlines pr. medarbejder</h4>
|
|
<div class="mc-row-head">
|
|
<div>Medarbejder</div>
|
|
<div>I dag</div>
|
|
<div>Overskredet</div>
|
|
<div></div>
|
|
</div>
|
|
<div id="deadlineTable" class="mc-table"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="view-camera" class="mc-view">
|
|
<div class="mc-card mc-camera-card mc-camera-stage" id="cameraStage">
|
|
<div class="mc-camera-head">
|
|
<h4 class="mb-0">Kamera spotlight</h4>
|
|
<span id="cameraStreamStatusLarge" class="mc-camera-status">Ikke aktiv</span>
|
|
</div>
|
|
<div class="mc-duration-row" id="spotlightDurationRow">
|
|
<span class="mc-duration-label">Spotlight varighed</span>
|
|
<button type="button" class="mc-duration-btn" data-seconds="10">10s</button>
|
|
<button type="button" class="mc-duration-btn" data-seconds="20">20s</button>
|
|
<button type="button" class="mc-duration-btn" data-seconds="30">30s</button>
|
|
</div>
|
|
<div id="cameraMotionBadgeLarge" class="mc-motion-pill hidden"></div>
|
|
<div id="cameraPreviewLarge" class="mc-camera-preview large empty">Feed er ikke aktiveret endnu.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mc-card">
|
|
<h5 class="mb-2">Live aktivitetsfeed</h5>
|
|
<div id="liveFeed" class="mc-feed"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
const kpiLabels = {
|
|
open_cases: 'Aabne sager',
|
|
new_cases: 'Nye sager',
|
|
unassigned_cases: 'Uden ansvarlig',
|
|
deadlines_today: 'Deadline i dag',
|
|
overdue_deadlines: 'Overskredne'
|
|
};
|
|
|
|
const caseTypeMeta = [
|
|
{ key: 'all', label: 'Alle' },
|
|
{ key: 'ticket', label: 'Ticket' },
|
|
{ key: 'opgave', label: 'Opgave' },
|
|
{ key: 'ordre', label: 'Ordre' },
|
|
{ key: 'projekt', label: 'Projekt' },
|
|
{ key: 'service', label: 'Service' }
|
|
];
|
|
|
|
const state = {
|
|
ws: null,
|
|
reconnectAttempts: 0,
|
|
reconnectTimer: null,
|
|
pollTimer: null,
|
|
failures: 0,
|
|
idleTimer: null,
|
|
idleTimeoutMs: 10000,
|
|
currentView: 'overview',
|
|
caseFilter: 'all',
|
|
preSpotlightView: null,
|
|
cameraSpotlightTimer: null,
|
|
spotlightTargetId: null,
|
|
quickSpotlightSeconds: 20,
|
|
config: {
|
|
sound_enabled: true,
|
|
sound_volume: 70,
|
|
sound_events: ['incoming_call', 'uptime_down', 'critical_event'],
|
|
kpi_visible: Object.keys(kpiLabels),
|
|
display_queues: [],
|
|
camera_enabled: false,
|
|
camera_name: 'Mission Kamera',
|
|
camera_feed_url: '',
|
|
camera_spotlight_seconds: 20,
|
|
},
|
|
kpis: {},
|
|
activeCalls: [],
|
|
employeeDeadlines: [],
|
|
activeAlerts: [],
|
|
liveFeed: [],
|
|
importantCases: [],
|
|
recentEmails: [],
|
|
environmentReadings: [],
|
|
cameraMotion: null,
|
|
cameraPreviewToken: 0,
|
|
renderedCameraTarget: null,
|
|
renderedCameraUrl: null,
|
|
renderedCameraMode: null,
|
|
};
|
|
|
|
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 formatShortDate(value) {
|
|
if (!value) return '-';
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return '-';
|
|
return d.toLocaleDateString('da-DK');
|
|
}
|
|
|
|
function getCaseHref(caseId) {
|
|
const id = Number(caseId || 0);
|
|
if (!Number.isFinite(id) || id <= 0) return '/sag';
|
|
return `/sag/${id}`;
|
|
}
|
|
|
|
function getEmailHref(emailId) {
|
|
const id = Number(emailId || 0);
|
|
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
|
return `/emails?open=${id}`;
|
|
}
|
|
|
|
function updateConnectionLabel(text) {
|
|
const el = document.getElementById('connectionState');
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function resetIdleTimer() {
|
|
if (state.idleTimer) {
|
|
clearTimeout(state.idleTimer);
|
|
state.idleTimer = null;
|
|
}
|
|
state.idleTimer = setTimeout(() => {
|
|
activateView('overview');
|
|
state.caseFilter = 'all';
|
|
renderCaseFilterChips();
|
|
renderImportantCases();
|
|
}, state.idleTimeoutMs);
|
|
}
|
|
|
|
function setCameraStatus(text, variant = '') {
|
|
['cameraStreamStatusSmall', 'cameraStreamStatusLarge'].forEach((id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = `mc-camera-status ${variant}`.trim();
|
|
});
|
|
}
|
|
|
|
function setMotionBadge(text) {
|
|
['cameraMotionBadgeSmall', 'cameraMotionBadgeLarge'].forEach((id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
if (!text) {
|
|
el.textContent = '';
|
|
el.classList.add('hidden');
|
|
} else {
|
|
el.textContent = text;
|
|
el.classList.remove('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
function getCurrentCameraTargetId() {
|
|
return state.currentView === 'camera' ? 'cameraPreviewLarge' : 'cameraPreviewSmall';
|
|
}
|
|
|
|
function clearOtherCameraTarget(activeTargetId) {
|
|
const otherId = activeTargetId === 'cameraPreviewLarge' ? 'cameraPreviewSmall' : 'cameraPreviewLarge';
|
|
const other = document.getElementById(otherId);
|
|
if (!other) return;
|
|
other.classList.add('empty');
|
|
other.textContent = 'Kamera vises i den aktive visning.';
|
|
}
|
|
|
|
async function renderCameraPreview() {
|
|
const targetId = getCurrentCameraTargetId();
|
|
const target = document.getElementById(targetId);
|
|
if (!target) return;
|
|
|
|
clearOtherCameraTarget(targetId);
|
|
|
|
const enabled = !!state.config.camera_enabled;
|
|
const feedUrl = (state.config.camera_feed_url || '').trim();
|
|
if (!enabled || !feedUrl) {
|
|
state.renderedCameraTarget = null;
|
|
state.renderedCameraUrl = null;
|
|
state.renderedCameraMode = null;
|
|
target.classList.add('empty');
|
|
target.textContent = enabled ? 'Manglende feed URL.' : 'Feed er ikke aktiveret endnu.';
|
|
setCameraStatus(enabled ? 'Mangler URL' : 'Ikke aktiv', enabled ? 'error' : '');
|
|
return;
|
|
}
|
|
|
|
const isRtsp = feedUrl.toLowerCase().startsWith('rtsp://');
|
|
const mode = isRtsp ? 'rtsp' : 'iframe';
|
|
|
|
const shouldReuse = (
|
|
state.renderedCameraTarget === targetId
|
|
&& state.renderedCameraUrl === feedUrl
|
|
&& state.renderedCameraMode === mode
|
|
);
|
|
|
|
if (shouldReuse) {
|
|
return;
|
|
}
|
|
|
|
state.cameraPreviewToken += 1;
|
|
const token = state.cameraPreviewToken;
|
|
state.renderedCameraTarget = targetId;
|
|
state.renderedCameraUrl = feedUrl;
|
|
state.renderedCameraMode = mode;
|
|
|
|
target.classList.remove('empty');
|
|
|
|
if (isRtsp) {
|
|
setCameraStatus('Forbinder RTSP...', 'connecting');
|
|
target.innerHTML = `<img id="cameraMjpegStream" src="/api/v1/mission/camera/mjpeg?fps=5&t=${Date.now()}" alt="Mission kamera">`;
|
|
|
|
const img = target.querySelector('#cameraMjpegStream');
|
|
if (!img) return;
|
|
|
|
img.onload = () => {
|
|
if (token !== state.cameraPreviewToken) return;
|
|
setCameraStatus('Live', 'live');
|
|
};
|
|
|
|
img.onerror = async () => {
|
|
if (token !== state.cameraPreviewToken) return;
|
|
let detail = 'Kunne ikke hente RTSP stream via proxy.';
|
|
try {
|
|
const statusRes = await fetch('/api/v1/mission/camera/status', { credentials: 'include' });
|
|
const statusData = await statusRes.json().catch(() => ({}));
|
|
if (statusData?.detail) detail = statusData.detail;
|
|
} catch {
|
|
// Keep generic detail on network issues.
|
|
}
|
|
setCameraStatus('Stream fejl', 'error');
|
|
target.classList.add('empty');
|
|
target.innerHTML = `<div>${escapeHtml(detail)}</div>`;
|
|
};
|
|
return;
|
|
}
|
|
|
|
target.innerHTML = `<iframe src="${escapeHtml(feedUrl)}" title="Mission kamera feed" allowfullscreen></iframe>`;
|
|
setCameraStatus('Ekstern stream', 'live');
|
|
}
|
|
|
|
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 activateView(viewKey) {
|
|
state.currentView = viewKey;
|
|
document.querySelectorAll('.mc-view').forEach((el) => {
|
|
el.classList.toggle('active', el.id === `view-${viewKey}`);
|
|
});
|
|
document.querySelectorAll('.mc-nav-btn').forEach((btn) => {
|
|
btn.classList.toggle('active', btn.dataset.view === viewKey);
|
|
});
|
|
|
|
if (viewKey !== 'important') {
|
|
state.caseFilter = 'all';
|
|
}
|
|
renderCaseFilterChips();
|
|
renderCameraPreview();
|
|
}
|
|
|
|
function renderCaseFilterChips() {
|
|
const row = document.getElementById('caseFilterChips');
|
|
if (!row) return;
|
|
|
|
const counts = state.importantCases.reduce((acc, item) => {
|
|
const key = (item.case_type || 'opgave').toLowerCase();
|
|
acc[key] = (acc[key] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
row.innerHTML = caseTypeMeta.map((entry) => {
|
|
const count = entry.key === 'all'
|
|
? state.importantCases.length
|
|
: (counts[entry.key] || 0);
|
|
const active = state.caseFilter === entry.key;
|
|
return `
|
|
<button
|
|
type="button"
|
|
class="mc-chip ${active ? 'active' : ''}"
|
|
data-filter="${entry.key}"
|
|
>${escapeHtml(entry.label)} (${count})</button>
|
|
`;
|
|
}).join('');
|
|
|
|
row.querySelectorAll('.mc-chip').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
if (state.currentView !== 'important') {
|
|
activateView('important');
|
|
}
|
|
state.caseFilter = btn.dataset.filter || 'all';
|
|
renderCaseFilterChips();
|
|
renderImportantCases();
|
|
resetIdleTimer();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderKpis() {
|
|
const grid = document.getElementById('kpiGrid');
|
|
if (!grid) return;
|
|
|
|
const visible = Array.isArray(state.config.kpi_visible) && state.config.kpi_visible.length
|
|
? state.config.kpi_visible
|
|
: Object.keys(kpiLabels);
|
|
|
|
grid.innerHTML = visible.map((key) => {
|
|
const value = Number(state.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 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 = 'mc-alert-box';
|
|
container.innerHTML = state.activeAlerts.map((alert) => `
|
|
<div class="mc-alert">
|
|
<span>DRIFT NED</span>
|
|
<span>${escapeHtml(alert.service_name || 'Ukendt service')}</span>
|
|
${alert.customer_name ? `<span class="mc-badge">${escapeHtml(alert.customer_name)}</span>` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderCallHero() {
|
|
const title = document.getElementById('callHeroTitle');
|
|
const meta = document.getElementById('callHeroMeta');
|
|
if (!title || !meta) return;
|
|
|
|
if (!state.activeCalls.length) {
|
|
title.textContent = 'Ingen aktive opkald';
|
|
meta.textContent = 'Mission overvager opkald og opdaterer live.';
|
|
return;
|
|
}
|
|
|
|
const call = state.activeCalls[0];
|
|
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
|
|
const parts = [];
|
|
if (call.contact_name) parts.push(call.contact_name);
|
|
if (call.company_name) parts.push(call.company_name);
|
|
parts.push(`Start: ${formatDate(call.started_at)}`);
|
|
meta.textContent = parts.join(' • ');
|
|
}
|
|
|
|
function renderActiveCalls() {
|
|
const list = document.getElementById('activeCallsList');
|
|
if (!list) return;
|
|
|
|
if (!state.activeCalls.length) {
|
|
list.innerHTML = '<div class="mc-feed-meta">Ingen aktive opkald</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = state.activeCalls.map((call) => `
|
|
<div class="mc-feed-item">
|
|
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
|
|
<div class="mc-feed-meta">
|
|
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
|
|
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
|
|
${call.started_at ? ` • ${escapeHtml(formatDate(call.started_at))}` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderDeadlines() {
|
|
const table = document.getElementById('deadlineTable');
|
|
if (!table) return;
|
|
|
|
if (!state.employeeDeadlines.length) {
|
|
table.innerHTML = '<div class="mc-feed-meta">Ingen deadlines i dag eller overskredne</div>';
|
|
return;
|
|
}
|
|
|
|
table.innerHTML = state.employeeDeadlines.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 ? '#ffaaaa' : 'inherit'}">${Number(row.overdue_deadlines || 0)}</div>
|
|
<div></div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderImportantCases() {
|
|
const list = document.getElementById('importantCasesList');
|
|
if (!list) return;
|
|
|
|
let rows = state.importantCases;
|
|
if (state.caseFilter !== 'all') {
|
|
rows = rows.filter((item) => (item.case_type || 'opgave').toLowerCase() === state.caseFilter);
|
|
}
|
|
|
|
if (!rows.length) {
|
|
list.innerHTML = '<div class="mc-feed-meta">Ingen sager for det valgte filter</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = rows.slice(0, 80).map((item) => `
|
|
<div class="mc-row">
|
|
<div>
|
|
<a class="mc-case-link" href="${getCaseHref(item.id)}">
|
|
<div class="mc-case-title">#${Number(item.id || 0)} ${escapeHtml(item.titel || 'Uden titel')}</div>
|
|
</a>
|
|
<div class="mc-case-sub">${escapeHtml(item.customer_name || 'Ukendt kunde')}</div>
|
|
</div>
|
|
<div><span class="mc-badge">${escapeHtml(item.case_type || 'opgave')}</span></div>
|
|
<div>${escapeHtml(item.status || '-')}</div>
|
|
<div>${escapeHtml(formatShortDate(item.deadline))}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderRecentEmails() {
|
|
const list = document.getElementById('recentEmailsList');
|
|
if (!list) return;
|
|
|
|
if (!state.recentEmails.length) {
|
|
list.innerHTML = '<div class="mc-feed-meta">Ingen emails fundet</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = state.recentEmails.slice(0, 25).map((email) => `
|
|
<div class="mc-email-row">
|
|
<div>
|
|
<a class="mc-email-link" href="${getEmailHref(email.id)}">${escapeHtml(email.subject || '(Ingen emne)')}</a>
|
|
<div class="mc-case-sub">${escapeHtml(email.sender_name || email.sender_email || 'Ukendt afsender')}</div>
|
|
</div>
|
|
<div>
|
|
<span class="mc-badge">${escapeHtml(email.classification || 'general')}</span>
|
|
${email.linked_case_id ? `<a class="mc-email-link ms-2" href="${getCaseHref(email.linked_case_id)}">SAG #${Number(email.linked_case_id)}</a>` : ''}
|
|
</div>
|
|
<div class="mc-feed-meta">${escapeHtml(formatDate(email.received_date))}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderEnvironmentReadings() {
|
|
const container = document.getElementById('environmentReadings');
|
|
if (!container) return;
|
|
|
|
if (!state.environmentReadings.length) {
|
|
container.innerHTML = '<div class="mc-feed-meta">Ingen temperaturdata endnu</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = state.environmentReadings.slice(0, 12).map((reading) => {
|
|
const temp = Number(reading.temperature);
|
|
const unit = reading.unit || '°C';
|
|
const value = Number.isFinite(temp) ? `${temp.toFixed(1)}${unit}` : `-${unit}`;
|
|
return `
|
|
<div class="mc-env-row">
|
|
<div>
|
|
<div class="mc-env-name">${escapeHtml(reading.sensor_name || reading.sensor_id || 'Sensor')}</div>
|
|
<div class="mc-env-meta">${escapeHtml(formatDate(reading.timestamp))}</div>
|
|
</div>
|
|
<div class="mc-env-value">${escapeHtml(value)}</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 applyDurationButtons() {
|
|
const row = document.getElementById('spotlightDurationRow');
|
|
if (!row) return;
|
|
|
|
row.querySelectorAll('.mc-duration-btn').forEach((btn) => {
|
|
const seconds = Number(btn.dataset.seconds || 20);
|
|
btn.classList.toggle('active', seconds === state.quickSpotlightSeconds);
|
|
btn.addEventListener('click', () => {
|
|
state.quickSpotlightSeconds = seconds;
|
|
applyDurationButtons();
|
|
resetIdleTimer();
|
|
});
|
|
});
|
|
}
|
|
|
|
function triggerCameraSpotlight() {
|
|
const activeTargetId = getCurrentCameraTargetId();
|
|
const activePreview = document.getElementById(activeTargetId);
|
|
if (!activePreview || activePreview.classList.contains('empty')) return;
|
|
|
|
if (state.cameraSpotlightTimer) {
|
|
clearTimeout(state.cameraSpotlightTimer);
|
|
state.cameraSpotlightTimer = null;
|
|
}
|
|
|
|
if (state.spotlightTargetId && state.spotlightTargetId !== activeTargetId) {
|
|
const prevTarget = document.getElementById(state.spotlightTargetId);
|
|
if (prevTarget) prevTarget.classList.remove('mc-spotlight-active');
|
|
}
|
|
|
|
activePreview.classList.add('mc-spotlight-active');
|
|
state.spotlightTargetId = activeTargetId;
|
|
|
|
const durationMs = Math.max(5000, Math.min(state.quickSpotlightSeconds * 1000, 30000));
|
|
state.cameraSpotlightTimer = setTimeout(() => {
|
|
if (state.spotlightTargetId) {
|
|
const target = document.getElementById(state.spotlightTargetId);
|
|
if (target) target.classList.remove('mc-spotlight-active');
|
|
state.spotlightTargetId = null;
|
|
}
|
|
state.cameraSpotlightTimer = null;
|
|
}, durationMs);
|
|
}
|
|
|
|
function renderMotionBadge() {
|
|
if (state.cameraMotion && state.cameraMotion.motion) {
|
|
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
|
|
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
|
return;
|
|
}
|
|
setMotionBadge('');
|
|
}
|
|
|
|
function renderAll() {
|
|
renderCaseFilterChips();
|
|
renderKpis();
|
|
renderAlerts();
|
|
renderCallHero();
|
|
renderActiveCalls();
|
|
renderDeadlines();
|
|
renderImportantCases();
|
|
renderRecentEmails();
|
|
renderEnvironmentReadings();
|
|
renderFeed();
|
|
renderMotionBadge();
|
|
applyDurationButtons();
|
|
renderCameraPreview();
|
|
}
|
|
|
|
function applyMissionState(payload) {
|
|
if (!payload) return;
|
|
|
|
state.config = { ...state.config, ...(payload.config || {}) };
|
|
state.kpis = payload.kpis || state.kpis;
|
|
state.activeCalls = Array.isArray(payload.active_calls) ? payload.active_calls : state.activeCalls;
|
|
state.employeeDeadlines = Array.isArray(payload.employee_deadlines) ? payload.employee_deadlines : state.employeeDeadlines;
|
|
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
|
|
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
|
|
state.importantCases = Array.isArray(payload.important_cases) ? payload.important_cases : state.importantCases;
|
|
state.recentEmails = Array.isArray(payload.recent_emails) ? payload.recent_emails : state.recentEmails;
|
|
state.environmentReadings = Array.isArray(payload.environment_readings) ? payload.environment_readings : state.environmentReadings;
|
|
|
|
const settingSeconds = Number(state.config.camera_spotlight_seconds || 20);
|
|
if (![10, 20, 30].includes(state.quickSpotlightSeconds) && [10, 20, 30].includes(settingSeconds)) {
|
|
state.quickSpotlightSeconds = settingSeconds;
|
|
}
|
|
|
|
const soundToggle = document.getElementById('soundEnabledToggle');
|
|
const soundVolume = document.getElementById('soundVolume');
|
|
if (soundToggle) soundToggle.checked = !!state.config.sound_enabled;
|
|
if (soundVolume) soundVolume.value = String(state.config.sound_volume || 70);
|
|
|
|
renderAll();
|
|
}
|
|
|
|
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();
|
|
applyMissionState(payload);
|
|
}
|
|
|
|
function startPollingFallback(intervalMs = 3000) {
|
|
if (state.pollTimer) return;
|
|
state.pollTimer = setInterval(async () => {
|
|
try {
|
|
await loadInitialState();
|
|
} catch {
|
|
// Poll fallback stays silent by design.
|
|
}
|
|
}, Math.max(1000, Number(intervalMs) || 3000));
|
|
}
|
|
|
|
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 (WS)');
|
|
};
|
|
|
|
state.ws.onclose = () => {
|
|
state.failures += 1;
|
|
updateConnectionLabel('WS afbrudt - fallback polling aktiv');
|
|
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') {
|
|
applyMissionState(data);
|
|
return;
|
|
}
|
|
|
|
if (event === 'kpi_update') {
|
|
state.kpis = data || state.kpis;
|
|
renderKpis();
|
|
return;
|
|
}
|
|
|
|
if (event === 'call_ringing') {
|
|
state.activeCalls = [data, ...state.activeCalls.filter((c) => c.call_id !== data.call_id)];
|
|
renderCallHero();
|
|
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);
|
|
renderCallHero();
|
|
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 === 'camera_motion') {
|
|
state.cameraMotion = data;
|
|
renderMotionBadge();
|
|
if (data.motion) {
|
|
playTone('critical_event');
|
|
triggerCameraSpotlight();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event === 'mission_environment_temperature') {
|
|
if (Array.isArray(data.environment_readings)) {
|
|
state.environmentReadings = data.environment_readings;
|
|
renderEnvironmentReadings();
|
|
}
|
|
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);
|
|
}
|
|
};
|
|
}
|
|
|
|
function bindUiEvents() {
|
|
document.getElementById('missionNav')?.querySelectorAll('.mc-nav-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
activateView(btn.dataset.view || 'overview');
|
|
resetIdleTimer();
|
|
});
|
|
});
|
|
|
|
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
|
document.addEventListener(name, resetIdleTimer, { passive: true });
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
bindUiEvents();
|
|
resetIdleTimer();
|
|
|
|
try {
|
|
await loadInitialState();
|
|
updateConnectionLabel('Mission loaded');
|
|
} catch (error) {
|
|
updateConnectionLabel('Fejl ved initial load');
|
|
console.error(error);
|
|
}
|
|
|
|
startPollingFallback(3000);
|
|
connectWs();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|