Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; }
function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }

Check warning on line 11 in public/app.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'payloadTypeColor' is defined but never used. Allowed unused vars must match /^_/u
function isTransportRoute(rt) { return rt === 0 || rt === 3; }
/** Byte offset of path_len in raw_hex: 5 for transport routes (4 bytes of next/last hop codes precede it), 1 otherwise. */
function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; }
Expand Down Expand Up @@ -1059,7 +1059,12 @@
ws = sock;
wsLastFrameAt = Date.now(); // from creation: a stuck handshake is caught too
wsWatchdogTimer = setTimeout(checkWSLiveness, WS_STALE_MS);
sock.onopen = () => Logo.setConnected(true);
sock.onopen = () => {
Logo.setConnected(true);
// A deploy restarts the server, so a reconnect is a cheap moment to
// check for a new version (#614, see version-notice.js).
window.dispatchEvent(new Event('corescope:ws-open'));
};
sock.onclose = () => {
if (ws !== sock) return;
clearTimeout(wsWatchdogTimer);
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
<script src="app.js?v=__BUST__"></script>
<script src="bottom-nav.js?v=__BUST__"></script>
<script src="nav-drawer.js?v=__BUST__"></script>
<script src="version-notice.js?v=__BUST__"></script>
<script src="touch-gestures.js?v=__BUST__"></script>
<script src="gesture-hints.js?v=__BUST__"></script>
<script src="url-state.js?v=__BUST__"></script>
Expand Down
31 changes: 24 additions & 7 deletions public/nav-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@

// ── Version footer (#111) ───────────────────────────────────────────────
// GET /api/health reports {version, commit, buildTime}; the server fills
// any it cannot resolve with "unknown". Fetched on the first open that
// passes the width gate (never at page load: the drawer may never open,
// and cannot at <= NARROW_MAX), then cached for the page lifetime --
// failures included -- so re-opening adds no requests. Values are written
// any it cannot resolve with "unknown". The reading is the shared
// page-load one (readHealthOnce, also the baseline for version-notice.js,
// #614), cached for the page lifetime -- failures included -- so opening
// or re-opening the drawer adds no requests. Values are written
// with textContent / title only.
var RELEASES_URL = 'https://github.com/dborup/CoreScope/releases';
var versionRequested = false;
Expand All @@ -121,13 +121,30 @@
if (bits.length) el.title = bits.join(' \u00B7 ');
}

// The one /api/health reader. Resolves to the parsed body, or null for a
// network error, non-OK (5xx during a restart) or invalid JSON response.
// Shared with version-notice.js (#614) as window.CoreHealth.
function readHealth() {
if (typeof fetch !== 'function') return Promise.resolve(null);
return fetch('/api/health', { headers: { Accept: 'application/json' } })
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (h) { return h || null; }, function () { return null; });
}
// The first reading is shared: version-notice.js reads it at page load as
// its baseline and the drawer footer reuses it, so opening the drawer adds
// no request. The result, failure included, is cached for the page lifetime.
var firstHealth = null;
function readHealthOnce() {
if (!firstHealth) firstHealth = readHealth();
return firstHealth;
}
window.CoreHealth = { read: readHealth, readOnce: readHealthOnce, field: healthField };

function requestVersion() {
if (versionRequested || !versionEl || typeof fetch !== 'function') return;
versionRequested = true;
var el = versionEl;
fetch('/api/health', { headers: { Accept: 'application/json' } })
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (h) { applyVersion(el, h); }, function () { /* neutral label */ });
readHealthOnce().then(function (h) { applyVersion(el, h); });
}

var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone)
Expand Down
42 changes: 42 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -6042,3 +6042,45 @@ body.embed #app.app-fixed {
.regions-controls label { flex: 1 1 140px; }
.regions-controls select { min-width: 0; flex: 1; }
}

/* #614 — "new version available" notice (version-notice.js) */
.version-notice {
position: fixed;
left: 50%;
bottom: calc(16px + var(--bottom-nav-reserve, 0px));
transform: translateX(-50%);
z-index: var(--z-popover, 300);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 12px;
box-sizing: border-box;
max-width: calc(100vw - 32px);
padding: 10px 14px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px var(--shadow);
font-size: 14px;
}
.version-notice-btn {
min-height: 32px;
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
}
.version-notice-reload {
background: var(--accent-strong);
border-color: var(--accent-strong);
color: var(--text-on-accent);
}
.version-notice-btn:focus-visible {
outline: 2px solid var(--accent-strong);
outline-offset: 2px;
}
109 changes: 109 additions & 0 deletions public/version-notice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* version-notice.js — "new version available" notice (#614).
*
* Remembers the server commit seen at page load (via the shared
* window.CoreHealth reader from nav-drawer.js) and shows a non-blocking,
* dismissible banner when a later reading reports a different commit. It
* never reloads by itself, and shows once per new commit.
*
* Checks piggyback on events that already happen: the tab becoming visible,
* the shared WebSocket (re)opening (app.js dispatches `corescope:ws-open`),
* and a slow interval for tabs that stay visible. All are throttled.
* Failed or commit-less health readings (restart window) are ignored.
*
* Intervals are hardcoded for now; candidates for the customizer later.
*/
'use strict';

(function () {
var MIN_GAP_MS = 30 * 1000; // floor between any two checks
var INTERVAL_MS = 5 * 60 * 1000; // periodic check for visible tabs

function initialState() { return { baseline: null, notified: null }; }

// Pure. Returns {notify, commit, state}; never mutates `state`.
function decide(state, health) {
var commit = health && typeof health.commit === 'string' ? health.commit.trim() : '';
if (!commit || commit.toLowerCase() === 'unknown') {
return { notify: false, commit: '', state: state };
}
if (state.baseline === null) {
return { notify: false, commit: commit, state: { baseline: commit, notified: state.notified } };
}
if (commit === state.baseline || commit === state.notified) {
return { notify: false, commit: commit, state: state };
}
return { notify: true, commit: commit, state: { baseline: state.baseline, notified: commit } };
}

function shouldCheck(now, last, minGap) {
return !(last > 0) || now - last >= minGap;
}

function buildBanner(onReload, onDismiss) {
var el = document.createElement('div');
el.className = 'version-notice';
el.setAttribute('role', 'status');
el.setAttribute('data-version-notice', '');
var msg = document.createElement('span');
msg.className = 'version-notice-text';
msg.textContent = 'New version available – reload to update.';
var reload = document.createElement('button');
reload.type = 'button';
reload.className = 'version-notice-btn version-notice-reload';
reload.setAttribute('data-version-notice-reload', '');
reload.textContent = 'Reload';
reload.addEventListener('click', onReload);
var close = document.createElement('button');
close.type = 'button';
close.className = 'version-notice-btn version-notice-dismiss';
close.setAttribute('data-version-notice-dismiss', '');
close.setAttribute('aria-label', 'Dismiss');
close.textContent = 'Dismiss';
close.addEventListener('click', onDismiss);
el.appendChild(msg);
el.appendChild(reload);
el.appendChild(close);
return el;
}

function start() {
var reader = window.CoreHealth;
if (!reader || typeof reader.read !== 'function') return;
var state = initialState();
var lastCheck = 0;
var banner = null;

function hide() {
if (banner) { banner.remove(); banner = null; }
}
function show() {
if (banner) return;
banner = buildBanner(function () { window.location.reload(); },
hide);
document.body.appendChild(banner);
}
function check(baseline) {
var now = Date.now();
if (!baseline && !shouldCheck(now, lastCheck, MIN_GAP_MS)) return;
lastCheck = now;
(baseline ? reader.readOnce() : reader.read()).then(function (h) {
var r = decide(state, h);
state = r.state;
if (r.notify) show();
});
}

check(true); // baseline
document.addEventListener('visibilitychange', function () {
if (!document.hidden) check(false);
});
window.addEventListener('corescope:ws-open', function () { check(false); });
setInterval(function () { if (!document.hidden) check(false); }, INTERVAL_MS);
}

window.VersionNotice = {
initialState: initialState, decide: decide,
shouldCheck: shouldCheck, start: start,
};
if (typeof document !== 'undefined' && document.body !== undefined) start();
})();
10 changes: 10 additions & 0 deletions scripts/e2e-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -1767,6 +1767,16 @@
13.017
]
},
{
"file": "test-issue-614-version-notice-e2e.js",
"shard": 3,
"env": {
"CHROMIUM_REQUIRE": "1",
"BASE_URL": "$BASE_URL"
},
"output": "e2e",
"meanSeconds": 12
},
{
"file": "test-issue-111-drawer-version-e2e.js",
"shard": 3,
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ run test-estimated-positions-config.js
run test-map-scope-filter.js
run test-issue-117-ws-watchdog.js
run test-issue-111-drawer-version.js
run test-issue-614-version-notice.js

# Previously run only by the JS unit step in deploy.yml (#174)
run test-packet-filter-time.js
Expand Down
7 changes: 4 additions & 3 deletions test-issue-111-drawer-version-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ async function page(browser, viewport, errors) {
console.log('\n=== #111 nav-drawer version footer against ' + BASE + ' ===');

for (const vp of [{ width: 1440, height: 900 }, { width: 1024, height: 768 }, { width: 800, height: 900 }, { width: 1280, height: 480 }]) {
await step(`${vp.width}x${vp.height}: footer inside the drawer, below a reachable route list; one request`, async () => {
await step(`${vp.width}x${vp.height}: footer inside the drawer, below a reachable route list; one request in total`, async () => {
const { ctx, p, health } = await page(browser, vp, errors);
try {
assert(health.length === 0, health.length + ' /api/health requests before the drawer was opened');
// #614: version-notice.js reads the baseline at load; the drawer reuses it.
assert(health.length <= 1, health.length + ' /api/health requests before the drawer was opened');
await p.evaluate(() => window.__navDrawer.open());
await p.waitForTimeout(400);
const r = await p.evaluate(() => {
Expand Down Expand Up @@ -88,7 +89,7 @@ async function page(browser, viewport, errors) {
try {
await p.evaluate(() => { window.__navDrawer.open(); window.__navDrawer.open(); });
await p.waitForTimeout(400);
assert(health.length === 0, health.length + ' requests at 700px');
assert(health.length <= 1, health.length + ' requests at 700px (the page-load baseline only)');
} finally { await ctx.close(); }
});

Expand Down
119 changes: 119 additions & 0 deletions test-issue-614-version-notice-e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/* Issue #614 — "new version available" notice in an already-open tab.
*
* Stubs /api/health: first reading commit "old", then "new". Fires
* visibilitychange and asserts the role=status banner appears, that nothing
* reloads by itself, that Reload reloads, that Dismiss hides it for good,
* and that it is visible and readable in light, dark and at 390px.
*/
'use strict';
const { chromium } = require('playwright');

const BASE = process.env.BASE_URL || 'http://localhost:13581';
const SHOTS = process.env.SHOT_DIR || '';
const NAME = 'test-issue-614-version-notice-e2e.js';
let passed = 0, failed = 0;
function ok(c, m) { if (c) { passed++; console.log(' ✅ ' + m); } else { failed++; console.log(' ❌ ' + m); } }

async function newPage(browser, theme, viewport, healthState) {
const ctx = await browser.newContext({ viewport });
await ctx.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (e) {} }, theme);
const page = await ctx.newPage();
await page.route('**/api/health', (route) => {
if (healthState.mode === '503') return route.fulfill({ status: 503, body: 'restarting' });
route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ version: 'v1', commit: healthState.commit, buildTime: 't' }),
});
});
await page.goto(BASE + '/#/nodes', { waitUntil: 'load' });
await page.waitForFunction(() => window.VersionNotice && window.CoreHealth);
await page.waitForTimeout(300); // baseline read
return { ctx, page };
}
async function tick(page) {
await page.evaluate(() => {
const real = Date.now.bind(Date);
const off = (window.__vnOff = (window.__vnOff || 0) + 120000);
Date.now = () => real() + off;
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
});
}

(async () => {
let browser;
try {
browser = await chromium.launch({
headless: true, executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') { console.error(`${NAME}: FAIL — Chromium unavailable: ${err.message}`); process.exit(1); }
console.log(`${NAME}: SKIP (Chromium unavailable)`); process.exit(0);
}

const cases = [
['light', { width: 1280, height: 800 }],
['dark', { width: 1280, height: 800 }],
['light', { width: 390, height: 844 }],
];
for (const [theme, vp] of cases) {
const label = `${theme} ${vp.width}px`;
console.log('--- ' + label);
const st = { commit: 'old', mode: 'ok' };
const { ctx, page } = await newPage(browser, theme, vp, st);
let navs = 0;
page.on('framenavigated', (f) => { if (f === page.mainFrame()) navs++; });
ok(await page.locator('[data-version-notice]').count() === 0, 'no banner on first load');

st.mode = '503';
await tick(page); await page.waitForTimeout(300);
ok(await page.locator('[data-version-notice]').count() === 0, 'no banner for a 503 during restart');

st.mode = 'ok'; st.commit = 'new';
await tick(page);
const banner = page.locator('[data-version-notice]');
await banner.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
ok(await banner.count() === 1 && await banner.isVisible(), 'banner visible after a new commit');
ok(await banner.getAttribute('role') === 'status', 'role=status');
const box = await banner.boundingBox();
ok(box && box.x >= 0 && box.x + box.width <= vp.width + 1 && box.y >= 0 && box.y + box.height <= vp.height + 1, 'banner inside the viewport');
const colors = await banner.evaluate((el) => { const s = getComputedStyle(el); return [s.backgroundColor, s.color]; });
ok(colors[0] !== 'rgba(0, 0, 0, 0)' && colors[0] !== colors[1], 'banner has a themed background distinct from text: ' + colors.join(' / '));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/614-${theme}-${vp.width}.png` });

await tick(page); await page.waitForTimeout(200);
ok(await page.locator('[data-version-notice]').count() === 1, 'one banner per new commit');
ok(navs === 0, 'no automatic reload');

// keyboard: Reload button reachable by Tab and activates with Enter
await page.locator('[data-version-notice-reload]').focus();
ok(await page.evaluate(() => document.activeElement && document.activeElement.hasAttribute('data-version-notice-reload')), 'Reload focusable');
const [ ] = await Promise.all([
page.waitForEvent('framenavigated', { timeout: 5000 }).catch(() => null),
page.keyboard.press('Enter'),
]);
await page.waitForLoadState('load');
ok(navs >= 1, 'Reload reloads the page');
await ctx.close();
}

console.log('--- dismiss');
const st = { commit: 'old', mode: 'ok' };
const { ctx, page } = await newPage(browser, 'light', { width: 1280, height: 800 }, st);
st.commit = 'new'; await tick(page);
await page.locator('[data-version-notice]').waitFor({ state: 'visible', timeout: 5000 });
await page.locator('[data-version-notice-dismiss]').click();
ok(await page.locator('[data-version-notice]').count() === 0, 'Dismiss hides the banner');
await tick(page); await page.waitForTimeout(300);
ok(await page.locator('[data-version-notice]').count() === 0, 'banner does not return for the same commit');
st.commit = 'newer'; await tick(page);
await page.locator('[data-version-notice]').waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
ok(await page.locator('[data-version-notice]').count() === 1, 'a later commit notifies again');
await ctx.close();

await browser.close();
console.log(`\n${NAME}: ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();
Loading
Loading