2026-04-23 10:08:35 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Force Monitor — Kiosk< / title >
< style >
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
:root{--bg1:#030712;--bg2:#0f172a;--panel:rgba(255,255,255,.06);--border:rgba(255,255,255,.1);--text:#f8fafc;--muted:#94a3b8;--ok:#34d399;--warn:#facc15;--bad:#f87171;}
body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.10);--text:#0f172a;--muted:#475569;--ok:#059669;--warn:#b45309;--bad:#dc2626;}
*{box-sizing:border-box} body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 20% 10%, rgba(56,189,248,.14), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
body[data-theme="light"]{background:radial-gradient(circle at 20% 10%, rgba(14,165,233,.10), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.10), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
.wrap{width:min(96vw,1800px);margin:0 auto;padding:18px 22px 28px;} .row,.nav{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}
.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;cursor:pointer}
body[data-theme="light"] .btn{background:rgba(255,255,255,.88);}
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
.hero{padding:18px 24px;margin-bottom:18px}.status{font-size:64px;font-weight:900;line-height:1;margin-top:12px}.sub{color:var(--muted)} .mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.card{padding:22px 24px}.label{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)} .value{font-size:54px;font-weight:900;line-height:1;margin-top:12px}
.small{font-size:18px;color:var(--muted);margin-top:10px}.banner{padding:16px 18px;border-radius:18px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.14);display:none;margin-bottom:16px}.banner.show{display:block}
.ok{color:var(--ok)} .warning{color:var(--warn)} .critical{color:var(--bad)} .neutral{color:var(--muted)}
.split{display:grid;grid-template-columns:1.35fr .85fr;gap:16px}.panel{padding:18px 22px} ul{margin:12px 0 0;padding-left:18px} li{margin:8px 0;color:var(--muted)}
@media (max-width:1200px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}.value{font-size:42px}.status{font-size:48px}}
@media (max-width:760px){.cards{grid-template-columns:1fr}.wrap{padding:14px}.value{font-size:36px}.status{font-size:38px}}
< / style >
< / head >
< body >
< div class = "wrap" >
< div class = "nav" style = "margin-bottom:14px" >
< a class = "btn" href = "/" > Dashboard< / a > < a class = "btn" href = "/history" > History< / a > < a class = "btn" href = "/alarms" > Alarms< / a > < a class = "btn primary" href = "/kiosk" > Kiosk< / a > < a class = "btn" href = "/process-capability" > Process capability< / a > < a class = "btn" href = "/reports" > Reports< / a > < a class = "btn" href = "/license" > License< / a >
< div class = "spacer" > < / div > < button id = "theme-toggle" class = "btn" type = "button" > Light theme< / button > < button id = "fullscreen-btn" class = "btn" type = "button" > Enter fullscreen< / button >
< / div >
< div id = "alarm-banner" class = "banner" > < / div >
< div class = "glass hero" >
< div class = "row" > < div > < div class = "label" id = "title-kicker" > Force Monitor< / div > < div class = "status" id = "status-text" > LOADING< / div > < div class = "sub" id = "status-reason" > Preparing kiosk view…< / div > < / div > < div class = "spacer" > < / div > < div class = "mono sub" id = "clock" > --< / div > < / div >
< / div >
< div class = "grid cards" >
< div class = "glass card" > < div class = "label" > Total peak< / div > < div id = "total-value" class = "value mono" > --< / div > < div id = "total-sub" class = "small" > kN / %< / div > < / div >
< div class = "glass card" > < div class = "label" > Left< / div > < div id = "left-value" class = "value mono" > --< / div > < div id = "left-sub" class = "small" > kN / %< / div > < / div >
< div class = "glass card" > < div class = "label" > Right< / div > < div id = "right-value" class = "value mono" > --< / div > < div id = "right-sub" class = "small" > kN / %< / div > < / div >
< div class = "glass card" > < div class = "label" > Imbalance< / div > < div id = "imb-value" class = "value mono" > --< / div > < div id = "imb-sub" class = "small" > bias / trend< / div > < / div >
< / div >
< div class = "split" >
< div class = "glass panel" >
< div class = "row" > < h2 style = "margin:0;font-size:30px" > Live production verdict< / h2 > < div class = "spacer" > < / div > < span id = "stale-pill" class = "mono sub" > Data freshness: --< / span > < / div >
< div id = "verdict-summary" class = "status neutral" style = "font-size:56px;margin-top:14px" > WAITING< / div >
< div id = "verdict-detail" class = "sub" style = "font-size:22px;margin-top:12px" > No PLC data yet.< / div >
< div class = "row" style = "margin-top:22px" >
< div class = "glass" style = "padding:14px 16px;min-width:220px" > < div class = "label" > Trend direction< / div > < div id = "trend-force" class = "value mono" style = "font-size:32px" > --< / div > < div id = "trend-force-sub" class = "small" > force drift< / div > < / div >
< div class = "glass" style = "padding:14px 16px;min-width:220px" > < div class = "label" > Process stability< / div > < div id = "trend-stability" class = "value mono" style = "font-size:32px" > --< / div > < div id = "trend-stability-sub" class = "small" > stability< / div > < / div >
< / div >
< / div >
< div class = "glass panel" >
< div class = "row" > < h2 style = "margin:0;font-size:28px" > Active attention items< / h2 > < div class = "spacer" > < / div > < span class = "sub mono" id = "last-refresh" > Last refresh: --< / span > < / div >
< ul id = "attention-list" > < li > Loading live status…< / li > < / ul >
< / div >
< / div >
< / div >
< script src = "/static/app-common.js" > < / script >
< script >
let cfg={ui:{title:'Force Monitor',unit_force:'kN',unit_percent:'%'},thresholds:{warning_percent:80,critical_percent:95,imbalance_warning_percent:10,imbalance_critical_percent:20}};
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok';
function zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
async function loadCfg(){try{const r=await fetch('/api/config/public',{cache:'no-store'}); if(r.ok){cfg=await r.json(); setThemeTitle();}}catch(e){}}
async function refreshAll(){
try{
const [dataRes, trendRes, alarmsRes]=await Promise.all([
fetch('/api/data',{cache:'no-store'}), fetch('/api/trend?window=15m',{cache:'no-store'}), fetch('/api/alarms?limit=8',{cache:'no-store'})
]);
if(dataRes.status===403){document.getElementById('status-text').textContent='LICENSE REQUIRED';document.getElementById('status-text').className='status critical';document.getElementById('status-reason').textContent='Open /license to activate the application.';return;}
const d=await dataRes.json(); const t=trendRes.ok?await trendRes.json():{}; const a=alarmsRes.ok?await alarmsRes.json():{events:[]};
const connected=!!d.connected, stale=!!d.stale; const lp=Number(d.sila_l)||0, rp=Number(d.sila_r)||0, tp=Number(d.sum_percent)||0, tkn=Number(d.sum_kn)||0, imb=Number(d.imbalance_percent)||0, bias=Number(d.bias_percent)||0;
const lkn=Number(d.sila_l_kn)||0, rkn=Number(d.sila_r_kn)||0;
document.getElementById('clock').textContent=new Date().toLocaleString(); document.getElementById('last-refresh').textContent='Last refresh: '+new Date().toLocaleTimeString();
document.getElementById('total-value').textContent=fmt(tkn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('total-sub').textContent=fmt(tp,1)+(cfg.ui.unit_percent||'%')+' total load';
document.getElementById('left-value').textContent=fmt(lp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('left-sub').textContent=fmt(lkn,1)+' '+(cfg.ui.unit_force||'kN');
document.getElementById('right-value').textContent=fmt(rp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('right-sub').textContent=fmt(rkn,1)+' '+(cfg.ui.unit_force||'kN');
document.getElementById('imb-value').textContent=fmt(imb,1)+(cfg.ui.unit_percent||'%'); document.getElementById('imb-sub').textContent='Bias '+fmt(bias,1)+(cfg.ui.unit_percent||'%');
const zForce=zone(Math.max(lp,rp),cfg.thresholds.warning_percent,cfg.thresholds.critical_percent); const zImb=zone(imb,cfg.thresholds.imbalance_warning_percent,cfg.thresholds.imbalance_critical_percent);
const statusEl=document.getElementById('status-text'); const reasonEl=document.getElementById('status-reason'); const verdict=document.getElementById('verdict-summary'); const detail=document.getElementById('verdict-detail');
let verdictText='OK', reason='Production looks stable.'; let level='ok';
if(!connected){ verdictText='PLC OFFLINE'; reason='No PLC communication.'; level='critical'; }
else if(stale){ verdictText='STALE DATA'; reason='PLC connected, but no fresh values are arriving.'; level='warning'; }
else if(zForce==='critical' || zImb==='critical'){ verdictText='CRITICAL'; reason='Force or imbalance reached critical region.'; level='critical'; }
else if(zForce==='warning' || zImb==='warning'){ verdictText='WARNING'; reason='Process is above warning thresholds.'; level='warning'; }
statusEl.textContent=verdictText; statusEl.className='status '+level; reasonEl.textContent=reason; verdict.textContent=verdictText; verdict.className='status '+level; detail.textContent=reason;
document.getElementById('stale-pill').textContent='Data freshness: '+(stale?'stale':connected?'fresh':'offline');
document.getElementById('trend-force').textContent=((Number(t.force_delta_pct)||0)>=0?'+':'')+fmt(t.force_delta_pct,1)+(cfg.ui.unit_percent||'%');
document.getElementById('trend-force').className='value mono '+(((Number(t.force_delta_pct)||0)>=3)?'warning':'ok');
document.getElementById('trend-force-sub').textContent=(t.force_direction||'--')+' over 15m';
document.getElementById('trend-stability').textContent=String(t.process_stability||'--').toUpperCase();
document.getElementById('trend-stability').className='value mono '+(t.process_stability==='unstable'?'critical':t.process_stability==='caution'?'warning':'ok');
document.getElementById('trend-stability-sub').textContent=t.stability_reason||'No trend reason';
const attention=[]; if(!connected) attention.push('Restore PLC communication to recover live monitoring.'); if(stale) attention.push('Investigate stale data path between PLC and the app.'); if(zForce!=='ok') attention.push('Force level is '+zForce+'; review current load and top-force causes.'); if(zImb!=='ok') attention.push('Imbalance is '+zImb+'; check centering, alignment, and tooling.');
(a.events||[]).slice(0,4).forEach(ev=>{if(ev.severity!=='info') attention.push((ev.time||'')+' • '+(ev.message||''));});
const ul=document.getElementById('attention-list'); ul.innerHTML=''; (attention.length?attention:['No active attention items.']).forEach(item=>{const li=document.createElement('li'); li.textContent=item; ul.appendChild(li);});
const banner=document.getElementById('alarm-banner'); if(level==='critical'){banner.textContent='Critical attention required — review force, imbalance, or PLC connectivity.'; banner.classList.add('show');} else if(level==='warning'){banner.textContent='Warning condition active — process should be reviewed.'; banner.classList.add('show');} else {banner.classList.remove('show');}
}catch(err){console.warn(err)}
}
AppUI.initTheme(); AppUI.initFullscreen({ buttonId:'fullscreen-btn' });
loadCfg().then(refreshAll); setInterval(refreshAll, 1500);
< / script >
< / body >
2026-04-22 14:16:27 +00:00
< / html >