Remove edge glow; prefill MCMS URL from env; drop self-signed-cert option

- Remove the animated neon edge-glow entirely (HTML element + CSS) — it
  couldn't be made to follow the border reliably on iOS Safari.
- Add PFW_MCMS_URL: the web server prefills the login "Host URL" field with
  it (injected as the input's value in the served index; still editable).
  Documented in env.example + web README.
- Remove the "Accept self-signed TLS certificate" option: the checkbox is
  gone and both backends now always use rejectUnauthorized:true — MCMS must
  present a valid certificate. Dropped the acceptSelfSigned plumbing from the
  renderer and both login handlers.

Docs: CLAUDE.md (drop edge-glow section, update login note) + web README.

Verified: node --check; no stale refs (in-self-signed / acceptSelfSigned /
edge-glow / beamspin); served index carries value="…" only when
PFW_MCMS_URL is set, and no self-signed checkbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 16:49:54 +02:00
parent 6f4a315947
commit ea251c3498
8 changed files with 30 additions and 79 deletions

View file

@ -143,8 +143,10 @@ stream progress events back to the renderer.
### Fleet view ### Fleet view
- Login with email / password, accept-self-signed-TLS toggle (essential - Login with email / password. TLS to MCMS is always verified
for lab MCMS installs). (`rejectUnauthorized: true`); there is no accept-self-signed option —
MCMS must present a valid certificate. The web server can prefill the host
field from `PFW_MCMS_URL`.
- Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` + - Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` +
`listAllOltStates`, all paginated via the `next` cursor at page size `listAllOltStates`, all paginated via the `next` cursor at page size
100 (see §5). 100 (see §5).
@ -777,17 +779,9 @@ The ONU-state rows drill down: clicking one sets `#filter-status` and jumps
to the ONU tab (loading the fleet if needed). `fmtBps` in `app.js` is a to the ONU tab (loading the fleet if needed). `fmtBps` in `app.js` is a
port of the iOS `Format.bps`. port of the iOS `Format.bps`.
### Animated edge glow (An animated neon edge-glow beam was tried and removed — it couldn't be made
to track the border reliably on iOS Safari. The static scanline overlay
`#edge-glow` (fixed, pointer-events-none, full-viewport) holds a `.beam` remains.)
child masked to a 3px border ring (`mask` + `mask-composite: exclude`); the
ring contains a `::before` that is an oversized `conic-gradient` with a
white-hot core, spun with **`transform: rotate` 0→360°**, so the beam travels
around the screen edge. (It deliberately does *not* use an `@property`-
animated gradient angle — iOS Safari won't animate that, which left the beam
static on mobile.) The glow halo is a `drop-shadow` on the parent so it
survives the child's mask. Respects `prefers-reduced-motion`; z-index sits
below the scanline overlay (9999) and modals (10000).
--- ---

View file

@ -63,11 +63,12 @@ function toWireError(err) {
return { message: err?.message || String(err) }; return { message: err?.message || String(err) };
} }
ipcMain.handle('api:login', async (_ev, { baseUrl, username, password, acceptSelfSigned }) => { ipcMain.handle('api:login', async (_ev, { baseUrl, username, password }) => {
try { try {
client = new McmsClient({ client = new McmsClient({
baseUrl, baseUrl,
rejectUnauthorized: !acceptSelfSigned, // TLS is always verified — MCMS must present a valid certificate.
rejectUnauthorized: true,
// Verbose logging goes to the Electron terminal (npm start output). // Verbose logging goes to the Electron terminal (npm start output).
// Turn off by setting PON_FLEET_VERBOSE=0 in the env. // Turn off by setting PON_FLEET_VERBOSE=0 in the env.
verbose: process.env.PON_FLEET_VERBOSE !== '0', verbose: process.env.PON_FLEET_VERBOSE !== '0',

View file

@ -416,57 +416,6 @@ button.danger:hover:not(:disabled) {
.modal-type-label { display: block; } .modal-type-label { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; } .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
/* -------- Animated neon edge glow (beam travels around the border) --------
A rotating conic-gradient (transform: rotate works on iOS Safari, unlike
an @property-animated gradient angle) clipped to a thin border ring. The
glow halo is a drop-shadow on the parent so it survives the child's mask. */
#edge-glow {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
filter: drop-shadow(0 0 6px var(--neon)) drop-shadow(0 0 16px var(--cyan));
}
#edge-glow .beam {
position: absolute;
inset: 0;
padding: 3px; /* thickness of the glowing border band */
overflow: hidden;
/* Keep only the padding ring (border band); punch out the interior. */
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
}
#edge-glow .beam::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 200vmax; /* > viewport diagonal at any rotation */
height: 200vmax;
transform: translate(-50%, -50%);
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 40deg,
var(--cyan) 66deg,
#ffffff 90deg,
var(--neon) 114deg,
transparent 140deg,
transparent 360deg
);
animation: beamspin 7s linear infinite;
will-change: transform;
}
@keyframes beamspin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
#edge-glow .beam::before { animation: none; }
}
/* -------- Top tabs -------- */ /* -------- Top tabs -------- */
.tabs { display: flex; gap: 4px; margin-left: 18px; align-self: stretch; align-items: flex-end; } .tabs { display: flex; gap: 4px; margin-left: 18px; align-self: stretch; align-items: flex-end; }
.tabs .tab { .tabs .tab {

View file

@ -223,14 +223,13 @@ $('#btn-login').addEventListener('click', async () => {
const baseUrl = $('#in-host').value.trim(); const baseUrl = $('#in-host').value.trim();
const username = $('#in-user').value.trim(); const username = $('#in-user').value.trim();
const password = $('#in-pass').value; const password = $('#in-pass').value;
const acceptSelfSigned = $('#in-self-signed').checked;
if (!baseUrl || !username || !password) { if (!baseUrl || !username || !password) {
$('#login-error').textContent = 'Host, username and password are required.'; $('#login-error').textContent = 'Host, username and password are required.';
return; return;
} }
$('#btn-login').disabled = true; $('#btn-login').disabled = true;
$('#btn-login').textContent = 'Connecting…'; $('#btn-login').textContent = 'Connecting…';
const res = await window.api.login({ baseUrl, username, password, acceptSelfSigned }); const res = await window.api.login({ baseUrl, username, password });
$('#btn-login').disabled = false; $('#btn-login').disabled = false;
$('#btn-login').textContent = 'Connect'; $('#btn-login').textContent = 'Connect';
if (!res.ok) { if (!res.ok) {

View file

@ -6,9 +6,6 @@
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css" />
</head> </head>
<body> <body>
<!-- Animated neon beam that travels around the screen edge. -->
<div id="edge-glow" aria-hidden="true"><div class="beam"></div></div>
<header class="topbar"> <header class="topbar">
<div class="brand">PON Fleet Upgrader</div> <div class="brand">PON Fleet Upgrader</div>
<nav id="tabs" class="tabs hidden"> <nav id="tabs" class="tabs hidden">
@ -32,10 +29,6 @@
<label>Host URL<input id="in-host" type="url" placeholder="https://mcms.example.net" /></label> <label>Host URL<input id="in-host" type="url" placeholder="https://mcms.example.net" /></label>
<label>Email / username<input id="in-user" type="text" autocomplete="username" /></label> <label>Email / username<input id="in-user" type="text" autocomplete="username" /></label>
<label>Password<input id="in-pass" type="password" autocomplete="current-password" /></label> <label>Password<input id="in-pass" type="password" autocomplete="current-password" /></label>
<label class="inline">
<input id="in-self-signed" type="checkbox" checked />
Accept self-signed TLS certificate
</label>
<div class="row"> <div class="row">
<button id="btn-login" class="primary">Connect</button> <button id="btn-login" class="primary">Connect</button>
<span id="login-error" class="error"></span> <span id="login-error" class="error"></span>

View file

@ -43,6 +43,7 @@ cookie jar) per session.
|---|---|---| |---|---|---|
| `PORT` | `8080` | listen port | | `PORT` | `8080` | listen port |
| `HOST` | `127.0.0.1` | bind address — `0.0.0.0` if the proxy is on another host | | `HOST` | `127.0.0.1` | bind address — `0.0.0.0` if the proxy is on another host |
| `PFW_MCMS_URL` | — | prefill the login "Host URL" field (still editable) |
| `PFW_CACHE_TTL_SECONDS` | `60` | shared upstream cache TTL; `0` disables (see Caching) | | `PFW_CACHE_TTL_SECONDS` | `60` | shared upstream cache TTL; `0` disables (see Caching) |
| `PFW_IDLE_MINUTES` | `30` | idle-session expiry (logs the MCMS session out) | | `PFW_IDLE_MINUTES` | `30` | idle-session expiry (logs the MCMS session out) |
| `PFW_SECURE_COOKIE` | off | force the `Secure` cookie flag (else inferred from `X-Forwarded-Proto: https`) | | `PFW_SECURE_COOKIE` | off | force the `Secure` cookie flag (else inferred from `X-Forwarded-Proto: https`) |
@ -142,8 +143,9 @@ LXC firewall + the NPM access list.
memory**. Run it behind HTTPS, keep `HOST=127.0.0.1`, and rely on the memory**. Run it behind HTTPS, keep `HOST=127.0.0.1`, and rely on the
idle timeout. The session cookie is `HttpOnly`, `SameSite=Lax`, and idle timeout. The session cookie is `HttpOnly`, `SameSite=Lax`, and
`Secure` behind TLS. `Secure` behind TLS.
- "Accept self-signed TLS certificate" disables verification **to MCMS** - TLS to MCMS is **always verified** — MCMS must present a valid certificate
for that session only (same as the desktop app). (there is no accept-self-signed option). If your MCMS uses an internal CA,
install that CA on the host running the server.
- There's no app-level user directory — anyone who can reach the page and - There's no app-level user directory — anyone who can reach the page and
has valid MCMS credentials can log in. Restrict network access at the has valid MCMS credentials can log in. Restrict network access at the
proxy (mTLS / SSO / IP allow-list) if you need more than MCMS auth. proxy (mTLS / SSO / IP allow-list) if you need more than MCMS auth.

View file

@ -2,6 +2,10 @@
# Copy to /etc/pon-fleet-web.env and edit. All values are optional; the # Copy to /etc/pon-fleet-web.env and edit. All values are optional; the
# defaults shown are what the server uses if a line is omitted. # defaults shown are what the server uses if a line is omitted.
# Prefill the login "Host URL" field so operators don't type it each time.
# (Still editable in the form.) Use the MCMS PON Manager root URL.
#PFW_MCMS_URL=https://mcms.example.net
# Listen port. # Listen port.
#PORT=8080 #PORT=8080

View file

@ -38,6 +38,7 @@ const PORT = Number(process.env.PORT) || 8080;
const HOST = process.env.HOST || '127.0.0.1'; const HOST = process.env.HOST || '127.0.0.1';
const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000; const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000;
const VERBOSE = process.env.PFW_VERBOSE === '1'; const VERBOSE = process.env.PFW_VERBOSE === '1';
const MCMS_URL = process.env.PFW_MCMS_URL || ''; // prefilled into the login host field
const CACHE_TTL_MS = (process.env.PFW_CACHE_TTL_SECONDS !== undefined const CACHE_TTL_MS = (process.env.PFW_CACHE_TTL_SECONDS !== undefined
? Number(process.env.PFW_CACHE_TTL_SECONDS) : 60) * 1000; ? Number(process.env.PFW_CACHE_TTL_SECONDS) : 60) * 1000;
const ROOT = __dirname; const ROOT = __dirname;
@ -208,6 +209,12 @@ function serveIndex(res) {
html = html.replace(/<script src="app\.js"><\/script>/i, html = html.replace(/<script src="app\.js"><\/script>/i,
'<script src="/api-web.js"></script>\n <script src="/shared/app.js"></script>'); '<script src="/api-web.js"></script>\n <script src="/shared/app.js"></script>');
// Prefill the login host field from PFW_MCMS_URL (server-side default).
if (MCMS_URL) {
const v = MCMS_URL.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/(<input id="in-host"[^>]*?)\s*\/?>/i, `$1 value="${v}" />`);
}
res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.writeHead(200, { 'Content-Type': MIME['.html'] });
res.end(html); res.end(html);
} }
@ -527,8 +534,9 @@ const server = http.createServer(async (req, res) => {
// Login establishes a session + its own McmsClient. // Login establishes a session + its own McmsClient.
if (name === 'login') { if (name === 'login') {
const body = await readBody(req, SMALL_BODY); const body = await readBody(req, SMALL_BODY);
const { baseUrl, username, password, acceptSelfSigned } = body || {}; const { baseUrl, username, password } = body || {};
const client = new McmsClient({ baseUrl, rejectUnauthorized: !acceptSelfSigned, verbose: VERBOSE }); // TLS is always verified — MCMS must present a valid certificate.
const client = new McmsClient({ baseUrl, rejectUnauthorized: true, verbose: VERBOSE });
try { try {
const result = await client.login(username, password); const result = await client.login(username, password);
const sid = crypto.randomUUID(); const sid = crypto.randomUUID();
@ -590,5 +598,6 @@ server.listen(PORT, HOST, () => {
console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`); console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`);
console.log(`[pon-fleet-web] idle session timeout: ${IDLE_MS / 60000} min · verbose MCMS: ${VERBOSE}`); console.log(`[pon-fleet-web] idle session timeout: ${IDLE_MS / 60000} min · verbose MCMS: ${VERBOSE}`);
console.log(`[pon-fleet-web] upstream cache: ${cache.enabled() ? (CACHE_TTL_MS / 1000) + 's TTL (shared per MCMS host)' : 'disabled'}`); console.log(`[pon-fleet-web] upstream cache: ${cache.enabled() ? (CACHE_TTL_MS / 1000) + 's TTL (shared per MCMS host)' : 'disabled'}`);
if (MCMS_URL) console.log(`[pon-fleet-web] login host prefilled with: ${MCMS_URL}`);
console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx/NPM) in front for remote use.'); console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx/NPM) in front for remote use.');
}); });