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');
+});
+