added pages for history, licence,alarms
This commit is contained in:
parent
3891d9b61d
commit
0ce398fbda
374
static/alarms.html
Normal file
374
static/alarms.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
539
static/history.html
Normal 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, '&').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() {
|
||||||
|
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>
|
||||||
|
|
@ -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">⚠️</span>
|
<span class="text-2xl">⚠️</span>
|
||||||
|
|
|
||||||
334
static/license.html
Normal file
334
static/license.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
Loading…
Reference in a new issue