1418 lines
72 KiB
HTML
1418 lines
72 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{.Title}}</title>
|
||
<script src="/static/tailwind.min.js"></script>
|
||
<script src="/static/chart.umd.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg1: #050816;
|
||
--bg2: #0b1224;
|
||
--panel: rgba(255,255,255,0.055);
|
||
--body-text: #f4f4f5;
|
||
--button-bg: rgba(255,255,255,0.05);
|
||
--button-border: rgba(255,255,255,0.10);
|
||
--button-text: #e4e4e7;
|
||
--text: var(--body-text);
|
||
--border: var(--button-border);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { min-height: 100%; }
|
||
|
||
body {
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
background:
|
||
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
||
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||
color: var(--body-text);
|
||
transition: background 180ms ease, color 180ms ease;
|
||
}
|
||
|
||
body[data-theme="light"] {
|
||
--bg1: #eef4ff;
|
||
--bg2: #f8fafc;
|
||
--panel: rgba(255,255,255,0.80);
|
||
--body-text: #0f172a;
|
||
--button-bg: rgba(255,255,255,0.88);
|
||
--button-border: rgba(15,23,42,0.10);
|
||
--button-text: #0f172a;
|
||
--text: var(--body-text);
|
||
--border: var(--button-border);
|
||
background:
|
||
radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
|
||
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
|
||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||
}
|
||
|
||
.glass {
|
||
background: var(--panel);
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
}
|
||
|
||
body[data-theme="light"] .glass,
|
||
body[data-theme="light"] .summary-card,
|
||
body[data-theme="light"] .intel-card,
|
||
body[data-theme="light"] .verdict-card {
|
||
border-color: rgba(15,23,42,0.10) !important;
|
||
box-shadow: 0 10px 28px rgba(15,23,42,0.06);
|
||
}
|
||
|
||
body[data-theme="light"] .bg-zinc-900\/60,
|
||
body[data-theme="light"] .bg-white\/5 {
|
||
background: rgba(255,255,255,0.86) !important;
|
||
}
|
||
|
||
body[data-theme="light"] .border-zinc-800,
|
||
body[data-theme="light"] .border-white\/10 {
|
||
border-color: rgba(15,23,42,0.10) !important;
|
||
}
|
||
|
||
body[data-theme="light"] .text-zinc-100,
|
||
body[data-theme="light"] .text-zinc-200 { color: #0f172a !important; }
|
||
body[data-theme="light"] .text-zinc-300 { color: #1e293b !important; }
|
||
body[data-theme="light"] .text-zinc-400,
|
||
body[data-theme="light"] .text-zinc-500 { color: #475569 !important; }
|
||
body[data-theme="light"] .text-emerald-300 { color: #059669 !important; }
|
||
body[data-theme="light"] .text-emerald-400 { color: #10b981 !important; }
|
||
body[data-theme="light"] .text-sky-100,
|
||
body[data-theme="light"] .text-sky-200 { color: #0369a1 !important; }
|
||
body[data-theme="light"] .text-violet-100,
|
||
body[data-theme="light"] .text-violet-200 { color: #7c3aed !important; }
|
||
body[data-theme="light"] .text-amber-200 { color: #b45309 !important; }
|
||
body[data-theme="light"] .text-sky-400 { color: #0284c7 !important; }
|
||
body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
|
||
body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
|
||
body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }
|
||
.control-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 42px;
|
||
padding: 10px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--border);
|
||
background: rgba(255,255,255,.05);
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
cursor: pointer;
|
||
transition: 160ms ease;
|
||
}
|
||
|
||
body[data-theme="light"] .control-btn {
|
||
background: rgba(255,255,255,.88);
|
||
}
|
||
|
||
.control-btn.primary {
|
||
background: rgba(14,165,233,0.14);
|
||
border-color: rgba(14,165,233,0.35);
|
||
}
|
||
|
||
.control-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 8px 20px rgba(0,0,0,0.10);
|
||
}
|
||
|
||
.soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); }
|
||
.soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); }
|
||
.soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); }
|
||
|
||
.gauge-header-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.gauge-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
text-align: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.gauge-head.with-digital {
|
||
justify-content: flex-start;
|
||
text-align: left;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.gauge-head-copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.gauge-head-copy.with-digital { align-items: flex-start; }
|
||
|
||
.gauge-digital { text-align: right; flex-shrink: 0; }
|
||
|
||
.gauge-container {
|
||
position: relative;
|
||
width: 100%;
|
||
max-width: 720px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.gauge-container.no-digital { height: clamp(430px, 48vw, 560px); }
|
||
.gauge-container.with-digital { height: clamp(360px, 42vw, 500px); }
|
||
|
||
.gauge-canvas { width: 100%; height: 100%; display: block; }
|
||
|
||
.window-btn.active, .trend-window-btn.active {
|
||
border-color: rgba(34,211,238,0.9);
|
||
color: white;
|
||
background: rgba(34,211,238,0.14);
|
||
box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset;
|
||
}
|
||
|
||
body[data-theme="light"] .window-btn.active,
|
||
body[data-theme="light"] .trend-window-btn.active {
|
||
color: #0f172a;
|
||
background: rgba(14,165,233,0.12);
|
||
}
|
||
|
||
.chart-wrap { width: min(92vw, 1800px); margin: 0 auto; }
|
||
|
||
.summary-card, .intel-card, .verdict-card {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
border-radius: 20px;
|
||
padding: 18px 20px;
|
||
border: 1px solid rgba(255,255,255,0.09);
|
||
background: rgba(255,255,255,0.04);
|
||
transition: 180ms ease;
|
||
}
|
||
|
||
.intel-card { min-height: 126px; align-items: flex-start; }
|
||
|
||
.summary-card.ok, .intel-card.ok, .verdict-card.ok {
|
||
border-color: rgba(34,197,94,0.35);
|
||
box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset, 0 0 26px rgba(34,197,94,0.06);
|
||
}
|
||
.summary-card.warning, .intel-card.warning, .verdict-card.warning {
|
||
border-color: rgba(234,179,8,0.35);
|
||
box-shadow: 0 0 0 1px rgba(234,179,8,0.08) inset, 0 0 26px rgba(234,179,8,0.06);
|
||
}
|
||
.summary-card.critical, .intel-card.critical, .verdict-card.critical {
|
||
border-color: rgba(239,68,68,0.35);
|
||
box-shadow: 0 0 0 1px rgba(239,68,68,0.08) inset, 0 0 26px rgba(239,68,68,0.06);
|
||
}
|
||
.summary-card.neutral, .intel-card.neutral, .verdict-card.neutral {
|
||
border-color: rgba(113,113,122,0.35);
|
||
box-shadow: 0 0 0 1px rgba(113,113,122,0.08) inset;
|
||
}
|
||
|
||
.summary-dot { width: 14px; height: 14px; border-radius: 999px; }
|
||
.summary-dot.ok { background: #10b981; box-shadow: 0 0 14px rgba(16,185,129,0.55); }
|
||
.summary-dot.warning { background: #f59e0b; box-shadow: 0 0 14px rgba(245,158,11,0.55); }
|
||
.summary-dot.critical { background: #ef4444; box-shadow: 0 0 14px rgba(239,68,68,0.55); }
|
||
.summary-dot.neutral { background: #71717a; box-shadow: 0 0 12px rgba(113,113,122,0.35); }
|
||
|
||
.summary-status.ok { color: #34d399; }
|
||
.summary-status.warning { color: #facc15; }
|
||
.summary-status.critical { color: #f87171; }
|
||
.summary-status.neutral { color: #a1a1aa; }
|
||
|
||
.intel-value {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: #f4f4f5;
|
||
}
|
||
|
||
body[data-theme="light"] .intel-value { color: #0f172a; }
|
||
|
||
.intel-sub { font-size: 0.83rem; color: #a1a1aa; margin-top: 10px; line-height: 1.35; }
|
||
|
||
.intel-kpi {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.dir-up { color: #facc15; }
|
||
.dir-down { color: #34d399; }
|
||
.dir-flat { color: #a1a1aa; }
|
||
.dir-bad { color: #f87171; }
|
||
.mini-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||
|
||
.severity-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 86px;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.severity-pill.info { background: rgba(59,130,246,0.12); color: #93c5fd; border: 1px solid rgba(59,130,246,0.22); }
|
||
.severity-pill.warning { background: rgba(245,158,11,0.12); color: #fde68a; border: 1px solid rgba(245,158,11,0.22); }
|
||
.severity-pill.critical { background: rgba(239,68,68,0.12); color: #fca5a5; border: 1px solid rgba(239,68,68,0.22); }
|
||
body[data-theme="light"] .severity-pill.info { color: #1d4ed8; }
|
||
body[data-theme="light"] .severity-pill.warning { color: #b45309; }
|
||
body[data-theme="light"] .severity-pill.critical { color: #dc2626; }
|
||
|
||
.alarm-table tbody tr:hover { background: rgba(255,255,255,0.03); }
|
||
body[data-theme="light"] .alarm-table tbody tr:hover { background: rgba(15,23,42,0.03); }
|
||
|
||
.limit-line-note { font-size: 0.8rem; color: #a1a1aa; }
|
||
|
||
.process-offline {
|
||
opacity: 0.35;
|
||
filter: grayscale(1) blur(1.5px);
|
||
pointer-events: none;
|
||
user-select: none;
|
||
transition: opacity 180ms ease, filter 180ms ease;
|
||
}
|
||
|
||
.process-online {
|
||
opacity: 1;
|
||
filter: none;
|
||
pointer-events: auto;
|
||
user-select: auto;
|
||
transition: opacity 180ms ease, filter 180ms ease;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body data-theme="dark">
|
||
<div class="w-[95vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
|
||
<div class="glass border border-white/10 rounded-3xl p-4 mb-8">
|
||
<div class="flex flex-wrap gap-3 items-center justify-between">
|
||
<div class="flex flex-wrap gap-3">
|
||
<a href="/" class="control-btn primary" target="_self">Dashboard</a>
|
||
<a href="/history" class="control-btn" target="_self">History</a>
|
||
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
|
||
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
|
||
<a href="/process-capability" class="control-btn" target="_self">Process Capability</a>
|
||
<a href="/reports" class="control-btn" target="_self">Reports</a>
|
||
<a href="/license" class="control-btn" target="_self">License</a>
|
||
</div>
|
||
<div class="text-zinc-500 text-sm font-mono">Embedded UI navigation</div>
|
||
</div>
|
||
</div>
|
||
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">⚠️</span>
|
||
<span id="alarm-text">CRITICAL ALARM ACTIVE</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between mb-8">
|
||
<div>
|
||
<h1 class="text-4xl md:text-5xl xl:text-6xl font-semibold tracking-tighter bg-gradient-to-r from-sky-300 to-violet-300 bg-clip-text text-transparent">{{.Title}}</h1>
|
||
<p class="text-zinc-400 mt-2 text-base md:text-lg">{{.Subtitle}}</p>
|
||
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-4 xl:items-end">
|
||
{{if .ShowHeaderControls}}
|
||
<div class="flex flex-wrap gap-3 justify-end">
|
||
<button id="theme-toggle" class="control-btn" type="button">Light theme</button>
|
||
<button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
|
||
<div class="flex items-center gap-3">
|
||
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
|
||
<span id="status-text" class="font-semibold text-lg text-red-400">Disconnected</span>
|
||
</div>
|
||
<div class="hidden md:block h-8 w-px bg-zinc-700"></div>
|
||
<div id="last-update" class="font-mono text-zinc-400 text-sm">Last update: --:--:--.---</div>
|
||
<div class="hidden md:block h-8 w-px bg-zinc-700"></div>
|
||
<div class="font-mono text-zinc-500 text-sm">Dropped S: <span id="dropped-samples">0</span> | E: <span id="dropped-events">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<div id="process-content">
|
||
{{if .ShowVerdict}}
|
||
<div id="verdict-card" class="verdict-card neutral mb-8">
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
|
||
<div id="verdict-status" class="text-3xl md:text-4xl font-bold mt-2 text-zinc-200">NO DATA</div>
|
||
</div>
|
||
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowSummaryBar}}
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
|
||
<div id="summary-force-card" class="summary-card neutral">
|
||
<div class="flex items-center gap-3">
|
||
<div id="summary-force-dot" class="summary-dot neutral"></div>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE</div>
|
||
<div id="summary-force-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
|
||
</div>
|
||
</div>
|
||
<div id="summary-force-value" class="font-mono text-zinc-200 text-lg">--</div>
|
||
</div>
|
||
|
||
<div id="summary-imbalance-card" class="summary-card neutral">
|
||
<div class="flex items-center gap-3">
|
||
<div id="summary-imbalance-dot" class="summary-dot neutral"></div>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE</div>
|
||
<div id="summary-imbalance-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
|
||
</div>
|
||
</div>
|
||
<div id="summary-imbalance-value" class="font-mono text-zinc-200 text-lg">--</div>
|
||
</div>
|
||
|
||
<div id="summary-plc-card" class="summary-card neutral">
|
||
<div class="flex items-center gap-3">
|
||
<div id="summary-plc-dot" class="summary-dot neutral"></div>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PLC</div>
|
||
<div id="summary-plc-text" class="summary-status neutral font-semibold mt-1">OFFLINE</div>
|
||
</div>
|
||
</div>
|
||
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowOverview}}
|
||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
||
<div>
|
||
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
|
||
<div class="mt-2 flex items-end gap-4">
|
||
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
|
||
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
|
||
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
||
<div class="text-zinc-500 text-xs uppercase tracking-widest">TOTAL %</div>
|
||
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
|
||
</div>
|
||
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
||
<div class="text-zinc-500 text-xs uppercase tracking-widest">IMBALANCE</div>
|
||
<div class="text-3xl font-mono font-bold text-amber-200 mt-1"><span id="imbalance-pct">0.0</span> {{.UnitPct}}</div>
|
||
<div class="text-xs text-zinc-500 mt-2 font-mono">abs(L - R)</div>
|
||
</div>
|
||
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
||
<div class="text-zinc-500 text-xs uppercase tracking-widest">BIAS</div>
|
||
<div class="text-3xl font-mono font-bold text-violet-200 mt-1"><span id="bias-pct">0.0</span> {{.UnitPct}}</div>
|
||
<div class="text-xs text-zinc-500 mt-2 font-mono">L - R</div>
|
||
</div>
|
||
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
||
<div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
|
||
<div class="text-sm font-mono text-zinc-300 mt-2">Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}</div>
|
||
<div class="text-xs text-zinc-500 mt-2 font-mono">Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowIntelligence}}
|
||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
||
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
||
<div>
|
||
<h2 class="text-2xl md:text-3xl font-semibold">Drift / Deterioration Intelligence</h2>
|
||
<div class="text-zinc-400 mt-1 text-sm md:text-base">Averages, drift direction, imbalance deterioration and process stability</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<button class="trend-window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
|
||
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
|
||
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30m">30m</button>
|
||
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
|
||
<div class="flex items-center gap-2 ml-1">
|
||
<input id="custom-trend-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
|
||
<button id="apply-trend-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-5 gap-4">
|
||
<div id="intel-avg5-card" class="intel-card neutral"><div class="w-full">
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 5 MIN</div>
|
||
<div id="intel-avg5-value" class="intel-value mt-3">--</div>
|
||
<div id="intel-avg5-sub" class="intel-sub">No data</div>
|
||
</div></div>
|
||
<div id="intel-avg1h-card" class="intel-card neutral"><div class="w-full">
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 1 HOUR</div>
|
||
<div id="intel-avg1h-value" class="intel-value mt-3">--</div>
|
||
<div id="intel-avg1h-sub" class="intel-sub">No data</div>
|
||
</div></div>
|
||
<div id="intel-force-card" class="intel-card neutral"><div class="w-full">
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE TREND</div>
|
||
<div id="intel-force-value" class="intel-value mt-3">--</div>
|
||
<div id="intel-force-sub" class="intel-sub">No data</div>
|
||
</div></div>
|
||
<div id="intel-imb-card" class="intel-card neutral"><div class="w-full">
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE TREND</div>
|
||
<div id="intel-imb-value" class="intel-value mt-3">--</div>
|
||
<div id="intel-imb-sub" class="intel-sub">No data</div>
|
||
</div></div>
|
||
<div id="intel-stability-card" class="intel-card neutral"><div class="w-full">
|
||
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PROCESS STABILITY</div>
|
||
<div id="intel-stability-value" class="intel-value mt-3">--</div>
|
||
<div id="intel-stability-sub" class="intel-sub">No data</div>
|
||
</div></div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowAlarmTimeline}}
|
||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
|
||
<div>
|
||
<h2 class="text-2xl md:text-3xl font-semibold">Event / Alarm Timeline</h2>
|
||
<div class="text-zinc-400 mt-1 text-sm md:text-base">Recent transitions show exactly when the process began drifting, overloading, losing balance, or losing PLC communication</div>
|
||
</div>
|
||
<div class="limit-line-note">Newest events first • clear events included</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="alarm-table w-full text-sm">
|
||
<thead>
|
||
<tr class="text-left text-zinc-500 border-b border-white/10">
|
||
<th class="py-3 pr-4">Time</th>
|
||
<th class="py-3 pr-4">Severity</th>
|
||
<th class="py-3 pr-4">Source</th>
|
||
<th class="py-3 pr-4">Event</th>
|
||
<th class="py-3 pr-4 text-right">Value</th>
|
||
<th class="py-3 text-right">Limit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="alarm-table-body" class="text-zinc-200">
|
||
<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowGauges}}
|
||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
|
||
<div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
|
||
{{if .ShowGaugeDigital}}
|
||
<div class="gauge-header-row">
|
||
<div class="gauge-head with-digital">
|
||
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||
<div class="gauge-head-copy with-digital">
|
||
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
|
||
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||
</div>
|
||
</div>
|
||
<div id="digital-l" class="gauge-digital">
|
||
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
|
||
<div class="text-xl text-sky-400 mt-1">{{.UnitPct}}</div>
|
||
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
||
</div>
|
||
</div>
|
||
{{else}}
|
||
<div class="gauge-head">
|
||
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||
<div class="gauge-head-copy">
|
||
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
|
||
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
<div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
|
||
<canvas id="gaugeL" class="gauge-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="card-r" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
|
||
{{if .ShowGaugeDigital}}
|
||
<div class="gauge-header-row">
|
||
<div class="gauge-head with-digital">
|
||
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||
<div class="gauge-head-copy with-digital">
|
||
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
|
||
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||
</div>
|
||
</div>
|
||
<div id="digital-r" class="gauge-digital">
|
||
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
|
||
<div class="text-xl text-violet-400 mt-1">{{.UnitPct}}</div>
|
||
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
||
</div>
|
||
</div>
|
||
{{else}}
|
||
<div class="gauge-head">
|
||
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||
<div class="gauge-head-copy">
|
||
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
|
||
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
<div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
|
||
<canvas id="gaugeR" class="gauge-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .ShowTrendChart}}
|
||
<div class="chart-wrap">
|
||
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
|
||
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
||
<div>
|
||
<h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
|
||
<div class="text-zinc-400 mt-1 text-sm md:text-base">Piezo peak/stroke history from SQLite with visible warning and critical limits</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<button class="window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30s">30s</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1m">1m</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="8h">8h</button>
|
||
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="24h">24h</button>
|
||
<div class="flex items-center gap-2 ml-1">
|
||
<input id="custom-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
|
||
<button id="apply-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="h-[52vh] min-h-[420px] max-h-[760px]">
|
||
<canvas id="lineChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div><!-- #process-content -->
|
||
</div>
|
||
|
||
<script src="/static/app-common.js"></script>
|
||
<script>
|
||
const WARNING_PERCENT = {{.WarningPercent}};
|
||
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
||
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
||
const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}};
|
||
const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}};
|
||
const UNIT_FORCE = '{{.UnitForce}}';
|
||
const UNIT_PCT = '{{.UnitPct}}';
|
||
const POLL_MS = {{.PollMs}};
|
||
const DEFAULT_WINDOW = '{{.DefaultWindow}}';
|
||
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
|
||
const STALE_MS = Math.max(POLL_MS * 4, 2500);
|
||
|
||
const SHOW_HEADER_CONTROLS = {{if .ShowHeaderControls}}true{{else}}false{{end}};
|
||
const SHOW_VERDICT = {{if .ShowVerdict}}true{{else}}false{{end}};
|
||
const SHOW_SUMMARY_BAR = {{if .ShowSummaryBar}}true{{else}}false{{end}};
|
||
const SHOW_OVERVIEW = {{if .ShowOverview}}true{{else}}false{{end}};
|
||
const SHOW_INTELLIGENCE = {{if .ShowIntelligence}}true{{else}}false{{end}};
|
||
const SHOW_ALARM_TIMELINE = {{if .ShowAlarmTimeline}}true{{else}}false{{end}};
|
||
const SHOW_GAUGES = {{if .ShowGauges}}true{{else}}false{{end}};
|
||
const SHOW_GAUGE_DIGITAL = {{if .ShowGaugeDigital}}true{{else}}false{{end}};
|
||
const SHOW_TREND_CHART = {{if .ShowTrendChart}}true{{else}}false{{end}};
|
||
const CURRENT_UI_REVISION = {{.UIRevision}};
|
||
|
||
const START_ANGLE = Math.PI * 0.75;
|
||
const END_ANGLE = Math.PI * 2.25;
|
||
|
||
let lineChart = null;
|
||
let latestData = null;
|
||
let currentWindow = DEFAULT_WINDOW;
|
||
let currentTrendWindow = DEFAULT_TREND_WINDOW;
|
||
let currentTheme = 'dark';
|
||
let activeUIRevision = CURRENT_UI_REVISION;
|
||
let historyBusy = false;
|
||
let trendBusy = false;
|
||
let alarmsBusy = false;
|
||
|
||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||
function isLightTheme() { return currentTheme === 'light'; }
|
||
|
||
function escapeHtml(value) {
|
||
return String(value === undefined || value === null ? '' : value)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function setTextById(id, text) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
}
|
||
|
||
function colorMix(c1, c2, t) {
|
||
return { r: Math.round(lerp(c1.r, c2.r, t)), g: Math.round(lerp(c1.g, c2.g, t)), b: Math.round(lerp(c1.b, c2.b, t)) };
|
||
}
|
||
|
||
function colorToCss(c, a) {
|
||
a = a === undefined ? 1 : a;
|
||
return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + a + ')';
|
||
}
|
||
|
||
function polar(cx, cy, r, a) { return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }; }
|
||
|
||
function valueToAngle(value) {
|
||
const ratio = clamp((Number(value) || 0) / GAUGE_MAX_PERCENT, 0, 1);
|
||
return START_ANGLE + ratio * (END_ANGLE - START_ANGLE);
|
||
}
|
||
|
||
function prepCanvas(canvas) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const w = Math.max(1, Math.floor(rect.width));
|
||
const h = Math.max(1, Math.floor(rect.height));
|
||
canvas.width = Math.max(1, Math.floor(w * dpr));
|
||
canvas.height = Math.max(1, Math.floor(h * dpr));
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
return { ctx, w, h };
|
||
}
|
||
|
||
function gaugeBandColor(v) {
|
||
const green = { r: 34, g: 197, b: 94 };
|
||
const yellow = { r: 234, g: 179, b: 8 };
|
||
const red = { r: 239, g: 68, b: 68 };
|
||
if (v <= WARNING_PERCENT) {
|
||
const t = WARNING_PERCENT <= 0 ? 0 : v / WARNING_PERCENT;
|
||
return colorMix(green, yellow, t * 0.15);
|
||
}
|
||
if (v <= CRITICAL_PERCENT) {
|
||
const span = Math.max(1, CRITICAL_PERCENT - WARNING_PERCENT);
|
||
return colorMix(green, yellow, (v - WARNING_PERCENT) / span);
|
||
}
|
||
const span = Math.max(1, GAUGE_MAX_PERCENT - CRITICAL_PERCENT);
|
||
return colorMix(yellow, red, (v - CRITICAL_PERCENT) / span);
|
||
}
|
||
|
||
function drawArc(ctx, cx, cy, r, a1, a2, stroke, width, shadowBlur) {
|
||
shadowBlur = shadowBlur || 0;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, a1, a2, false);
|
||
ctx.strokeStyle = stroke;
|
||
ctx.lineWidth = width;
|
||
ctx.lineCap = 'butt';
|
||
if (shadowBlur > 0) { ctx.shadowColor = stroke; ctx.shadowBlur = shadowBlur; }
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawColoredBand(ctx, cx, cy, r, width) {
|
||
const segments = 180;
|
||
for (let i = 0; i < segments; i++) {
|
||
const v1 = (i / segments) * GAUGE_MAX_PERCENT;
|
||
const v2 = ((i + 1) / segments) * GAUGE_MAX_PERCENT;
|
||
const c = gaugeBandColor((v1 + v2) / 2);
|
||
drawArc(ctx, cx, cy, r, valueToAngle(v1), valueToAngle(v2), colorToCss(c, 0.95), width, 0);
|
||
}
|
||
}
|
||
|
||
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
|
||
if (!SHOW_GAUGES) return;
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const prep = prepCanvas(canvas);
|
||
const ctx = prep.ctx, w = prep.w, h = prep.h;
|
||
const light = isLightTheme();
|
||
const cx = w / 2;
|
||
const radius = Math.min(w * 0.35, h * 0.42);
|
||
const cy = h * 0.58;
|
||
const trackWidth = Math.max(20, radius * 0.17);
|
||
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
|
||
const valueAngle = valueToAngle(value);
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2);
|
||
ctx.fillStyle = light ? 'rgba(15,23,42,0.04)' : 'rgba(255,255,255,0.015)';
|
||
ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(0,0,0,0.45)';
|
||
ctx.shadowBlur = 32;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, light ? 'rgba(15,23,42,0.08)' : 'rgba(255,255,255,0.06)', trackWidth + 10, 0);
|
||
drawColoredBand(ctx, cx, cy, radius, trackWidth);
|
||
drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, light ? 'rgba(255,255,255,0.72)' : 'rgba(9,9,11,0.60)', trackWidth - 1, 0);
|
||
drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, light ? 'rgba(15,23,42,0.05)' : 'rgba(255,255,255,0.04)', trackWidth - 1, 10);
|
||
|
||
for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) {
|
||
const a = valueToAngle(v);
|
||
const isMajor = Math.abs(v % 10) < 0.0001;
|
||
const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001;
|
||
const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80;
|
||
const p1 = polar(cx, cy, r1, a);
|
||
const p2 = polar(cx, cy, radius * 0.97, a);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p1.x, p1.y);
|
||
ctx.lineTo(p2.x, p2.y);
|
||
if (isThreshold) {
|
||
ctx.strokeStyle = light ? '#0f172a' : '#ffffff';
|
||
ctx.lineWidth = 3.2;
|
||
} else if (isMajor) {
|
||
ctx.strokeStyle = light ? 'rgba(15,23,42,0.80)' : 'rgba(255,255,255,0.86)';
|
||
ctx.lineWidth = 2.2;
|
||
} else {
|
||
ctx.strokeStyle = light ? 'rgba(71,85,105,0.65)' : 'rgba(161,161,170,0.74)';
|
||
ctx.lineWidth = 1.1;
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = light ? 'rgba(15,23,42,0.88)' : 'rgba(244,244,245,0.96)';
|
||
ctx.font = '700 18px system-ui, sans-serif';
|
||
for (const v of [0, 20, 40, 60, 80, 100, 120, 130]) {
|
||
const p = polar(cx, cy, radius * 1.13, valueToAngle(v));
|
||
ctx.fillText(String(v), p.x, p.y);
|
||
}
|
||
|
||
const tip = polar(cx, cy, radius * 0.86, valueAngle);
|
||
const left = polar(cx, cy, 8, valueAngle + Math.PI / 2);
|
||
const right = polar(cx, cy, 8, valueAngle - Math.PI / 2);
|
||
const tail = polar(cx, cy, radius * 0.20, valueAngle + Math.PI);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(left.x, left.y);
|
||
ctx.lineTo(tip.x, tip.y);
|
||
ctx.lineTo(right.x, right.y);
|
||
ctx.lineTo(tail.x, tail.y);
|
||
ctx.closePath();
|
||
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
||
ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.18)';
|
||
ctx.shadowBlur = 10;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
|
||
ctx.fillStyle = light ? '#ffffff' : '#101114';
|
||
ctx.fill();
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = sideAccent;
|
||
ctx.stroke();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
||
ctx.fill();
|
||
|
||
const centerPlateRadius = (radius * 0.72) - 18;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.90)';
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.2;
|
||
ctx.strokeStyle = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.10)';
|
||
ctx.stroke();
|
||
|
||
let valueFontPx = 58;
|
||
if (value >= 100) valueFontPx = 50;
|
||
if (w < 420) valueFontPx -= 6;
|
||
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
||
ctx.font = '700 ' + valueFontPx + 'px system-ui, sans-serif';
|
||
ctx.fillText(value.toFixed(1), cx, cy - 6);
|
||
ctx.fillStyle = sideAccent;
|
||
ctx.font = '700 19px system-ui, sans-serif';
|
||
ctx.fillText(UNIT_PCT, cx, cy + 30);
|
||
ctx.fillStyle = light ? '#334155' : '#a1a1aa';
|
||
ctx.font = '600 17px system-ui, sans-serif';
|
||
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 58);
|
||
}
|
||
|
||
function updateDigitalDisplay(side, percent, kn) {
|
||
if (!SHOW_GAUGE_DIGITAL) return;
|
||
const el = document.getElementById('digital-' + side);
|
||
if (!el) return;
|
||
const pctEl = el.querySelector('.percent');
|
||
const knEl = el.querySelector('.kn');
|
||
if (pctEl) pctEl.textContent = percent.toFixed(1);
|
||
if (knEl) knEl.textContent = kn.toFixed(1) + ' ' + UNIT_FORCE;
|
||
}
|
||
|
||
function getZone(v) {
|
||
if (v >= CRITICAL_PERCENT) return 'critical';
|
||
if (v >= WARNING_PERCENT) return 'warning';
|
||
return 'ok';
|
||
}
|
||
|
||
function getImbalanceZone(v) {
|
||
if (v >= IMBALANCE_CRITICAL_PERCENT) return 'critical';
|
||
if (v >= IMBALANCE_WARNING_PERCENT) return 'warning';
|
||
return 'ok';
|
||
}
|
||
|
||
function setProcessVisualState(connected) {
|
||
const el = document.getElementById('process-content');
|
||
if (!el) return;
|
||
el.classList.toggle('process-offline', !connected);
|
||
el.classList.toggle('process-online', connected);
|
||
}
|
||
|
||
function setConnectionIndicator(connected, stale) {
|
||
const dot = document.getElementById('dot');
|
||
const text = document.getElementById('status-text');
|
||
if (!dot || !text) return;
|
||
if (!connected) {
|
||
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
|
||
text.textContent = 'Disconnected';
|
||
text.className = 'font-semibold text-lg text-red-400';
|
||
return;
|
||
}
|
||
if (stale) {
|
||
dot.className = 'w-4 h-4 rounded-full bg-yellow-400 ring-4 ring-yellow-400/20';
|
||
text.textContent = 'Stale';
|
||
text.className = 'font-semibold text-lg text-yellow-300';
|
||
return;
|
||
}
|
||
dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20';
|
||
text.textContent = 'Connected';
|
||
text.className = 'font-semibold text-lg text-emerald-400';
|
||
}
|
||
|
||
function applyChannelState(side, percentValue) {
|
||
if (!SHOW_GAUGES) return;
|
||
const zone = getZone(percentValue);
|
||
const card = document.getElementById('card-' + side);
|
||
const led = document.getElementById('led-' + side);
|
||
const stTxt = document.getElementById('state-' + side);
|
||
if (!card || !led || !stTxt) return;
|
||
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
|
||
if (zone === 'critical') {
|
||
led.className = 'w-6 h-6 bg-red-500 rounded-full shadow-lg shadow-red-600/50';
|
||
stTxt.textContent = 'CRITICAL';
|
||
stTxt.className = 'text-sm text-red-400 mt-1 font-semibold';
|
||
card.classList.add('soft-glow-red');
|
||
} else if (zone === 'warning') {
|
||
led.className = 'w-6 h-6 bg-yellow-400 rounded-full shadow-lg shadow-yellow-500/50';
|
||
stTxt.textContent = 'WARNING';
|
||
stTxt.className = 'text-sm text-yellow-400 mt-1 font-semibold';
|
||
card.classList.add('soft-glow-yellow');
|
||
} else {
|
||
led.className = 'w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40';
|
||
stTxt.textContent = 'NORMAL';
|
||
stTxt.className = 'text-sm text-emerald-400 mt-1 font-semibold';
|
||
card.classList.add('soft-glow-green');
|
||
}
|
||
}
|
||
|
||
function formatLastUpdate(isoString) {
|
||
if (!isoString) return 'Last update: --:--:--.---';
|
||
const d = new Date(isoString);
|
||
if (isNaN(d.getTime())) return 'Last update: --:--:--.---';
|
||
const hh = String(d.getHours()).padStart(2, '0');
|
||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
|
||
}
|
||
|
||
function setSummaryCard(kind, zone, text, value) {
|
||
if (!SHOW_SUMMARY_BAR) return;
|
||
const card = document.getElementById('summary-' + kind + '-card');
|
||
const dot = document.getElementById('summary-' + kind + '-dot');
|
||
const status = document.getElementById('summary-' + kind + '-text');
|
||
const val = document.getElementById('summary-' + kind + '-value');
|
||
if (!card || !dot || !status || !val) return;
|
||
card.className = 'summary-card ' + zone;
|
||
dot.className = 'summary-dot ' + zone;
|
||
status.className = 'summary-status ' + zone + ' font-semibold mt-1';
|
||
status.textContent = text;
|
||
val.textContent = value;
|
||
}
|
||
|
||
function setVerdict(zone, statusText, reasonText) {
|
||
if (!SHOW_VERDICT) return;
|
||
const card = document.getElementById('verdict-card');
|
||
const status = document.getElementById('verdict-status');
|
||
const reason = document.getElementById('verdict-reason');
|
||
if (!card || !status || !reason) return;
|
||
card.className = 'verdict-card ' + zone;
|
||
status.textContent = statusText;
|
||
reason.textContent = reasonText;
|
||
}
|
||
|
||
function updateSummaryBar(connected, stale, leftPct, rightPct, imbalance) {
|
||
if (!SHOW_SUMMARY_BAR) return;
|
||
if (!connected) {
|
||
setSummaryCard('force', 'neutral', 'NO DATA', '--');
|
||
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
|
||
setSummaryCard('plc', 'critical', 'OFFLINE', 'Disconnected');
|
||
return;
|
||
}
|
||
const maxForce = Math.max(leftPct, rightPct);
|
||
const forceZone = getZone(maxForce);
|
||
const dominantSide = leftPct >= rightPct ? 'L' : 'R';
|
||
const forceText = forceZone === 'ok' ? 'OK' : forceZone === 'warning' ? 'WARNING' : 'CRITICAL';
|
||
setSummaryCard('force', forceZone, forceText, 'Max ' + maxForce.toFixed(1) + UNIT_PCT + ' (' + dominantSide + ')');
|
||
const imbZone = getImbalanceZone(imbalance);
|
||
const imbText = imbZone === 'ok' ? 'OK' : imbZone === 'warning' ? 'WARNING' : 'CRITICAL';
|
||
setSummaryCard('imbalance', imbZone, imbText, imbalance.toFixed(1) + UNIT_PCT);
|
||
if (stale) {
|
||
setSummaryCard('plc', 'warning', 'STALE', 'No fresh data');
|
||
} else {
|
||
setSummaryCard('plc', 'ok', 'OK', 'Online');
|
||
}
|
||
}
|
||
|
||
function updateMachineVerdict(connected, stale, leftPct, rightPct, imbalance) {
|
||
if (!SHOW_VERDICT) return;
|
||
if (!connected) { setVerdict('critical', 'OFFLINE', 'No PLC communication'); return; }
|
||
if (stale) { setVerdict('warning', 'STALE DATA', 'PLC connected but no fresh values received'); return; }
|
||
const leftCrit = leftPct >= CRITICAL_PERCENT;
|
||
const rightCrit = rightPct >= CRITICAL_PERCENT;
|
||
const imbCrit = imbalance >= IMBALANCE_CRITICAL_PERCENT;
|
||
if (leftCrit || rightCrit || imbCrit) {
|
||
const reasons = [];
|
||
if (leftCrit) reasons.push('left force critical');
|
||
if (rightCrit) reasons.push('right force critical');
|
||
if (imbCrit) reasons.push('imbalance critical');
|
||
setVerdict('critical', 'CRITICAL', reasons.join(' • '));
|
||
return;
|
||
}
|
||
const leftWarn = leftPct >= WARNING_PERCENT;
|
||
const rightWarn = rightPct >= WARNING_PERCENT;
|
||
const imbWarn = imbalance >= IMBALANCE_WARNING_PERCENT;
|
||
if (leftWarn || rightWarn || imbWarn) {
|
||
const reasons = [];
|
||
if (leftWarn) reasons.push('left force warning');
|
||
if (rightWarn) reasons.push('right force warning');
|
||
if (imbWarn) reasons.push('imbalance warning');
|
||
setVerdict('warning', 'WARNING', reasons.join(' • '));
|
||
return;
|
||
}
|
||
setVerdict('ok', 'OK', 'Production stable within configured force and imbalance limits');
|
||
}
|
||
|
||
function updateAlarmBanner(leftPct, rightPct, imbalancePct, connected, stale) {
|
||
const banner = document.getElementById('alarm-banner');
|
||
const text = document.getElementById('alarm-text');
|
||
if (!banner || !text) return;
|
||
if (!connected) { text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE'; banner.classList.remove('hidden'); return; }
|
||
if (stale) { text.textContent = 'WARNING • PLC DATA STALE'; banner.classList.remove('hidden'); return; }
|
||
const lc = leftPct >= CRITICAL_PERCENT;
|
||
const rc = rightPct >= CRITICAL_PERCENT;
|
||
const ic = imbalancePct >= IMBALANCE_CRITICAL_PERCENT;
|
||
if (!lc && !rc && !ic) { banner.classList.add('hidden'); return; }
|
||
const parts = [];
|
||
if (lc && rc) parts.push('FORCE LEFT + RIGHT');
|
||
else if (lc) parts.push('FORCE LEFT');
|
||
else if (rc) parts.push('FORCE RIGHT');
|
||
if (ic) parts.push('IMBALANCE');
|
||
text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • ');
|
||
banner.classList.remove('hidden');
|
||
}
|
||
|
||
function redrawGauges() {
|
||
if (!SHOW_GAUGES || !latestData) return;
|
||
const lPct = Number(latestData.sila_l) || 0;
|
||
const rPct = Number(latestData.sila_r) || 0;
|
||
const lKN = Number(latestData.sila_l_kn) || 0;
|
||
const rKN = Number(latestData.sila_r_kn) || 0;
|
||
drawGauge('gaugeL', lPct, lKN, '#22d3ee');
|
||
drawGauge('gaugeR', rPct, rKN, '#c084fc');
|
||
}
|
||
|
||
function directionLabel(d) {
|
||
const map = { rising:'↑ rising', falling:'↓ falling', worsening:'↑ worsening', improving:'↓ improving', stable:'→ stable' };
|
||
return map[d] || 'No data';
|
||
}
|
||
|
||
function directionClass(d) {
|
||
const map = { rising:'dir-up', falling:'dir-down', worsening:'dir-bad', improving:'dir-down', stable:'dir-flat' };
|
||
return map[d] || 'dir-flat';
|
||
}
|
||
|
||
function trendZoneForForce(dir, delta) {
|
||
if (dir === 'insufficient_data') return 'neutral';
|
||
if (dir === 'stable') return 'ok';
|
||
if (dir === 'rising') return Math.abs(delta) >= 8 ? 'critical' : 'warning';
|
||
return 'ok';
|
||
}
|
||
|
||
function trendZoneForImbalance(dir, delta) {
|
||
if (dir === 'insufficient_data') return 'neutral';
|
||
if (dir === 'stable') return 'ok';
|
||
if (dir === 'worsening') return Math.abs(delta) >= 4 ? 'critical' : 'warning';
|
||
return 'ok';
|
||
}
|
||
|
||
function stabilityZone(s) {
|
||
return { stable:'ok', caution:'warning', unstable:'critical' }[s] || 'neutral';
|
||
}
|
||
|
||
function setIntelCard(idPrefix, zone, valueText, subText) {
|
||
if (!SHOW_INTELLIGENCE) return;
|
||
const card = document.getElementById(idPrefix + '-card');
|
||
const value = document.getElementById(idPrefix + '-value');
|
||
const sub = document.getElementById(idPrefix + '-sub');
|
||
if (!card || !value || !sub) return;
|
||
card.className = 'intel-card ' + zone;
|
||
value.innerHTML = valueText;
|
||
sub.innerHTML = subText;
|
||
}
|
||
|
||
function formatSource(source) {
|
||
const map = { force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' };
|
||
return map[source] || String(source || '').toUpperCase();
|
||
}
|
||
|
||
function formatValue(value) {
|
||
const n = Number(value);
|
||
if (!isFinite(n)) return '--';
|
||
return n.toFixed(1) + UNIT_PCT;
|
||
}
|
||
|
||
function updateChartTheme() {
|
||
if (!SHOW_TREND_CHART || !lineChart) return;
|
||
const light = isLightTheme();
|
||
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
|
||
const tick = light ? '#334155' : '#a1a1aa';
|
||
const legend = light ? '#0f172a' : '#f4f4f5';
|
||
const tooltipBg = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.96)';
|
||
const tooltipText = light ? '#0f172a' : '#f4f4f5';
|
||
lineChart.options.scales.x.grid.color = grid;
|
||
lineChart.options.scales.x.ticks.color = tick;
|
||
lineChart.options.scales.y.grid.color = grid;
|
||
lineChart.options.scales.y.ticks.color = tick;
|
||
lineChart.options.plugins.legend.labels.color = legend;
|
||
lineChart.options.plugins.tooltip.backgroundColor = tooltipBg;
|
||
lineChart.options.plugins.tooltip.titleColor = tooltipText;
|
||
lineChart.options.plugins.tooltip.bodyColor = tooltipText;
|
||
lineChart.update('none');
|
||
}
|
||
|
||
async function checkUIRevision() {
|
||
try {
|
||
const res = await fetch('/api/ui-revision', { cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const d = await res.json();
|
||
const revision = Number(d.revision) || 0;
|
||
if (revision > activeUIRevision) { window.location.reload(); return; }
|
||
activeUIRevision = revision;
|
||
} catch (err) { console.warn('UI revision check error:', err); }
|
||
}
|
||
|
||
function computeStaleFromPayload(d, connected) {
|
||
if (!connected) return false;
|
||
if (typeof d.stale === 'boolean') return d.stale;
|
||
if (!d.last_update) return false;
|
||
|
||
const lastTs = new Date(d.last_update).getTime();
|
||
if (isNaN(lastTs)) return false;
|
||
|
||
const serverNowTs = d.server_time ? new Date(d.server_time).getTime() : Date.now();
|
||
if (isNaN(serverNowTs)) return false;
|
||
|
||
return (serverNowTs - lastTs) > STALE_MS;
|
||
}
|
||
|
||
async function fetchLiveData() {
|
||
try {
|
||
const res = await fetch('/api/data', { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const d = await res.json();
|
||
latestData = d;
|
||
|
||
const connected = !!d.connected;
|
||
const leftPercent = Number(d.sila_l) || 0;
|
||
const rightPercent= Number(d.sila_r) || 0;
|
||
const sumPercent = Number(d.sum_percent) || 0;
|
||
const sumKN = Number(d.sum_kn) || 0;
|
||
const imbalance = Number(d.imbalance_percent)|| 0;
|
||
const bias = Number(d.bias_percent) || 0;
|
||
const leftKN = Number(d.sila_l_kn) || 0;
|
||
const rightKN = Number(d.sila_r_kn) || 0;
|
||
const stale = computeStaleFromPayload(d, connected);
|
||
|
||
setConnectionIndicator(connected, stale);
|
||
setProcessVisualState(connected && !stale);
|
||
|
||
if (SHOW_OVERVIEW) {
|
||
setTextById('sum-percent', sumPercent.toFixed(1));
|
||
setTextById('sum-kn', sumKN.toFixed(1));
|
||
setTextById('imbalance-pct', imbalance.toFixed(1));
|
||
setTextById('bias-pct', bias.toFixed(1));
|
||
}
|
||
|
||
setTextById('last-update', formatLastUpdate(d.last_update));
|
||
setTextById('dropped-samples', String(d.dropped_samples || 0));
|
||
setTextById('dropped-events', String(d.dropped_events || 0));
|
||
|
||
applyChannelState('l', leftPercent);
|
||
applyChannelState('r', rightPercent);
|
||
updateDigitalDisplay('l', leftPercent, leftKN);
|
||
updateDigitalDisplay('r', rightPercent, rightKN);
|
||
|
||
updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance);
|
||
updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance);
|
||
updateAlarmBanner(leftPercent, rightPercent, imbalance, connected, stale);
|
||
redrawGauges();
|
||
} catch (err) {
|
||
console.warn('Live fetch error:', err);
|
||
latestData = null;
|
||
setConnectionIndicator(false, false);
|
||
setProcessVisualState(false);
|
||
updateSummaryBar(false, false, 0, 0, 0);
|
||
updateMachineVerdict(false, false, 0, 0, 0);
|
||
updateAlarmBanner(0, 0, 0, false, false);
|
||
updateDigitalDisplay('l', 0, 0);
|
||
updateDigitalDisplay('r', 0, 0);
|
||
}
|
||
}
|
||
|
||
async function fetchHistory() {
|
||
if (!SHOW_TREND_CHART || !lineChart || historyBusy) return;
|
||
historyBusy = true;
|
||
try {
|
||
const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const d = await res.json();
|
||
const pts = Array.isArray(d.points) ? d.points : [];
|
||
const labels = pts.map(p => p.time);
|
||
const dataL = pts.map(p => p.sila_l);
|
||
const dataR = pts.map(p => p.sila_r);
|
||
const warnLine = labels.map(() => WARNING_PERCENT);
|
||
const critLine = labels.map(() => CRITICAL_PERCENT);
|
||
lineChart.data.labels = labels;
|
||
lineChart.data.datasets[0].data = dataL;
|
||
lineChart.data.datasets[1].data = dataR;
|
||
lineChart.data.datasets[2].data = warnLine;
|
||
lineChart.data.datasets[3].data = critLine;
|
||
lineChart.update('none');
|
||
} catch (err) {
|
||
console.warn('History fetch error:', err);
|
||
} finally {
|
||
historyBusy = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTrend() {
|
||
if (!SHOW_INTELLIGENCE || trendBusy) return;
|
||
trendBusy = true;
|
||
try {
|
||
const res = await fetch('/api/trend?window=' + encodeURIComponent(currentTrendWindow), { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const d = await res.json();
|
||
const avgPeak5m = Number(d.avg_peak_5m) || 0;
|
||
const avgPeak1h = Number(d.avg_peak_1h) || 0;
|
||
const avgImb5m = Number(d.avg_imbalance_5m) || 0;
|
||
const avgImb1h = Number(d.avg_imbalance_1h) || 0;
|
||
const forceDelta = Number(d.force_delta_pct) || 0;
|
||
const imbDelta = Number(d.imbalance_delta_pct) || 0;
|
||
const forceDir = d.force_direction || 'insufficient_data';
|
||
const imbDir = d.imbalance_direction || 'insufficient_data';
|
||
const stability = d.process_stability || 'insufficient_data';
|
||
const stReason = d.stability_reason || 'No data';
|
||
const forceStd = Number(d.force_stddev) || 0;
|
||
const imbStd = Number(d.imbalance_stddev) || 0;
|
||
const sampleCount = Number(d.sample_count) || 0;
|
||
const windowLabel = d.window || currentTrendWindow;
|
||
|
||
setIntelCard('intel-avg5', getZone(avgPeak5m),
|
||
avgPeak5m.toFixed(1) + UNIT_PCT,
|
||
'Avg imbalance 5m: <span class="intel-kpi">' + avgImb5m.toFixed(1) + UNIT_PCT + '</span>');
|
||
|
||
setIntelCard('intel-avg1h', getZone(avgPeak1h),
|
||
avgPeak1h.toFixed(1) + UNIT_PCT,
|
||
'Avg imbalance 1h: <span class="intel-kpi">' + avgImb1h.toFixed(1) + UNIT_PCT + '</span>');
|
||
|
||
setIntelCard('intel-force', trendZoneForForce(forceDir, forceDelta),
|
||
(forceDelta >= 0 ? '+' : '') + forceDelta.toFixed(1) + UNIT_PCT,
|
||
'<span class="' + directionClass(forceDir) + ' font-semibold">' + directionLabel(forceDir) + '</span><br>Δ avg force over ' + windowLabel + ' • σ ' + forceStd.toFixed(2));
|
||
|
||
setIntelCard('intel-imb', trendZoneForImbalance(imbDir, imbDelta),
|
||
(imbDelta >= 0 ? '+' : '') + imbDelta.toFixed(1) + UNIT_PCT,
|
||
'<span class="' + directionClass(imbDir) + ' font-semibold">' + directionLabel(imbDir) + '</span><br>Δ avg imbalance over ' + windowLabel + ' • σ ' + imbStd.toFixed(2));
|
||
|
||
setIntelCard('intel-stability', stabilityZone(stability),
|
||
String(stability).toUpperCase(),
|
||
stReason + '<br><span class="mini-mono">Samples: ' + sampleCount + ' • Window: ' + windowLabel + '</span>');
|
||
} catch (err) {
|
||
console.warn('Trend fetch error:', err);
|
||
['intel-avg5','intel-avg1h','intel-force','intel-imb','intel-stability']
|
||
.forEach(id => setIntelCard(id, 'neutral', '--', 'No data'));
|
||
} finally {
|
||
trendBusy = false;
|
||
}
|
||
}
|
||
|
||
async function fetchAlarms() {
|
||
if (!SHOW_ALARM_TIMELINE || alarmsBusy) return;
|
||
alarmsBusy = true;
|
||
try {
|
||
const res = await fetch('/api/alarms?limit=20', { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const d = await res.json();
|
||
const events = Array.isArray(d.events) ? d.events : [];
|
||
const body = document.getElementById('alarm-table-body');
|
||
if (!body) return;
|
||
if (events.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const ev of events) {
|
||
const sev = String(ev.severity || 'info');
|
||
const val = ev.source === 'plc' ? '--' : formatValue(ev.value);
|
||
const lim = ev.limit > 0 ? formatValue(ev.limit) : '--';
|
||
html += '<tr class="border-b border-white/5">';
|
||
html += '<td class="py-3 pr-4 font-mono text-zinc-300 whitespace-nowrap">' + escapeHtml(ev.time || '--') + '</td>';
|
||
html += '<td class="py-3 pr-4"><span class="severity-pill ' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></td>';
|
||
html += '<td class="py-3 pr-4 font-semibold text-zinc-200">' + escapeHtml(formatSource(ev.source)) + '</td>';
|
||
html += '<td class="py-3 pr-4 text-zinc-300">' + escapeHtml(ev.message || '--') + '</td>';
|
||
html += '<td class="py-3 pr-4 text-right font-mono text-zinc-200">' + escapeHtml(val) + '</td>';
|
||
html += '<td class="py-3 text-right font-mono text-zinc-400">' + escapeHtml(lim) + '</td>';
|
||
html += '</tr>';
|
||
}
|
||
body.innerHTML = html;
|
||
} catch (err) {
|
||
console.warn('Alarm fetch error:', err);
|
||
} finally {
|
||
alarmsBusy = false;
|
||
}
|
||
}
|
||
|
||
function setActiveWindowButton(value) {
|
||
document.querySelectorAll('.window-btn').forEach(btn =>
|
||
btn.classList.toggle('active', btn.dataset.window === value));
|
||
}
|
||
|
||
function setActiveTrendWindowButton(value) {
|
||
document.querySelectorAll('.trend-window-btn').forEach(btn =>
|
||
btn.classList.toggle('active', btn.dataset.window === value));
|
||
}
|
||
|
||
function useWindow(value) {
|
||
if (!SHOW_TREND_CHART) return;
|
||
currentWindow = value;
|
||
setActiveWindowButton(value);
|
||
fetchHistory();
|
||
}
|
||
|
||
function useTrendWindow(value) {
|
||
if (!SHOW_INTELLIGENCE) return;
|
||
currentTrendWindow = value;
|
||
setActiveTrendWindowButton(value);
|
||
fetchTrend();
|
||
}
|
||
|
||
window.onload = () => {
|
||
AppUI.initTheme({
|
||
buttonId: 'theme-toggle',
|
||
onChange: (theme) => {
|
||
currentTheme = theme;
|
||
updateChartTheme();
|
||
redrawGauges();
|
||
}
|
||
});
|
||
if (SHOW_HEADER_CONTROLS) {
|
||
AppUI.initFullscreen({ buttonId: 'fullscreen-toggle' });
|
||
}
|
||
|
||
setActiveWindowButton(DEFAULT_WINDOW);
|
||
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
||
|
||
document.querySelectorAll('.window-btn').forEach(btn =>
|
||
btn.addEventListener('click', () => useWindow(btn.dataset.window)));
|
||
|
||
document.querySelectorAll('.trend-window-btn').forEach(btn =>
|
||
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window)));
|
||
|
||
const applyWindowBtn = document.getElementById('apply-window');
|
||
if (applyWindowBtn) {
|
||
applyWindowBtn.addEventListener('click', () => {
|
||
const input = document.getElementById('custom-window');
|
||
const val = input ? input.value.trim() : '';
|
||
if (!val) return;
|
||
currentWindow = val;
|
||
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
|
||
fetchHistory();
|
||
});
|
||
}
|
||
|
||
const applyTrendBtn = document.getElementById('apply-trend-window');
|
||
if (applyTrendBtn) {
|
||
applyTrendBtn.addEventListener('click', () => {
|
||
const input = document.getElementById('custom-trend-window');
|
||
const val = input ? input.value.trim() : '';
|
||
if (!val) return;
|
||
currentTrendWindow = val;
|
||
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
|
||
fetchTrend();
|
||
});
|
||
}
|
||
|
||
|
||
if (SHOW_TREND_CHART) {
|
||
const chartCanvas = document.getElementById('lineChart');
|
||
if (chartCanvas) {
|
||
lineChart = new Chart(chartCanvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels: [],
|
||
datasets: [
|
||
{ label: 'Levi peak %', borderColor: '#22d3ee', backgroundColor: 'rgba(34,211,238,0.10)', borderWidth: 3, tension: 0.22, pointRadius: 0, data: [] },
|
||
{ label: 'Desni peak %', borderColor: '#c084fc', backgroundColor: 'rgba(192,132,252,0.10)', borderWidth: 3, tension: 0.22, pointRadius: 0, data: [] },
|
||
{ label: 'Warning limit', borderColor: 'rgba(245,158,11,0.95)', borderWidth: 2, pointRadius: 0, borderDash: [8, 6], tension: 0, data: [] },
|
||
{ label: 'Critical limit',borderColor: 'rgba(239,68,68,0.95)', borderWidth: 2, pointRadius: 0, borderDash: [8, 6], tension: 0, data: [] }
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
animation: false,
|
||
scales: {
|
||
x: { grid: { color: 'rgba(255,255,255,0.06)' }, ticks: { color: '#a1a1aa', maxTicksLimit: 18 } },
|
||
y: { min: 0, max: GAUGE_MAX_PERCENT, grid: { color: 'rgba(255,255,255,0.06)' }, ticks: { color: '#a1a1aa', stepSize: 10 } }
|
||
},
|
||
plugins: {
|
||
legend: { position: 'top', labels: { color: '#f4f4f5' } },
|
||
tooltip: { backgroundColor: 'rgba(9,9,11,0.96)', titleColor: '#f4f4f5', bodyColor: '#f4f4f5' }
|
||
}
|
||
}
|
||
});
|
||
updateChartTheme();
|
||
}
|
||
}
|
||
|
||
setProcessVisualState(false);
|
||
|
||
fetchLiveData();
|
||
fetchHistory();
|
||
fetchTrend();
|
||
fetchAlarms();
|
||
checkUIRevision();
|
||
|
||
setInterval(fetchLiveData, POLL_MS);
|
||
setInterval(checkUIRevision, 1200);
|
||
if (SHOW_TREND_CHART) setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
|
||
if (SHOW_INTELLIGENCE) setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
|
||
if (SHOW_ALARM_TIMELINE) setInterval(fetchAlarms, 2500);
|
||
|
||
window.addEventListener('resize', redrawGauges);
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|