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 — 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, '& ').replace(/< /g, '< ').replace(/>/g, '> ').replace(/"/g, '" ').replace(/'/g, '' ');
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' });
AppUI.updateFullscreenButton('fullscreen-toggle');
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 >
2026-04-22 14:16:27 +00:00
< / html >