From e07cf196c644e9edb5c477454b871e5d6c19dbb9 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 02:45:36 -0700 Subject: [PATCH] feat: add formatLastSeen and buildOfflineTileHTML functions - Add formatLastSeen(ms) to format millisecond timestamps into relative strings (Xs ago, Xm ago, Xh ago, Xd ago, Never for null) - Add buildOfflineTileHTML(source) to render unreachable device tiles with Offline badge and last-seen timestamp - Both functions added after buildAuthTileHTML in app.js - Both exported via module.exports - 7 new tests covering export, CSS class, device name, badge, last-seen, HTML escaping, and null handling --- muxplex/frontend/app.js | 33 ++++++++++++++++++++++++ muxplex/frontend/tests/test_app.mjs | 40 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 5e2b5c3..e888ed3 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -541,6 +541,37 @@ function buildAuthTileHTML(source) { ); } +/** + * Format a millisecond timestamp into a relative 'last seen' string. + * @param {number|null} ms - Millisecond timestamp + * @returns {string} + */ +function formatLastSeen(ms) { + if (ms == null) return 'Never'; + var diff = Math.floor((Date.now() - ms) / 1000); + if (diff < 60) return diff + 's ago'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; +} + +/** + * Build the HTML string for an offline (unreachable) source tile. + * @param {{ name: string, url: string, lastSeenAt: number|null }} source + * @returns {string} + */ +function buildOfflineTileHTML(source) { + var escapedName = escapeHtml(source.name || ''); + var lastSeen = formatLastSeen(source.lastSeenAt); + return ( + '
' + + '' + escapedName + '' + + 'Offline' + + 'Last seen ' + escapeHtml(lastSeen) + '' + + '
' + ); +} + /** * Returns sessions with hidden session names removed. * Only hides LOCAL sessions (those with empty/absent sourceUrl) matching the @@ -2219,6 +2250,8 @@ if (typeof module !== 'undefined' && module.exports) { renderFilterBar, // Federation tiles buildAuthTileHTML, + buildOfflineTileHTML, + formatLastSeen, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 6194569..1495fe6 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2864,3 +2864,43 @@ test('buildAuthTileHTML escapes HTML in device name', () => { assert.ok(html.includes('<script>'), 'escaped script tag should appear in html'); }); +// --- buildOfflineTileHTML --- + +test('buildOfflineTileHTML is exported as a function', () => { + assert.strictEqual(typeof app.buildOfflineTileHTML, 'function'); +}); + +test('buildOfflineTileHTML returns article with source-tile--offline class', () => { + const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null }); + assert.ok(html.startsWith(' { + const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null }); + assert.ok(html.includes('Dev Server'), 'html should include the device name'); +}); + +test('buildOfflineTileHTML includes Offline badge', () => { + const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null }); + assert.ok(html.includes('Offline'), 'html should include Offline text'); + assert.ok(html.includes('source-tile__badge'), 'html should include source-tile__badge class'); +}); + +test('buildOfflineTileHTML shows relative last-seen time', () => { + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: fiveMinAgo }); + assert.ok(html.includes('Last seen'), 'html should include Last seen text'); +}); + +test('buildOfflineTileHTML escapes device name', () => { + const html = app.buildOfflineTileHTML({ name: 'bad', url: '', lastSeenAt: null }); + assert.ok(!html.includes('bad'), 'raw bad must not appear in html'); + assert.ok(html.includes('<b>bad</b>'), 'device name should be HTML-escaped'); +}); + +test('buildOfflineTileHTML shows "Never" when lastSeenAt is null', () => { + const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: '', lastSeenAt: null }); + assert.ok(html.includes('Never'), 'html should include Never when lastSeenAt is null'); +}); +