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

547 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — History & Analytics</title>
<script src="/static/chart.umd.min.js"></script>
<style>
:root {
--bg1:#050816; --bg2:#0b1224; --panel:rgba(255,255,255,0.055); --border:rgba(255,255,255,0.10);
--text:#f4f4f5; --muted:#a1a1aa; --muted2:#71717a; --btnbg:rgba(255,255,255,0.05); --shadow:0 16px 36px rgba(0,0,0,0.18);
--tableHover:rgba(255,255,255,0.04); --good:#34d399; --warn:#facc15; --bad:#f87171; --info:#93c5fd;
}
body[data-theme="light"] {
--bg1:#eef4ff; --bg2:#f8fafc; --panel:rgba(255,255,255,0.84); --border:rgba(15,23,42,0.10);
--text:#0f172a; --muted:#475569; --muted2:#64748b; --btnbg:rgba(255,255,255,0.88); --shadow:0 16px 36px rgba(15,23,42,0.08);
--tableHover:rgba(15,23,42,0.04); --good:#059669; --warn:#b45309; --bad:#dc2626; --info:#1d4ed8;
}
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; color:var(--text); 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%);
}
body[data-theme="light"] {
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%);
}
.wrap { width:min(95vw, 1760px); margin:0 auto; padding:24px; }
.glass { background:var(--panel); border:1px solid var(--border); border-radius:24px; backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); box-shadow:var(--shadow); }
.nav, .toolbar, .row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.spacer { flex:1 1 auto; }
.btn, .input, select, .checkline {
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
padding:10px 14px; font:inherit;
}
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
.btn:hover { transform:translateY(-1px); }
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
.btn.good { background:rgba(16,185,129,0.14); border-color:rgba(16,185,129,0.35); }
.btn.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
.btn.active { outline:2px solid rgba(14,165,233,0.32); }
.input { width:100%; }
.page-card { padding:20px 22px; margin-bottom:18px; }
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.title { margin:8px 0 0 0; font-size:42px; line-height:1.02; }
.status-line { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:14px; margin-top:8px; }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
.card { padding:18px 20px; }
.metric-value { font-size:34px; font-weight:800; margin-top:8px; line-height:1; }
.metric-sub { color:var(--muted); margin-top:10px; font-size:14px; line-height:1.4; }
.ok { color:var(--good); }
.warning { color:var(--warn); }
.critical { color:var(--bad); }
.neutral { color:var(--muted); }
.chart-shell { padding:18px 20px 12px; }
.chart-box { height:58vh; min-height:460px; max-height:820px; }
.legend-checks { display:flex; flex-wrap:wrap; gap:10px; }
.checkline { display:inline-flex; align-items:center; gap:8px; min-height:auto; padding:9px 12px; }
.checkline input { accent-color:#38bdf8; }
.table-wrap { overflow:auto; }
table { width:100%; border-collapse:collapse; }
th, td { padding:12px 10px; text-align:left; border-bottom:1px solid var(--border); vertical-align:top; }
th { color:var(--muted2); font-size:12px; text-transform:uppercase; letter-spacing:.16em; }
tbody tr:hover { background:var(--tableHover); }
.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:.04em; border:1px solid transparent;
}
.pill.good { background:rgba(16,185,129,0.12); color:var(--good); border-color:rgba(16,185,129,0.24); }
.pill.warn { background:rgba(245,158,11,0.12); color:var(--warn); border-color:rgba(245,158,11,0.24); }
.pill.bad { background:rgba(239,68,68,0.12); color:var(--bad); border-color:rgba(239,68,68,0.24); }
.pill.info { background:rgba(59,130,246,0.12); color:var(--info); border-color:rgba(59,130,246,0.24); }
.banner {
display:none; margin-bottom:16px; padding:14px 18px; border-radius:18px;
background:rgba(239,68,68,0.14); border:1px solid rgba(239,68,68,0.28); color:#fecaca;
}
.banner.show { display:block; }
.section-title { font-size:26px; margin:0; }
.note { color:var(--muted); font-size:13px; }
@media (max-width: 900px) {
.wrap { width:min(97vw, 1760px); padding:16px; }
.title { font-size:34px; }
.metric-value { font-size:28px; }
.chart-box { min-height:360px; height:46vh; }
}
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<a class="btn" href="/">Dashboard</a>
<a class="btn primary" href="/history">History</a>
<a class="btn" href="/alarms">Alarms</a>
<a class="btn" 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-toggle" class="btn" type="button">Enter fullscreen</button>
</div>
<div class="glass page-card">
<div class="row">
<div>
<div class="kicker" id="page-kicker">Force Monitor</div>
<h1 class="title">Engineering History & Executive Analytics</h1>
<div class="sub">Longer-window peak force analytics, imbalance risk, percentile-based engineering metrics, alarm density, previous-window comparison, and top-event tables for engineering and management.</div>
</div>
<div class="spacer"></div>
<div class="status-line">
<span id="fetch-status">Status: idle</span>
<span id="last-refresh">Last refresh: --</span>
<span id="current-window">Window: --</span>
</div>
</div>
</div>
<div id="license-warning" class="banner"></div>
<div class="glass page-card">
<div class="toolbar" style="justify-content:space-between; align-items:flex-start;">
<div class="toolbar" style="align-items:center;">
<button class="btn active window-btn" data-window="15m">15m</button>
<button class="btn window-btn" data-window="1h">1h</button>
<button class="btn window-btn" data-window="8h">8h</button>
<button class="btn window-btn" data-window="24h">24h</button>
<button class="btn window-btn" data-window="7d">7d</button>
<input id="custom-window" class="input" style="width:140px" placeholder="e.g. 90m or 3h">
<button id="apply-window" class="btn primary" type="button">Apply</button>
</div>
<div class="toolbar">
<label class="checkline"><input id="auto-refresh" type="checkbox" checked> Auto refresh</label>
<button id="refresh-btn" class="btn good" type="button">Refresh now</button>
<button id="export-csv" class="btn warn" type="button">Export current CSV</button>
</div>
</div>
<div class="toolbar" style="margin-top:14px; justify-content:space-between; align-items:flex-start;">
<div class="legend-checks">
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="left" checked> Left %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="right" checked> Right %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="total" checked> Total %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="imbalance" checked> Imbalance %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="warning" checked> Warning line</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="critical" checked> Critical line</label>
</div>
<div class="note" id="limit-note">Thresholds loading…</div>
</div>
</div>
<div class="grid cards" style="margin-bottom:18px;">
<div class="glass card"><div class="kicker">Avg total peak</div><div id="metric-avg-total" class="metric-value mono">--</div><div id="metric-avg-total-sub" class="metric-sub">Window average</div></div>
<div class="glass card"><div class="kicker">Max total peak</div><div id="metric-max-total" class="metric-value mono">--</div><div id="metric-max-total-sub" class="metric-sub">Highest total in window</div></div>
<div class="glass card"><div class="kicker">P95 / P99 total</div><div id="metric-p95-p99" class="metric-value mono">--</div><div id="metric-p95-p99-sub" class="metric-sub">Engineering percentiles</div></div>
<div class="glass card"><div class="kicker">Avg imbalance</div><div id="metric-avg-imb" class="metric-value mono">--</div><div id="metric-avg-imb-sub" class="metric-sub">Process centering quality</div></div>
<div class="glass card"><div class="kicker">Critical sample rate</div><div id="metric-critical-rate" class="metric-value mono">--</div><div id="metric-critical-rate-sub" class="metric-sub">% of samples in critical zone</div></div>
<div class="glass card"><div class="kicker">Alarm transitions</div><div id="metric-alarm-count" class="metric-value mono">--</div><div id="metric-alarm-count-sub" class="metric-sub">Window event density</div></div>
<div class="glass card"><div class="kicker">Vs previous window</div><div id="metric-prev-delta" class="metric-value mono">--</div><div id="metric-prev-delta-sub" class="metric-sub">Average total comparison</div></div>
<div class="glass card"><div class="kicker">Stability verdict</div><div id="metric-stability" class="metric-value mono">--</div><div id="metric-stability-sub" class="metric-sub">Analytics interpretation</div></div>
</div>
<div class="glass chart-shell" style="margin-bottom:18px;">
<div class="row" style="justify-content:space-between; margin-bottom:14px;">
<div>
<h2 class="section-title">Expanded Trend View</h2>
<div class="sub">Overlay left, right, total, and imbalance across the selected window. Imbalance uses the right axis so engineering can see centering drift without losing total-force detail.</div>
</div>
<div class="note">Refresh cadence follows the current page only. Open the dashboard for operator live view.</div>
</div>
<div class="chart-box"><canvas id="historyChart"></canvas></div>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); margin-bottom:18px;">
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:8px;">
<h2 class="section-title">Top total peaks</h2>
<span class="pill bad">stress points</span>
</div>
<div class="sub">Highest total peaks in the selected window. This helps engineering review overload clusters and lets management see the true worst-case demand.</div>
<div class="table-wrap" style="margin-top:14px;">
<table>
<thead><tr><th>Time</th><th class="right">Total %</th><th class="right">Total kN</th><th class="right">L %</th><th class="right">R %</th><th class="right">Imb %</th></tr></thead>
<tbody id="top-peaks-body"><tr><td colspan="6" class="note">No data</td></tr></tbody>
</table>
</div>
</div>
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:8px;">
<h2 class="section-title">Worst imbalances</h2>
<span class="pill warn">centering risk</span>
</div>
<div class="sub">Largest left-right differences in the selected window. This is ideal for die setup review, mechanical alignment checks, and boss-level trend summaries.</div>
<div class="table-wrap" style="margin-top:14px;">
<table>
<thead><tr><th>Time</th><th class="right">Imb %</th><th class="right">Total %</th><th class="right">L %</th><th class="right">R %</th><th class="right">Total kN</th></tr></thead>
<tbody id="worst-imb-body"><tr><td colspan="6" class="note">No data</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<h2 class="section-title">Executive interpretation</h2>
<span id="boss-pill" class="pill info">loading</span>
</div>
<div id="boss-summary" class="sub">Loading analytics…</div>
</div>
</div>
<script src="/static/app-common.js"></script>
<script>
let config = { ui:{ title:'Force Monitor', unit_force:'kN' }, thresholds:{ warning_percent:80, critical_percent:95, gauge_max_percent:130, imbalance_warning_percent:10, imbalance_critical_percent:20 } };
let currentWindow = '15m';
let chart = null;
let historyData = [];
let analyticsData = null;
let trendData = null;
let refreshTimer = null;
let busy = false;
let currentTheme = 'dark';
const fmt1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 });
const fmt2 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 });
const percent = v => fmt1.format(Number(v || 0)) + '%';
const kn = v => fmt1.format(Number(v || 0)) + ' ' + ((config && config.ui && config.ui.unit_force) || 'kN');
const nowTime = () => new Date().toLocaleTimeString();
const escapeHtml = (value) => String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
function isLightTheme() { return currentTheme === 'light'; }
function qs(id) { return document.getElementById(id); }
function applyTheme(theme) {
currentTheme = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', currentTheme);
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (_) {}
qs('theme-toggle').textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
updateChartTheme();
}
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 (_) {}
applyTheme(theme);
}
function setFetchStatus(text) { qs('fetch-status').textContent = 'Status: ' + text; }
function setWarning(msg) {
const el = qs('license-warning');
if (!msg) { el.classList.remove('show'); el.textContent = ''; return; }
el.textContent = msg; el.classList.add('show');
}
async function fetchJson(url) {
const res = await fetch(url, { cache: 'no-store' });
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok) {
const msg = data && data.error ? data.error : ('HTTP ' + res.status);
const err = new Error(msg);
err.status = res.status;
err.payload = data;
throw err;
}
return data;
}
async function loadConfig() {
config = await fetchJson('/api/config/public');
document.title = ((config.ui && config.ui.title) || 'Force Monitor') + ' — History & Analytics';
qs('page-kicker').textContent = (config.ui && config.ui.title) || 'Force Monitor';
qs('limit-note').textContent = `Force W ${fmt1.format(config.thresholds.warning_percent)} / C ${fmt1.format(config.thresholds.critical_percent)} • Imbalance W ${fmt1.format(config.thresholds.imbalance_warning_percent)} / C ${fmt1.format(config.thresholds.imbalance_critical_percent)}`;
}
function buildChart() {
const ctx = qs('historyChart');
if (!ctx) return;
chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{ label:'Left %', data:[], borderColor:'#22d3ee', backgroundColor:'rgba(34,211,238,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Right %', data:[], borderColor:'#c084fc', backgroundColor:'rgba(192,132,252,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Total %', data:[], borderColor:'#34d399', backgroundColor:'rgba(52,211,153,0.10)', borderWidth:3, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Imbalance %', data:[], borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y1' },
{ label:'Warning limit', data:[], borderColor:'rgba(245,158,11,0.95)', borderWidth:2, pointRadius:0, borderDash:[8,6], tension:0, yAxisID:'y' },
{ label:'Critical limit', data:[], borderColor:'rgba(239,68,68,0.95)', borderWidth:2, pointRadius:0, borderDash:[8,6], tension:0, yAxisID:'y' }
]
},
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:{ beginAtZero:true, suggestedMax:130, grid:{ color:'rgba(255,255,255,0.06)' }, ticks:{ color:'#a1a1aa' }, title:{ display:true, text:'Force %' } },
y1:{ beginAtZero:true, suggestedMax:30, position:'right', grid:{ drawOnChartArea:false }, ticks:{ color:'#f59e0b' }, title:{ display:true, text:'Imbalance %' } }
},
plugins:{
legend:{ position:'top', labels:{ color:'#f4f4f5' } },
tooltip:{ backgroundColor:'rgba(9,9,11,0.96)', titleColor:'#f4f4f5', bodyColor:'#f4f4f5' }
}
}
});
updateChartTheme();
document.querySelectorAll('.series-toggle').forEach(input => input.addEventListener('change', syncSeriesVisibility));
}
function updateChartTheme() {
if (!chart) 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';
chart.options.scales.x.grid.color = grid;
chart.options.scales.x.ticks.color = tick;
chart.options.scales.y.grid.color = grid;
chart.options.scales.y.ticks.color = tick;
chart.options.scales.y1.ticks.color = light ? '#b45309' : '#f59e0b';
chart.options.plugins.legend.labels.color = legend;
chart.options.plugins.tooltip.backgroundColor = tooltipBg;
chart.options.plugins.tooltip.titleColor = tooltipText;
chart.options.plugins.tooltip.bodyColor = tooltipText;
chart.update('none');
}
function syncSeriesVisibility() {
if (!chart) return;
const mapping = { left:0, right:1, total:2, imbalance:3, warning:4, critical:5 };
document.querySelectorAll('.series-toggle').forEach(input => {
const idx = mapping[input.dataset.series];
if (typeof idx === 'number') chart.setDatasetVisibility(idx, input.checked);
});
chart.update('none');
}
function severityClass(rate) {
if (rate >= 10) return 'critical';
if (rate >= 2) return 'warning';
return 'ok';
}
function setMetric(id, value, sub, cls) {
const valueEl = qs(id);
const subEl = qs(id + '-sub');
if (!valueEl) return;
valueEl.className = 'metric-value mono ' + (cls || '');
valueEl.textContent = value;
if (subEl) subEl.textContent = sub || '';
}
function renderAnalytics() {
if (!analyticsData) return;
const a = analyticsData;
const t = trendData || {};
setMetric('metric-avg-total', percent(a.total_avg_pct), `Average total peak • ${kn(a.total_avg_kn)}`, a.total_avg_pct >= config.thresholds.warning_percent ? 'warning' : 'ok');
setMetric('metric-max-total', percent(a.total_max_pct), `Maximum total • ${kn(a.total_max_kn)}`, a.total_max_pct >= config.thresholds.critical_percent ? 'critical' : (a.total_max_pct >= config.thresholds.warning_percent ? 'warning' : 'ok'));
setMetric('metric-p95-p99', `${fmt1.format(a.total_p95_pct)} / ${fmt1.format(a.total_p99_pct)}`, 'P95 / P99 total %', a.total_p99_pct >= config.thresholds.critical_percent ? 'critical' : 'ok');
setMetric('metric-avg-imb', percent(a.imbalance_avg_pct), `P95 imbalance ${fmt1.format(a.imbalance_p95_pct)}% • Max ${fmt1.format(a.imbalance_max_pct)}%`, a.imbalance_avg_pct >= config.thresholds.imbalance_warning_percent ? 'warning' : 'ok');
setMetric('metric-critical-rate', fmt2.format(a.critical_rate_pct) + '%', `${a.critical_samples} critical samples • ${a.warning_samples} warning samples`, severityClass(a.critical_rate_pct));
setMetric('metric-alarm-count', String(a.alarm_transitions), `${a.critical_events} critical events • ${a.plc_disconnects} PLC disconnects`, a.critical_events > 0 ? 'warning' : 'ok');
const deltaCls = a.previous_window_delta_pct >= 5 ? 'critical' : (Math.abs(a.previous_window_delta_pct) >= 2 ? 'warning' : 'ok');
setMetric('metric-prev-delta', (a.previous_window_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_window_delta_pct) + '%', `Imbalance vs previous ${(a.previous_imbalance_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_imbalance_delta_pct)}%`, deltaCls);
const stability = String(t.process_stability || 'insufficient_data').toUpperCase();
const stabilityCls = t.process_stability === 'unstable' ? 'critical' : (t.process_stability === 'caution' ? 'warning' : 'ok');
setMetric('metric-stability', stability, t.stability_reason || 'No interpretation', stabilityCls);
qs('current-window').textContent = 'Window: ' + a.window;
renderTable('top-peaks-body', a.top_peaks || [], 'peaks');
renderTable('worst-imb-body', a.worst_imbalances || [], 'imb');
renderBossSummary();
}
function renderTable(targetId, rows, mode) {
const body = qs(targetId);
if (!body) return;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="6" class="note">No data in selected window</td></tr>';
return;
}
body.innerHTML = rows.map(row => {
if (mode === 'peaks') {
return `<tr><td class="mono">${escapeHtml(row.time)}</td><td class="right mono">${fmt1.format(row.total_percent)}%</td><td class="right mono">${fmt1.format(row.total_kn)}</td><td class="right mono">${fmt1.format(row.left_percent)}%</td><td class="right mono">${fmt1.format(row.right_percent)}%</td><td class="right mono">${fmt1.format(row.imbalance_percent)}%</td></tr>`;
}
return `<tr><td class="mono">${escapeHtml(row.time)}</td><td class="right mono">${fmt1.format(row.imbalance_percent)}%</td><td class="right mono">${fmt1.format(row.total_percent)}%</td><td class="right mono">${fmt1.format(row.left_percent)}%</td><td class="right mono">${fmt1.format(row.right_percent)}%</td><td class="right mono">${fmt1.format(row.total_kn)}</td></tr>`;
}).join('');
}
function renderBossSummary() {
if (!analyticsData) return;
const a = analyticsData;
const bossPill = qs('boss-pill');
const bossSummary = qs('boss-summary');
let headline = 'Stable';
let cls = 'good';
const parts = [];
if (a.total_max_pct >= config.thresholds.critical_percent || a.critical_events > 0 || a.plc_disconnects > 0) {
headline = 'Attention required'; cls = 'bad';
} else if (a.total_max_pct >= config.thresholds.warning_percent || a.imbalance_avg_pct >= config.thresholds.imbalance_warning_percent || Math.abs(a.previous_window_delta_pct) >= 2) {
headline = 'Watch closely'; cls = 'warn';
}
bossPill.className = 'pill ' + cls;
bossPill.textContent = headline;
parts.push(`In the selected ${a.window} window, the average total peak was ${fmt1.format(a.total_avg_pct)}% and the maximum reached ${fmt1.format(a.total_max_pct)}% (${kn(a.total_max_kn)}).`);
parts.push(`Critical-zone exposure was ${fmt2.format(a.critical_rate_pct)}% of samples, with ${a.critical_events} critical alarm transitions and ${a.plc_disconnects} PLC disconnect event(s).`);
parts.push(`Average imbalance was ${fmt1.format(a.imbalance_avg_pct)}%, worst imbalance was ${fmt1.format(a.imbalance_max_pct)}%, and the window-to-window change in average total was ${(a.previous_window_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_window_delta_pct)}%.`);
if (trendData && trendData.process_stability) {
parts.push(`Trend interpretation reports ${String(trendData.process_stability).toUpperCase()}${trendData.stability_reason || 'no extra reason provided'}.`);
}
bossSummary.textContent = parts.join(' ');
}
function renderChart() {
if (!chart) return;
const labels = historyData.map(p => p.time);
const left = historyData.map(p => Number(p.sila_l || 0));
const right = historyData.map(p => Number(p.sila_r || 0));
const total = historyData.map(p => (Number(p.sila_l || 0) + Number(p.sila_r || 0)) / 2);
const imbalance = historyData.map(p => Math.abs(Number(p.sila_l || 0) - Number(p.sila_r || 0)));
const warning = labels.map(() => Number(config.thresholds.warning_percent || 0));
const critical = labels.map(() => Number(config.thresholds.critical_percent || 0));
chart.options.scales.y.suggestedMax = Math.max(Number(config.thresholds.gauge_max_percent || 130), 130);
chart.options.scales.y1.suggestedMax = Math.max(Number(config.thresholds.imbalance_critical_percent || 20) * 1.4, 30);
chart.data.labels = labels;
chart.data.datasets[0].data = left;
chart.data.datasets[1].data = right;
chart.data.datasets[2].data = total;
chart.data.datasets[3].data = imbalance;
chart.data.datasets[4].data = warning;
chart.data.datasets[5].data = critical;
chart.update('none');
syncSeriesVisibility();
}
async function refreshAll() {
if (busy) return;
busy = true;
setFetchStatus('loading…');
try {
const [history, analytics, trend] = await Promise.all([
fetchJson('/api/history?window=' + encodeURIComponent(currentWindow)),
fetchJson('/api/history/analytics?window=' + encodeURIComponent(currentWindow)),
fetchJson('/api/trend?window=' + encodeURIComponent(currentWindow))
]);
setWarning('');
historyData = Array.isArray(history.points) ? history.points : [];
analyticsData = analytics;
trendData = trend;
renderChart();
renderAnalytics();
setFetchStatus('ready');
qs('last-refresh').textContent = 'Last refresh: ' + nowTime();
} catch (err) {
console.warn('history page refresh failed', err);
if (err.status === 403) {
const msg = err.payload && err.payload.license && err.payload.license.message ? err.payload.license.message : err.message;
setWarning('License required or expired: ' + msg);
} else {
setWarning('Unable to load analytics: ' + err.message);
}
setFetchStatus('error');
} finally {
busy = false;
}
}
function setActiveWindowButtons() {
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.window === currentWindow));
}
function exportCsv() {
if (!historyData.length) return;
const rows = [['time','left_percent','right_percent','total_percent','imbalance_percent']];
for (const p of historyData) {
const left = Number(p.sila_l || 0);
const right = Number(p.sila_r || 0);
rows.push([p.time, left.toFixed(3), right.toFixed(3), ((left + right) / 2).toFixed(3), Math.abs(left - right).toFixed(3)]);
}
const csv = rows.map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type:'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `force-monitor-history-${currentWindow}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function scheduleRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
if (!qs('auto-refresh').checked) return;
refreshTimer = setInterval(refreshAll, 5000);
}
function wireEvents() {
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
updateFullscreenButton();
qs('refresh-btn').addEventListener('click', refreshAll);
qs('export-csv').addEventListener('click', exportCsv);
qs('apply-window').addEventListener('click', () => {
const value = qs('custom-window').value.trim();
if (!value) return;
currentWindow = value;
setActiveWindowButtons();
refreshAll();
});
document.querySelectorAll('.window-btn').forEach(btn => btn.addEventListener('click', () => {
currentWindow = btn.dataset.window;
setActiveWindowButtons();
refreshAll();
}));
qs('auto-refresh').addEventListener('change', scheduleRefresh);
}
(async function init() {
// theme initialized by AppUI
wireEvents();
buildChart();
try { await loadConfig(); } catch (err) { setWarning('Failed to load public config: ' + err.message); }
setActiveWindowButtons();
await refreshAll();
scheduleRefresh();
})();
</script>
</body>
</html>