375 lines
18 KiB
HTML
375 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Force Monitor — 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>
|