This commit is contained in:
Dejan R 2026-04-22 16:16:27 +02:00
parent ea9bdddef4
commit 8c0b353c90
5 changed files with 37 additions and 29 deletions

43
main.go
View file

@ -792,12 +792,13 @@ type mqttManager struct {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
var ( var (
cfg Config cfg Config
cfgMu sync.RWMutex cfgMu sync.RWMutex
state AppState state AppState
db *sql.DB db *sql.DB
sampleCh chan Sample sampleCh chan Sample
alarmCh chan AlarmEvent alarmCh chan AlarmEvent
indexTmpl *template.Template
alarmTracker AlarmTracker alarmTracker AlarmTracker
uiRevision uint64 = 1 uiRevision uint64 = 1
@ -2830,11 +2831,12 @@ func queryReportSummary(ctx context.Context, window time.Duration, label string)
if err != nil { if err != nil {
return ReportSummaryResponse{}, err return ReportSummaryResponse{}, err
} }
defer alarmRows.Close()
for alarmRows.Next() { for alarmRows.Next() {
var tsUnix int64 var tsUnix int64
var severity, source, code string var severity, source, code string
if err := alarmRows.Scan(&tsUnix, &severity, &source, &code); err != nil { if err := alarmRows.Scan(&tsUnix, &severity, &source, &code); err != nil {
alarmRows.Close()
return ReportSummaryResponse{}, err return ReportSummaryResponse{}, err
} }
labelKey := reportBucketLabel(time.Unix(0, tsUnix), window) labelKey := reportBucketLabel(time.Unix(0, tsUnix), window)
@ -2848,7 +2850,9 @@ func queryReportSummary(ctx context.Context, window time.Duration, label string)
plcDiscByBucket[labelKey]++ plcDiscByBucket[labelKey]++
} }
} }
alarmRows.Close() if err := alarmRows.Err(); err != nil {
return ReportSummaryResponse{}, err
}
buckets := make([]ReportBucket, 0, len(order)) buckets := make([]ReportBucket, 0, len(order))
for _, key := range order { for _, key := range order {
@ -3300,9 +3304,8 @@ async function activate(){
} }
// License OK — serve the full dashboard template from the embedded static files // License OK — serve the full dashboard template from the embedded static files
tmpl, err := template.ParseFS(embeddedStaticFiles, "static/index.html") if indexTmpl == nil {
if err != nil { log.Printf("dashboard template not initialized")
log.Printf("template parse error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
@ -3349,7 +3352,7 @@ async function activate(){
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
if err := tmpl.Execute(w, data); err != nil { if err := indexTmpl.Execute(w, data); err != nil {
log.Printf("template execute error: %v", err) log.Printf("template execute error: %v", err)
} }
return return
@ -3622,6 +3625,11 @@ func main() {
log.Fatalf("invalid config: %v", err) log.Fatalf("invalid config: %v", err)
} }
indexTmpl, err = template.ParseFS(embeddedStaticFiles, "static/index.html")
if err != nil {
log.Fatalf("failed to parse embedded dashboard template: %v", err)
}
dbPath := cfg.DB.Path dbPath := cfg.DB.Path
if !filepath.IsAbs(dbPath) { if !filepath.IsAbs(dbPath) {
dbPath = filepath.Join(wd, dbPath) dbPath = filepath.Join(wd, dbPath)
@ -3742,11 +3750,12 @@ func main() {
mux.HandleFunc("/api/license/activate", apiLicenseActivate) mux.HandleFunc("/api/license/activate", apiLicenseActivate)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Server.ListenAddr, Addr: cfg.Server.ListenAddr,
Handler: mux, Handler: mux,
ReadTimeout: 15 * time.Second, ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second, WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
} }
log.Printf("Listening address: %s", cfg.Server.ListenAddr) log.Printf("Listening address: %s", cfg.Server.ListenAddr)

View file

@ -514,7 +514,7 @@
function wireEvents() { function wireEvents() {
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } }); AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
updateFullscreenButton(); AppUI.updateFullscreenButton('fullscreen-toggle');
qs('refresh-btn').addEventListener('click', refreshAll); qs('refresh-btn').addEventListener('click', refreshAll);
qs('export-csv').addEventListener('click', exportCsv); qs('export-csv').addEventListener('click', exportCsv);
qs('apply-window').addEventListener('click', () => { qs('apply-window').addEventListener('click', () => {
@ -543,4 +543,4 @@
})(); })();
</script> </script>
</body> </body>
</html> </html>

View file

@ -6,9 +6,12 @@
<title>Force Monitor — Kiosk</title> <title>Force Monitor — Kiosk</title>
<style> <style>
: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;} :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));} *{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} .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} .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)} .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} .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} .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}
@ -54,9 +57,6 @@
<script src="/static/app-common.js"></script> <script src="/static/app-common.js"></script>
<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}}; 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}};
function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} const btn=document.getElementById('theme-toggle'); if(btn) btn.textContent=theme==='light'?'Dark theme':'Light theme';}
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark');}
function updateFullscreenButton(){const btn=document.getElementById('fullscreen-btn'); if(btn) btn.textContent=document.fullscreenElement?'Exit fullscreen':'Enter fullscreen';}
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok'; 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 zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'} function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
@ -100,4 +100,4 @@ AppUI.initTheme(); AppUI.initFullscreen({ buttonId:'fullscreen-btn' });
loadCfg().then(refreshAll); setInterval(refreshAll, 1500); loadCfg().then(refreshAll); setInterval(refreshAll, 1500);
</script> </script>
</body> </body>
</html> </html>

View file

@ -15,8 +15,7 @@
<script src="/static/app-common.js"></script> <script src="/static/app-common.js"></script>
<script> <script>
let currentWindow='1h', 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}}, totalChart=null, imbChart=null; let currentWindow='1h', 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}}, totalChart=null, imbChart=null;
function fmt(n,d=2){return Number(n||0).toFixed(d)} function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} document.getElementById('theme-toggle').textContent=theme==='light'?'Dark theme':'Light theme'; if(totalChart){updateChartTheme();}} function fmt(n,d=2){return Number(n||0).toFixed(d)}
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark')} 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(e){console.warn('Fullscreen error:',e)}finally{updateFullscreenButton();}}
function updateChartTheme(){const light=document.body.dataset.theme==='light'; [totalChart,imbChart].forEach(ch=>{ if(!ch) return; ch.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.update('none');});} function updateChartTheme(){const light=document.body.dataset.theme==='light'; [totalChart,imbChart].forEach(ch=>{ if(!ch) return; ch.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.update('none');});}
function makeHistChart(id,label,color){return new Chart(document.getElementById(id),{type:'bar',data:{labels:[],datasets:[{label:label,borderColor:color,backgroundColor:color+'55',data:[]}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}}}}});} function makeHistChart(id,label,color){return new Chart(document.getElementById(id),{type:'bar',data:{labels:[],datasets:[{label:label,borderColor:color,backgroundColor:color+'55',data:[]}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}}}}});}
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}} async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
@ -26,4 +25,4 @@ async function refresh(){const r=await fetch('/api/process-capability?window='+e
const body=document.getElementById('outlier-body'); const rows=(d.top_outliers||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td></tr>').join(''); body.innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';} const body=document.getElementById('outlier-body'); const rows=(d.top_outliers||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td></tr>').join(''); body.innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)} function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
AppUI.initTheme({ onChange: ()=>{ if(totalChart||imbChart) updateChartTheme(); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); loadCfg().then(()=>refresh().catch(console.warn)); AppUI.initTheme({ onChange: ()=>{ if(totalChart||imbChart) updateChartTheme(); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); loadCfg().then(()=>refresh().catch(console.warn));
</script></body></html> </script></body></html>

View file

@ -15,11 +15,11 @@
<script src="/static/app-common.js"></script> <script src="/static/app-common.js"></script>
<script> <script>
let currentWindow='8h', reportCache=null, cfg={ui:{unit_force:'kN',unit_percent:'%'}}, chart=null; let currentWindow='8h', reportCache=null, cfg={ui:{unit_force:'kN',unit_percent:'%'}}, chart=null;
function fmt(n,d=1){return Number(n||0).toFixed(d)} function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} document.getElementById('theme-toggle').textContent=theme==='light'?'Dark theme':'Light theme'; if(chart){const light=theme==='light'; chart.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y1.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y1.grid.color='transparent'; chart.update('none');}} function fmt(n,d=1){return Number(n||0).toFixed(d)} function setTheme(theme){if(chart){const light=theme==='light'; chart.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y1.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y1.grid.color='transparent'; chart.update('none');}}
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark')} 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(e){console.warn('Fullscreen error:',e)}finally{updateFullscreenButton();}}
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}} async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
function makeChart(){chart=new Chart(document.getElementById('reportChart'),{type:'bar',data:{labels:[],datasets:[{type:'bar',label:'Avg total %',backgroundColor:'rgba(34,211,238,.55)',borderColor:'#22d3ee',data:[]},{type:'line',label:'Max total %',borderColor:'#f87171',backgroundColor:'rgba(248,113,113,.12)',tension:.18,borderWidth:3,data:[],yAxisID:'y'},{type:'line',label:'Warning+Critical events',borderColor:'#facc15',backgroundColor:'rgba(250,204,21,.10)',tension:.18,borderWidth:3,data:[],yAxisID:'y1'}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y1:{position:'right',grid:{color:'transparent'},ticks:{color:'#a1a1aa'}}}}}); setTheme(document.body.dataset.theme||'dark');} function makeChart(){chart=new Chart(document.getElementById('reportChart'),{type:'bar',data:{labels:[],datasets:[{type:'bar',label:'Avg total %',backgroundColor:'rgba(34,211,238,.55)',borderColor:'#22d3ee',data:[]},{type:'line',label:'Max total %',borderColor:'#f87171',backgroundColor:'rgba(248,113,113,.12)',tension:.18,borderWidth:3,data:[],yAxisID:'y'},{type:'line',label:'Warning+Critical events',borderColor:'#facc15',backgroundColor:'rgba(250,204,21,.10)',tension:.18,borderWidth:3,data:[],yAxisID:'y1'}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y1:{position:'right',grid:{color:'transparent'},ticks:{color:'#a1a1aa'}}}}}); setTheme(document.body.dataset.theme||'dark');}
async function refresh(){const r=await fetch('/api/reports/summary?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); reportCache=d; document.getElementById('report-range').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('health').textContent=d.health_score+'/100'; document.getElementById('health').className='value mono '+(d.health_score<70?'critical':d.health_score<85?'warning':'good'); document.getElementById('health-sub').textContent='Availability '+fmt(d.availability_pct,1)+'% stability '+String(d.stability||'--').toUpperCase(); document.getElementById('avg-peak').textContent=fmt(d.average_total_pct,1)+' / '+fmt(d.peak_total_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-peak-sub').textContent='Avg '+fmt(d.average_total_kn,1)+' '+(cfg.ui.unit_force||'kN')+' peak '+fmt(d.peak_total_kn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('avg-imb').textContent=fmt(d.average_imbalance_pct,1)+' / '+fmt(d.peak_imbalance_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-imb-sub').textContent='Δ force '+((d.force_delta_pct>=0)?'+':'')+fmt(d.force_delta_pct,1)+' • Δ imb '+((d.imbalance_delta_pct>=0)?'+':'')+fmt(d.imbalance_delta_pct,1); document.getElementById('events').textContent=d.warning_events+' / '+d.critical_events; document.getElementById('events-sub').textContent='Warnings / criticals • PLC disconnects '+d.plc_disconnects; document.getElementById('executive-summary').textContent=d.executive_summary||'--'; document.getElementById('summary-pill').textContent=String(d.stability||'stable').toUpperCase(); document.getElementById('summary-pill').className='pill '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); const findings=document.getElementById('findings'); findings.innerHTML=''; (d.findings||[]).forEach(item=>{const li=document.createElement('li'); li.textContent=item; findings.appendChild(li);}); if(!chart) makeChart(); chart.data.labels=(d.buckets||[]).map(b=>b.label); chart.data.datasets[0].data=(d.buckets||[]).map(b=>b.avg_total_pct); chart.data.datasets[1].data=(d.buckets||[]).map(b=>b.max_total_pct); chart.data.datasets[2].data=(d.buckets||[]).map(b=>(b.warning_events||0)+(b.critical_events||0)); chart.update('none'); const rows=(d.top_peaks||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td></tr>').join(''); document.getElementById('top-peaks-body').innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';} async function refresh(){const r=await fetch('/api/reports/summary?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); reportCache=d; document.getElementById('report-range').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('health').textContent=d.health_score+'/100'; document.getElementById('health').className='value mono '+(d.health_score<70?'critical':d.health_score<85?'warning':'good'); document.getElementById('health-sub').textContent='Availability '+fmt(d.availability_pct,1)+'% stability '+String(d.stability||'--').toUpperCase(); document.getElementById('avg-peak').textContent=fmt(d.average_total_pct,1)+' / '+fmt(d.peak_total_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-peak-sub').textContent='Avg '+fmt(d.average_total_kn,1)+' '+(cfg.ui.unit_force||'kN')+' peak '+fmt(d.peak_total_kn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('avg-imb').textContent=fmt(d.average_imbalance_pct,1)+' / '+fmt(d.peak_imbalance_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-imb-sub').textContent='Δ force '+((d.force_delta_pct>=0)?'+':'')+fmt(d.force_delta_pct,1)+' • Δ imb '+((d.imbalance_delta_pct>=0)?'+':'')+fmt(d.imbalance_delta_pct,1); document.getElementById('events').textContent=d.warning_events+' / '+d.critical_events; document.getElementById('events-sub').textContent='Warnings / criticals • PLC disconnects '+d.plc_disconnects; document.getElementById('executive-summary').textContent=d.executive_summary||'--'; document.getElementById('summary-pill').textContent=String(d.stability||'stable').toUpperCase(); document.getElementById('summary-pill').className='pill '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); const findings=document.getElementById('findings'); findings.innerHTML=''; (d.findings||[]).forEach(item=>{const li=document.createElement('li'); li.textContent=item; findings.appendChild(li);}); if(!chart) makeChart(); chart.data.labels=(d.buckets||[]).map(b=>b.label); chart.data.datasets[0].data=(d.buckets||[]).map(b=>b.avg_total_pct); chart.data.datasets[1].data=(d.buckets||[]).map(b=>b.max_total_pct); chart.data.datasets[2].data=(d.buckets||[]).map(b=>(b.warning_events||0)+(b.critical_events||0)); chart.update('none'); const rows=(d.top_peaks||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td></tr>').join(''); document.getElementById('top-peaks-body').innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)} function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
AppUI.initTheme({ onChange: ()=>{ if(chart) setTheme(document.body.dataset.theme || 'dark'); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); document.getElementById('download-json').addEventListener('click',()=>{ if(!reportCache) return; const blob=new Blob([JSON.stringify(reportCache,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='force-monitor-report-'+currentWindow+'.json'; a.click(); URL.revokeObjectURL(a.href);}); loadCfg().then(()=>refresh().catch(console.warn)); AppUI.initTheme({ onChange: ()=>{ if(chart) setTheme(document.body.dataset.theme || 'dark'); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); document.getElementById('download-json').addEventListener('click',()=>{ if(!reportCache) return; const blob=new Blob([JSON.stringify(reportCache,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='force-monitor-report-'+currentWindow+'.json'; a.click(); URL.revokeObjectURL(a.href);}); loadCfg().then(()=>refresh().catch(console.warn));
</script></body></html> </script></body></html>