Add multi-user web server variant (browser UI behind a reverse proxy)
New web/ subfolder: a Node http server that serves the SAME tooling as the Electron app to any browser, with per-session MCMS credentials so several operators can use it at once behind a TLS reverse proxy. Mobile-responsive. Not a fork — it reuses the core and the UI: - serves renderer/app.js + app.css verbatim and rewrites index.html on the fly (mobile viewport + browser window.api shim + responsive overlay) - web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming; Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob - web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry, routes mirroring the IPC handlers, NDJSON for the 6 streaming ops - pure Node http, no new deps (borrows ../node_modules tough-cookie) Shared improvements (benefit both apps): - src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses it); adds abnormal-Tx detection alongside abnormal-Rx - renderer: dashboard health stats are now clickable — abnormal Rx / Tx / lasers-off pop a list of the offending devices (like the iOS app), via a new showListModal Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16. Verified: node --check all; started the server and confirmed transformed index, shared-asset serving, 401 auth gate, login (per-session client), and NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9106e5a071
commit
96b0264179
10 changed files with 1096 additions and 109 deletions
|
|
@ -556,3 +556,28 @@ button.danger:hover:not(:disabled) {
|
|||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.dash-chevron { color: var(--neon); margin-left: 8px; font-weight: 700; }
|
||||
|
||||
/* List/menu popped from a clickable dashboard stat. */
|
||||
.list-modal { width: 540px; }
|
||||
.list-modal-items {
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
|
||||
}
|
||||
.list-modal-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.list-modal-item:last-child { border-bottom: none; }
|
||||
.list-modal-item:hover { background: rgba(33, 200, 255, 0.06); }
|
||||
.lm-primary { font-weight: 600; }
|
||||
.lm-secondary { color: var(--fg-dim); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
|
|
|||
|
|
@ -1574,14 +1574,45 @@ function optionExists(selectSel, value) {
|
|||
return Array.from($(selectSel).options).some((o) => o.value === value);
|
||||
}
|
||||
|
||||
function dashHealthRow(title, detail, count) {
|
||||
function fmtDbm(n) {
|
||||
return typeof n === 'number' ? n.toFixed(1) : '—';
|
||||
}
|
||||
|
||||
function dashHealthRow(title, detail, count, drillKey) {
|
||||
const cls = count > 0 ? 'status-warn' : 'status-ok';
|
||||
return `<div class="dash-row">
|
||||
const tappable = drillKey && count > 0;
|
||||
const chevron = tappable ? ' <span class="dash-chevron">›</span>' : '';
|
||||
return `<div class="dash-row${tappable ? ' tappable' : ''}"${drillKey ? ` data-drill="${drillKey}"` : ''}>
|
||||
<span class="dash-row-main">${escapeHtml(title)}${detail ? `<span class="dash-row-sub">${escapeHtml(detail)}</span>` : ''}</span>
|
||||
<span class="dash-num ${cls}">${count}</span>
|
||||
<span class="dash-num ${cls}">${count}${chevron}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Scrollable list/menu popped from a clickable dashboard stat (e.g. the
|
||||
// list of ONUs with abnormal Rx). `items` is [{primary, secondary}].
|
||||
function showListModal(title, items) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
const list = items.length
|
||||
? `<div class="list-modal-items">${items.map((it) =>
|
||||
`<div class="list-modal-item"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
|
||||
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</span>` : ''}</div>`).join('')}</div>`
|
||||
: '<div class="modal-body muted">Nothing to show — all clear.</div>';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal list-modal" role="dialog" aria-modal="true">
|
||||
<h3>${escapeHtml(title)} <span class="muted">(${items.length})</span></h3>
|
||||
${list}
|
||||
<div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
||||
overlay.querySelector('.modal-ok').addEventListener('click', close);
|
||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
overlay.querySelector('.modal-ok').focus();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const extra = $('#dash-extra').checked;
|
||||
$('#dash-status').textContent = extra
|
||||
|
|
@ -1633,12 +1664,13 @@ function renderDashboard(d) {
|
|||
</div>`);
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
// --- Health (rows with a count drill down to the offenders) ---
|
||||
const healthRows = [];
|
||||
if (d.optical && d.optical.available) {
|
||||
healthRows.push(dashHealthRow('ONUs with abnormal Rx', '< −28 or > −10 dBm', d.optical.abnormalRxCount));
|
||||
healthRows.push(dashHealthRow('ONUs with abnormal Rx', '< −28 or > −10 dBm', d.optical.abnormalRxCount, 'rx'));
|
||||
healthRows.push(dashHealthRow('ONUs with abnormal Tx', '< 3 dBm', d.optical.abnormalTxCount, 'tx'));
|
||||
}
|
||||
healthRows.push(dashHealthRow('OLTs with laser off', null, (d.lasersOff || []).length));
|
||||
healthRows.push(dashHealthRow('OLTs with laser off', null, (d.lasersOff || []).length, 'laser'));
|
||||
sections.push(`
|
||||
<div class="dash-section">
|
||||
<h3>Health</h3>
|
||||
|
|
@ -1675,13 +1707,34 @@ function renderDashboard(d) {
|
|||
|
||||
$('#dash-sections').innerHTML = sections.join('');
|
||||
|
||||
// Drill-down: clicking an ONU-state row jumps to the ONU tab pre-filtered.
|
||||
// Drill-down: ONU-state rows jump to the ONU tab pre-filtered; health
|
||||
// rows pop a list of the offending devices (like the iOS app).
|
||||
for (const row of $$('#dash-sections .dash-row.tappable')) {
|
||||
row.addEventListener('click', () => {
|
||||
const status = row.dataset.status;
|
||||
$('#filter-status').value = optionExists('#filter-status', status) ? status : '';
|
||||
showView('#view-fleet');
|
||||
if (!state.fleet.length) loadFleet(); else applyFilters();
|
||||
if (row.dataset.status !== undefined) {
|
||||
const status = row.dataset.status;
|
||||
$('#filter-status').value = optionExists('#filter-status', status) ? status : '';
|
||||
showView('#view-fleet');
|
||||
if (!state.fleet.length) loadFleet(); else applyFilters();
|
||||
return;
|
||||
}
|
||||
const drill = row.dataset.drill;
|
||||
if (drill === 'rx') {
|
||||
showListModal('ONUs with abnormal Rx', (d.optical.abnormalRx || []).map((x) => ({
|
||||
primary: x.name || x.onuId,
|
||||
secondary: `${fmtDbm(x.rx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
|
||||
})));
|
||||
} else if (drill === 'tx') {
|
||||
showListModal('ONUs with abnormal Tx', (d.optical.abnormalTx || []).map((x) => ({
|
||||
primary: x.name || x.onuId,
|
||||
secondary: `${fmtDbm(x.tx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
|
||||
})));
|
||||
} else if (drill === 'laser') {
|
||||
showListModal('OLTs with laser off', (d.lasersOff || []).map((x) => ({
|
||||
primary: x.name || x.oltId,
|
||||
secondary: `${x.laser} · ${x.oltId}`,
|
||||
})));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue