Tonnage-app-IMCO/static/index.html
2026-04-22 10:42:52 +02:00

1452 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
}
* { 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;
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;
gap: 8px;
min-height: 44px;
padding: 10px 14px;
border-radius: 16px;
border: 1px solid var(--button-border);
background: var(--button-bg);
color: var(--button-text);
font-weight: 600;
transition: 160ms ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.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" 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">&#9888;&#65039;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 applyTheme(theme) {
currentTheme = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', currentTheme);
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (e) {}
updateThemeButton();
updateChartTheme();
redrawGauges();
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') {
theme = stored;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
theme = 'light';
}
} catch (e) {}
applyTheme(theme);
}
function toggleTheme() { applyTheme(isLightTheme() ? 'dark' : 'light'); }
function updateThemeButton() {
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
}
function updateFullscreenButton() {
const btn = document.getElementById('fullscreen-toggle');
if (btn) btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
}
async function toggleFullscreen() {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (err) {
console.warn('Fullscreen error:', err);
} finally {
updateFullscreenButton();
}
}
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 = () => {
initTheme();
setActiveWindowButton(DEFAULT_WINDOW);
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
if (SHOW_HEADER_CONTROLS) {
const themeBtn = document.getElementById('theme-toggle');
const fsBtn = document.getElementById('fullscreen-toggle');
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
}
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();
});
}
document.addEventListener('fullscreenchange', updateFullscreenButton);
updateFullscreenButton();
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>