added pages for history, licence,alarms

This commit is contained in:
Dejan Rožič 2026-04-21 12:34:48 +02:00
parent 3891d9b61d
commit 0ce398fbda
4 changed files with 1253 additions and 1 deletions

374
static/alarms.html Normal file
View file

@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — Alarms</title>
<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);
}
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);
}
* { 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(94vw, 1680px); 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 { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.btn, .input, select {
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); }
.input { width:100%; }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
.card { padding:18px 20px; }
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
.value { font-size:34px; font-weight:800; margin-top:8px; }
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
.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.info { background:rgba(59,130,246,0.12); color:#93c5fd; border-color:rgba(59,130,246,0.24); }
.pill.warning { background:rgba(245,158,11,0.12); color:#fde68a; border-color:rgba(245,158,11,0.24); }
.pill.critical { background:rgba(239,68,68,0.12); color:#fca5a5; border-color:rgba(239,68,68,0.24); }
.pill.clear { background:rgba(113,113,122,0.12); color:#d4d4d8; border-color:rgba(113,113,122,0.24); }
body[data-theme="light"] .pill.info { color:#1d4ed8; }
body[data-theme="light"] .pill.warning { color:#b45309; }
body[data-theme="light"] .pill.critical { color:#dc2626; }
body[data-theme="light"] .pill.clear { color:#52525b; }
.status-line { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:14px; margin-top:8px; }
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); }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.right { text-align:right; }
.toolbar-wrap { padding:18px 20px; margin-bottom:18px; }
.table-wrap { padding:0 0 6px 0; overflow:auto; }
.error, .empty, .hint { color:var(--muted); }
.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; }
.row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.spacer { flex:1 1 auto; }
@media (max-width: 900px) {
.wrap { width:min(96vw, 1680px); padding:16px; }
.value { font-size:28px; }
th:nth-child(5), td:nth-child(5), th:nth-child(6), td:nth-child(6) { display:none; }
}
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<a class="btn" href="/">Dashboard</a>
<a class="btn" href="/history">History</a>
<a class="btn primary" href="/alarms">Alarms</a>
<a class="btn" href="/license">License</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</button>
</div>
<div class="glass card" style="margin-bottom:18px;">
<div class="row">
<div>
<div class="kicker">Force Monitor</div>
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">Alarm Timeline</h1>
<div class="sub">Advanced event view with filters, summary cards, active-only mode, CSV export, and auto-refresh.</div>
</div>
<div class="spacer"></div>
<div class="status-line">
<span id="fetch-status">Status: idle</span>
<span id="last-refresh">Last refresh: --</span>
</div>
</div>
</div>
<div id="license-warning" class="banner"></div>
<div class="grid cards">
<div class="glass card"><div class="kicker">Loaded events</div><div id="metric-total" class="value mono">0</div><div id="metric-total-sub" class="sub">Current filtered set</div></div>
<div class="glass card"><div class="kicker">Active alarms</div><div id="metric-active" class="value mono">0</div><div class="sub">State = active</div></div>
<div class="glass card"><div class="kicker">Critical</div><div id="metric-critical" class="value mono">0</div><div class="sub">Severity critical</div></div>
<div class="glass card"><div class="kicker">Warning</div><div id="metric-warning" class="value mono">0</div><div class="sub">Severity warning</div></div>
</div>
<div class="glass toolbar-wrap">
<div class="toolbar">
<select id="limit-select" title="Fetch limit">
<option value="20">20 rows</option>
<option value="50" selected>50 rows</option>
<option value="100">100 rows</option>
</select>
<select id="severity-filter" title="Severity filter">
<option value="all">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select id="source-filter" title="Source filter">
<option value="all">All sources</option>
<option value="plc">PLC</option>
<option value="force_left">Left force</option>
<option value="force_right">Right force</option>
<option value="imbalance">Imbalance</option>
</select>
<select id="state-filter" title="State filter">
<option value="all">All states</option>
<option value="active">Active only</option>
<option value="clear">Clear only</option>
<option value="info">Info only</option>
</select>
<input id="search-input" class="input" style="max-width:320px;" type="text" placeholder="Search source, message, time...">
<label class="btn warn" style="gap:8px;"><input id="auto-refresh" type="checkbox" checked> Auto refresh</label>
<button id="refresh-btn" class="btn primary" type="button">Refresh now</button>
<button id="export-btn" class="btn good" type="button">Export CSV</button>
</div>
<div class="status-line" style="margin-top:12px;">
<span>Tip: “active only” helps operators see what still matters right now.</span>
</div>
</div>
<div class="glass table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Severity</th>
<th>Source</th>
<th>State</th>
<th>Event</th>
<th class="right">Value</th>
<th class="right">Limit</th>
</tr>
</thead>
<tbody id="alarm-body">
<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">Loading alarms...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
let allEvents = [];
let refreshTimer = null;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function setTheme(theme) {
const t = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', t);
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') theme = stored;
} catch (e) {}
setTheme(theme);
}
function formatPct(value) {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(1) + '%' : '--';
}
function formatSource(source) {
return ({ force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' })[source] || String(source || '').toUpperCase();
}
function severityPill(severity, state) {
const sev = String(severity || 'info').toLowerCase();
const klass = state === 'clear' ? 'clear' : sev;
const label = state === 'clear' ? 'CLEAR' : sev.toUpperCase();
return '<span class="pill ' + escapeHtml(klass) + '">' + escapeHtml(label) + '</span>';
}
function setMetrics(events) {
const active = events.filter(e => String(e.state || '').toLowerCase() === 'active').length;
const critical = events.filter(e => String(e.severity || '').toLowerCase() === 'critical').length;
const warning = events.filter(e => String(e.severity || '').toLowerCase() === 'warning').length;
document.getElementById('metric-total').textContent = String(events.length);
document.getElementById('metric-active').textContent = String(active);
document.getElementById('metric-critical').textContent = String(critical);
document.getElementById('metric-warning').textContent = String(warning);
document.getElementById('metric-total-sub').textContent = events.length === allEvents.length ? 'Current fetched set' : 'Filtered view';
}
function renderTable(events) {
const body = document.getElementById('alarm-body');
if (!body) return;
if (!events.length) {
body.innerHTML = '<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">No events match the current filters</td></tr>';
setMetrics(events);
return;
}
let html = '';
for (const ev of events) {
const state = String(ev.state || '').toLowerCase();
const value = ev.source === 'plc' ? '--' : formatPct(ev.value);
const limit = Number(ev.limit) > 0 ? formatPct(ev.limit) : '--';
html += '<tr>' +
'<td class="mono">' + escapeHtml(ev.time || '--') + '</td>' +
'<td>' + severityPill(ev.severity, state) + '</td>' +
'<td style="font-weight:700;">' + escapeHtml(formatSource(ev.source)) + '</td>' +
'<td class="mono">' + escapeHtml(state || '--') + '</td>' +
'<td>' + escapeHtml(ev.message || '--') + '</td>' +
'<td class="right mono">' + escapeHtml(value) + '</td>' +
'<td class="right mono">' + escapeHtml(limit) + '</td>' +
'</tr>';
}
body.innerHTML = html;
setMetrics(events);
}
function getFilters() {
return {
severity: document.getElementById('severity-filter').value,
source: document.getElementById('source-filter').value,
state: document.getElementById('state-filter').value,
search: document.getElementById('search-input').value.trim().toLowerCase()
};
}
function applyFilters() {
const f = getFilters();
const out = allEvents.filter(ev => {
const sev = String(ev.severity || '').toLowerCase();
const src = String(ev.source || '').toLowerCase();
const state = String(ev.state || '').toLowerCase();
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
if (f.severity !== 'all' && sev !== f.severity) return false;
if (f.source !== 'all' && src !== f.source) return false;
if (f.state !== 'all' && state !== f.state) return false;
if (f.search && !hay.includes(f.search)) return false;
return true;
});
renderTable(out);
}
function updateBanner(msg, show) {
const el = document.getElementById('license-warning');
if (!el) return;
el.textContent = msg || '';
el.classList.toggle('show', !!show);
}
async function fetchAlarms() {
const limit = document.getElementById('limit-select').value || '50';
document.getElementById('fetch-status').textContent = 'Status: loading...';
try {
const res = await fetch('/api/alarms?limit=' + encodeURIComponent(limit), { cache: 'no-store' });
if (res.status === 403) {
const data = await res.json().catch(() => ({}));
const message = data && data.error ? data.error : 'license required';
allEvents = [];
applyFilters();
updateBanner('Alarm API is locked: ' + message + '. Open /license to activate the app.', true);
document.getElementById('fetch-status').textContent = 'Status: license locked';
return;
}
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
allEvents = Array.isArray(data.events) ? data.events : [];
applyFilters();
updateBanner('', false);
document.getElementById('fetch-status').textContent = 'Status: OK';
document.getElementById('last-refresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
} catch (err) {
console.warn('Alarm fetch error:', err);
allEvents = [];
applyFilters();
updateBanner('Could not load alarms. Check app connectivity and browser console.', true);
document.getElementById('fetch-status').textContent = 'Status: error';
}
}
function exportCSV() {
const rows = [['time','severity','source','state','message','value','limit']];
const events = allEvents.filter(ev => {
const f = getFilters();
const sev = String(ev.severity || '').toLowerCase();
const src = String(ev.source || '').toLowerCase();
const state = String(ev.state || '').toLowerCase();
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
if (f.severity !== 'all' && sev !== f.severity) return false;
if (f.source !== 'all' && src !== f.source) return false;
if (f.state !== 'all' && state !== f.state) return false;
if (f.search && !hay.includes(f.search)) return false;
return true;
});
for (const ev of events) {
rows.push([ev.time || '', ev.severity || '', ev.source || '', ev.state || '', ev.message || '', ev.value ?? '', ev.limit ?? '']);
}
const csv = rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\r\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-alarms-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function syncAutoRefresh() {
const enabled = document.getElementById('auto-refresh').checked;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (enabled) refreshTimer = setInterval(fetchAlarms, 3000);
}
window.addEventListener('DOMContentLoaded', () => {
initTheme();
document.getElementById('theme-toggle').addEventListener('click', () => setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light'));
document.getElementById('refresh-btn').addEventListener('click', fetchAlarms);
document.getElementById('export-btn').addEventListener('click', exportCSV);
document.getElementById('auto-refresh').addEventListener('change', syncAutoRefresh);
['severity-filter','source-filter','state-filter','search-input','limit-select'].forEach(id => {
document.getElementById(id).addEventListener(id === 'search-input' ? 'input' : 'change', () => {
if (id === 'limit-select') fetchAlarms(); else applyFilters();
});
});
fetchAlarms();
syncAutoRefresh();
});
</script>
</body>
</html>

539
static/history.html Normal file
View file

@ -0,0 +1,539 @@
<!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="/license">License</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</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>
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() {
qs('theme-toggle').addEventListener('click', () => applyTheme(isLightTheme() ? 'dark' : 'light'));
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() {
initTheme();
wireEvents();
buildChart();
try { await loadConfig(); } catch (err) { setWarning('Failed to load public config: ' + err.message); }
setActiveWindowButtons();
await refreshAll();
scheduleRefresh();
})();
</script>
</body>
</html>

View file

@ -278,10 +278,15 @@
transition: opacity 180ms ease, filter 180ms ease; transition: opacity 180ms ease, filter 180ms ease;
} }
</style> </style>
<base target="_blank">
</head> </head>
<body data-theme="dark"> <body data-theme="dark">
<div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen"> <div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
<div class="flex flex-wrap items-center gap-3 mb-6">
<a href="/" class="control-btn">Dashboard</a>
<a href="/history" class="control-btn">History</a>
<a href="/alarms" class="control-btn">Alarms</a>
<a href="/license" class="control-btn">License</a>
</div>
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium"> <div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-2xl">&#9888;&#65039;</span> <span class="text-2xl">&#9888;&#65039;</span>

334
static/license.html Normal file
View file

@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — License</title>
<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);
--ok:#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);
--ok:#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(94vw, 1560px); 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, .row, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.btn, .input, textarea {
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.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); }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
.card { padding:18px 20px; }
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
.value { font-size:30px; font-weight:800; margin-top:8px; }
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
textarea { width:100%; min-height:210px; resize:vertical; }
.two { grid-template-columns:1.1fr .9fr; }
pre {
margin:0; padding:16px; border-radius:18px; border:1px solid var(--border); background:rgba(2,6,23,0.35);
color:var(--text); white-space:pre-wrap; word-break:break-word; min-height:210px; overflow:auto;
}
body[data-theme="light"] pre { background:rgba(248,250,252,0.96); }
.badge { display:inline-flex; align-items:center; gap:8px; border-radius:999px; padding:6px 12px; font-weight:700; border:1px solid var(--border); }
.ok { color:var(--ok); }
.warnc { color:var(--warn); }
.bad { color:var(--bad); }
.info { color:var(--info); }
.field { margin-top:12px; }
.label { color:var(--muted2); font-size:12px; letter-spacing:.14em; text-transform:uppercase; margin-bottom:6px; }
.details { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.detail-box { padding:14px 16px; border-radius:18px; border:1px solid var(--border); background:rgba(255,255,255,0.03); }
body[data-theme="light"] .detail-box { background:rgba(255,255,255,0.68); }
.message { margin-top:12px; padding:14px 16px; border-radius:16px; display:none; }
.message.show { display:block; }
.message.good { background:rgba(16,185,129,0.12); border:1px solid rgba(16,185,129,0.24); color:#bbf7d0; }
.message.bad { background:rgba(239,68,68,0.12); border:1px solid rgba(239,68,68,0.24); color:#fecaca; }
body[data-theme="light"] .message.good { color:#166534; }
body[data-theme="light"] .message.bad { color:#991b1b; }
.spacer { flex:1 1 auto; }
@media (max-width: 980px) { .two { grid-template-columns:1fr; } .wrap { width:min(96vw, 1560px); padding:16px; } }
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<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="/license">License</a>
<a class="btn" href="/licence">Licence alias</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</button>
<button id="refresh-btn" class="btn warn" type="button">Refresh</button>
</div>
<div class="glass card" style="margin-bottom:18px;">
<div class="row">
<div>
<div class="kicker">Force Monitor</div>
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">License Center</h1>
<div class="sub">Status, fingerprint, activation request, local license import, and signed license activation.</div>
</div>
<div class="spacer"></div>
<div class="row">
<span id="mode-badge" class="badge info">mode: loading</span>
<span id="lock-badge" class="badge">locked: --</span>
<span id="tamper-badge" class="badge">tamper: --</span>
</div>
</div>
<div id="action-message" class="message"></div>
</div>
<div class="grid cards">
<div class="glass card"><div class="kicker">Current mode</div><div id="metric-mode" class="value">--</div><div id="metric-mode-sub" class="sub">Loading...</div></div>
<div class="glass card"><div class="kicker">Days remaining</div><div id="metric-days" class="value mono">--</div><div class="sub">Trial or expiry information</div></div>
<div class="glass card"><div class="kicker">Activation configured</div><div id="metric-configured" class="value mono">--</div><div class="sub">Public key present or not</div></div>
<div class="glass card"><div class="kicker">Fingerprint</div><div id="metric-fingerprint" class="value mono" style="font-size:24px;">--</div><div class="sub">Short fingerprint</div></div>
</div>
<div class="grid two">
<div class="glass card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<div>
<div class="kicker">Activation request</div>
<div class="sub">Send this JSON to your private signing tool or license issuer.</div>
</div>
<div class="toolbar">
<button id="copy-request-btn" class="btn" type="button">Copy</button>
<button id="download-request-btn" class="btn good" type="button">Download</button>
</div>
</div>
<pre id="request-json">Loading activation request...</pre>
</div>
<div class="glass card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<div>
<div class="kicker">Activate signed license</div>
<div class="sub">Paste the signed license JSON or load it from a local file.</div>
</div>
<div class="toolbar">
<input id="license-file" type="file" accept="application/json,.json" class="btn" style="padding:8px 12px;">
<button id="activate-btn" class="btn primary" type="button">Activate</button>
</div>
</div>
<textarea id="license-text" class="mono" placeholder='{"app":"force_monitor",...}'></textarea>
</div>
</div>
<div class="glass card" style="margin-top:18px;">
<div class="kicker">Status details</div>
<div class="details" style="margin-top:14px;">
<div class="detail-box"><div class="label">Message</div><div id="detail-message">--</div></div>
<div class="detail-box"><div class="label">Customer</div><div id="detail-customer">--</div></div>
<div class="detail-box"><div class="label">License ID</div><div id="detail-license-id" class="mono">--</div></div>
<div class="detail-box"><div class="label">Hostname</div><div id="detail-hostname" class="mono">--</div></div>
<div class="detail-box"><div class="label">Fingerprint</div><div id="detail-fingerprint" class="mono">--</div></div>
<div class="detail-box"><div class="label">Trial window</div><div id="detail-trial-window">--</div></div>
<div class="detail-box"><div class="label">Expires at</div><div id="detail-expires">--</div></div>
<div class="detail-box"><div class="label">Features</div><div id="detail-features">--</div></div>
</div>
</div>
</div>
<script>
let activationRequestText = '';
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function setTheme(theme) {
const t = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', t);
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') theme = stored;
} catch (e) {}
setTheme(theme);
}
function setMessage(text, good) {
const box = document.getElementById('action-message');
if (!box) return;
box.textContent = text || '';
box.className = 'message' + (text ? ' show ' + (good ? 'good' : 'bad') : '');
}
function setDetail(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value || '--';
}
function setBadge(id, text, klass) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'badge ' + (klass || '');
el.textContent = text;
}
function renderStatus(status) {
const mode = String(status.mode || '--');
const locked = !!status.locked;
const tampered = !!status.tampered;
const activationConfigured = !!status.activation_configured;
const daysRemaining = Number.isFinite(Number(status.days_remaining)) ? String(status.days_remaining) : '--';
document.getElementById('metric-mode').textContent = mode.toUpperCase();
document.getElementById('metric-mode-sub').textContent = status.message || '--';
document.getElementById('metric-days').textContent = daysRemaining;
document.getElementById('metric-configured').textContent = activationConfigured ? 'YES' : 'NO';
document.getElementById('metric-fingerprint').textContent = status.fingerprint_short || '--';
setBadge('mode-badge', 'mode: ' + mode, locked ? 'bad' : (mode === 'licensed' ? 'ok' : 'info'));
setBadge('lock-badge', 'locked: ' + (locked ? 'yes' : 'no'), locked ? 'bad' : 'ok');
setBadge('tamper-badge', 'tamper: ' + (tampered ? 'yes' : 'no'), tampered ? 'bad' : 'ok');
setDetail('detail-message', status.message || '--');
setDetail('detail-customer', status.customer || '--');
setDetail('detail-license-id', status.license_id || '--');
setDetail('detail-hostname', status.hostname || '--');
setDetail('detail-fingerprint', status.fingerprint || '--');
const trialWindow = (status.trial_started_at || '--') + ' → ' + (status.trial_expires_at || '--');
setDetail('detail-trial-window', trialWindow);
setDetail('detail-expires', status.expires_at || '--');
setDetail('detail-features', Array.isArray(status.features) && status.features.length ? status.features.join(', ') : '--');
}
async function refreshStatus() {
try {
const res = await fetch('/api/license/status', { cache:'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const status = await res.json();
renderStatus(status);
} catch (err) {
console.warn('License status error:', err);
setMessage('Could not load license status.', false);
}
}
async function refreshRequest() {
try {
const res = await fetch('/api/license/request', { cache:'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
activationRequestText = JSON.stringify(data, null, 2);
document.getElementById('request-json').textContent = activationRequestText;
} catch (err) {
console.warn('License request error:', err);
activationRequestText = '';
document.getElementById('request-json').textContent = 'Could not load activation request.';
}
}
async function activateLicense() {
const text = document.getElementById('license-text').value.trim();
if (!text) {
setMessage('Paste a signed license JSON first.', false);
return;
}
setMessage('Activating license...', true);
try {
const res = await fetch('/api/license/activate', {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ license_text:text })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errMsg = data && data.error ? data.error : ('HTTP ' + res.status);
setMessage('Activation failed: ' + errMsg, false);
if (data && data.license) renderStatus(data.license);
return;
}
setMessage('License activated successfully.', true);
if (data && data.license) renderStatus(data.license);
await refreshStatus();
} catch (err) {
console.warn('License activate error:', err);
setMessage('Activation request failed.', false);
}
}
async function copyRequest() {
if (!activationRequestText) return;
try {
await navigator.clipboard.writeText(activationRequestText);
setMessage('Activation request copied to clipboard.', true);
} catch (err) {
setMessage('Clipboard copy failed.', false);
}
}
function downloadRequest() {
if (!activationRequestText) return;
const blob = new Blob([activationRequestText], { type:'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'force-monitor-activation-request.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function attachFileReader() {
const input = document.getElementById('license-file');
if (!input) return;
input.addEventListener('change', async () => {
const file = input.files && input.files[0];
if (!file) return;
const text = await file.text();
document.getElementById('license-text').value = text;
setMessage('Loaded license file into the text box.', true);
});
}
window.addEventListener('DOMContentLoaded', async () => {
initTheme();
document.getElementById('theme-toggle').addEventListener('click', () => setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light'));
document.getElementById('refresh-btn').addEventListener('click', async () => { setMessage('', true); await Promise.all([refreshStatus(), refreshRequest()]); });
document.getElementById('activate-btn').addEventListener('click', activateLicense);
document.getElementById('copy-request-btn').addEventListener('click', copyRequest);
document.getElementById('download-request-btn').addEventListener('click', downloadRequest);
attachFileReader();
await Promise.all([refreshStatus(), refreshRequest()]);
});
</script>
</body>
</html>