Files
muxplex/docs/plans/2026-03-30-multi-device-federation-phase3.md
T
2026-03-31 03:40:57 -07:00

1322 lines
44 KiB
Markdown

# Multi-Device Federation Phase 3: Remote Auth + Terminal + Unreachable States
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
**Goal:** Make remote sessions fully interactive — detect auth requirements, present login flow, show unreachable placeholders, and connect terminals to remote instances.
**Architecture:** Phase 2 left sources in three status states (`authenticated | auth_required | unreachable`) but only rendered sessions for `authenticated` sources. Phase 3 adds rendering for the other two states (auth-required tiles with login buttons, offline placeholder tiles with last-seen timestamps), implements the popup login flow, makes `terminal.js` connect WebSockets to remote origins, and updates `openSession`/`closeSession` to route API calls to the correct instance.
**Tech Stack:** Vanilla JS (no framework), CSS custom properties, Node.js `--test` runner with `assert`, `window.open()` for login popups, cross-origin `fetch` with `credentials: "include"`.
---
## Conventions (read this before you start)
**File paths are relative to the repo root** (`muxplex/`).
**How to run tests:**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs
cd muxplex && node --test muxplex/frontend/tests/test_terminal.mjs
```
**Test pattern:** Every test file uses `.mjs` extension, imports via `createRequire`, and mocks `globalThis.document`, `globalThis.fetch`, `globalThis.window`, etc. before requiring the module. See the top of `muxplex/frontend/tests/test_app.mjs` for the full mock setup.
**CommonJS export shim:** `app.js` ends with a `module.exports = { ... }` block wrapped in `if (typeof module !== 'undefined' && module.exports)`. Any new function you want to test **must** be added to that exports object at the bottom of `app.js` (around line 1660).
**CSS design tokens:** The file `muxplex/frontend/style.css` defines tokens on `:root`. Key ones: `--bg: #0D1117`, `--accent: #00D9F5`, `--text-dim: #4A5060`, `--text-muted: #8E95A3`, `--border: #2A3040`, `--err: #f85149`, `--warn: #d29922`, `--ok: #3fb950`.
**Phase 2 assumptions:** The plan assumes Phase 2 left these in place:
- `_sources` array with `{ url, name, type, status, lastSeenAt, backoff }` per source
- `pollSessions()` does `Promise.all` across sources, sets `source.status` to `authenticated | auth_required | unreachable`
- Each session object has `deviceName`, `sourceUrl`, `sessionKey` fields
- Device badge HTML in `buildTileHTML` (something like `<span class="device-badge">Laptop</span>`)
- `_sources` is accessible from tests via a `_setSources` or `_getSources` test helper
If any of these are named slightly differently in the actual Phase 2 code, adjust accordingly — the logic is the same.
---
### Task 1: CSS for auth-required and offline tile states
**Files:**
- Modify: `muxplex/frontend/style.css`
Phase 3 tiles need two new visual states: a greyed-out "offline" tile and an "auth required" tile with a login button. Add these styles to the end of `style.css`, before the `@media (prefers-reduced-motion)` block.
**Step 1: Add the CSS**
Append these rules to `muxplex/frontend/style.css`, just before the `/* Reduced Motion */` media query (currently around line 798):
```css
/* ============================================================
Federation: source status tiles (auth-required + offline)
============================================================ */
.source-tile {
height: var(--tile-height);
background: var(--bg-tile);
border: 1px solid var(--border);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 24px;
}
/* Offline / unreachable device tile */
.source-tile--offline {
opacity: 0.45;
border-style: dashed;
}
.source-tile--offline .source-tile__name {
color: var(--text-dim);
}
.source-tile--offline .source-tile__badge {
background: var(--err);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.source-tile--offline .source-tile__last-seen {
font-size: 11px;
color: var(--text-dim);
}
/* Auth-required device tile */
.source-tile--auth {
border-color: var(--warn);
border-style: dashed;
}
.source-tile__name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.source-tile__login-btn {
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 4px;
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity var(--t-fast);
}
.source-tile__login-btn:hover {
opacity: 0.85;
}
.source-tile__login-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.source-tile__hint {
font-size: 11px;
color: var(--text-muted);
}
```
**Step 2: Verify the CSS file is valid**
Open the file and visually confirm the new classes are syntactically correct (no unclosed braces):
```bash
cd muxplex && grep -c 'source-tile' muxplex/frontend/style.css
```
Expected: a count of ~20+ (the lines we just added).
**Step 3: Commit**
```bash
cd muxplex && git add muxplex/frontend/style.css && git commit -m "feat(federation): add CSS for auth-required and offline source tiles"
```
---
### Task 2: `buildAuthTileHTML` — render an auth-required tile
**Files:**
- Modify: `muxplex/frontend/app.js` (add function + export)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
This function builds the HTML for a single auth-required source tile. It shows the device name and a "Log in" button.
**Step 1: Write the failing tests**
Add these tests to the end of `muxplex/frontend/tests/test_app.mjs` (before the final blank lines):
```javascript
// --- buildAuthTileHTML ---
test('buildAuthTileHTML is exported as a function', () => {
assert.strictEqual(typeof app.buildAuthTileHTML, 'function');
});
test('buildAuthTileHTML returns article with source-tile--auth class', () => {
const html = app.buildAuthTileHTML({ name: 'Workstation', url: 'http://work:8088' });
assert.ok(html.includes('source-tile--auth'), 'must have source-tile--auth class');
assert.ok(html.startsWith('<article'), 'must be an <article> element');
});
test('buildAuthTileHTML includes device name', () => {
const html = app.buildAuthTileHTML({ name: 'Dev Server', url: 'http://dev:8088' });
assert.ok(html.includes('Dev Server'), 'must include the device name');
});
test('buildAuthTileHTML includes login button with data-url attribute', () => {
const html = app.buildAuthTileHTML({ name: 'Dev', url: 'http://dev:8088' });
assert.ok(html.includes('source-tile__login-btn'), 'must have login button');
assert.ok(html.includes('data-url="http://dev:8088"'), 'button must have data-url');
});
test('buildAuthTileHTML escapes HTML in device name', () => {
const html = app.buildAuthTileHTML({ name: '<script>alert(1)</script>', url: 'http://x' });
assert.ok(!html.includes('<script>'), 'must not contain raw <script>');
assert.ok(html.includes('&lt;script&gt;'), 'must escape device name');
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: 5 new failures (buildAuthTileHTML not exported).
**Step 3: Implement `buildAuthTileHTML`**
Add this function in `muxplex/frontend/app.js`, right after the `buildSidebarHTML` function (after line ~451):
```javascript
/**
* Build HTML for an auth-required source tile (user must log in).
* Shows device name and a "Log in" button that opens the remote login page.
* @param {{ name: string, url: string }} source
* @returns {string}
*/
function buildAuthTileHTML(source) {
const escapedName = escapeHtml(source.name || '');
const escapedUrl = escapeHtml(source.url || '');
return (
'<article class="source-tile source-tile--auth">' +
'<span class="source-tile__name">' + escapedName + '</span>' +
'<button class="source-tile__login-btn" data-url="' + escapedUrl + '">Log in</button>' +
'<span class="source-tile__hint">Authenticate to see sessions</span>' +
'</article>'
);
}
```
Then add `buildAuthTileHTML` to the `module.exports` object at the bottom of `app.js`:
```javascript
// Federation tiles
buildAuthTileHTML,
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all tests pass including the 5 new ones.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): add buildAuthTileHTML for auth-required sources"
```
---
### Task 3: `buildOfflineTileHTML` — render an unreachable device tile
**Files:**
- Modify: `muxplex/frontend/app.js` (add function + export)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
This function builds HTML for a single offline/unreachable source tile, showing the device name, an "Offline" badge, and the last-seen relative timestamp.
**Step 1: Write the failing tests**
Add to the end of `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- 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: 'Server', url: 'http://s:8088', lastSeenAt: Date.now() - 60000 });
assert.ok(html.includes('source-tile--offline'), 'must have source-tile--offline class');
assert.ok(html.startsWith('<article'), 'must be an <article> element');
});
test('buildOfflineTileHTML includes device name', () => {
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: Date.now() });
assert.ok(html.includes('Dev Server'), 'must include the device name');
});
test('buildOfflineTileHTML includes Offline badge', () => {
const html = app.buildOfflineTileHTML({ name: 'Dev', url: 'http://dev:8088', lastSeenAt: Date.now() });
assert.ok(html.includes('Offline'), 'must include Offline badge text');
assert.ok(html.includes('source-tile__badge'), 'must have badge class');
});
test('buildOfflineTileHTML shows relative last-seen time', () => {
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
const html = app.buildOfflineTileHTML({ name: 'Dev', url: 'http://dev:8088', lastSeenAt: fiveMinAgo });
assert.ok(html.includes('Last seen'), 'must include "Last seen" text');
});
test('buildOfflineTileHTML escapes device name', () => {
const html = app.buildOfflineTileHTML({ name: '<b>bad</b>', url: 'http://x', lastSeenAt: Date.now() });
assert.ok(!html.includes('<b>bad</b>'), 'must not contain raw HTML');
assert.ok(html.includes('&lt;b&gt;'), 'must escape name');
});
test('buildOfflineTileHTML shows "Never" when lastSeenAt is null', () => {
const html = app.buildOfflineTileHTML({ name: 'Dev', url: 'http://dev:8088', lastSeenAt: null });
assert.ok(html.includes('Never'), 'must show "Never" when lastSeenAt is null');
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: 7 new failures.
**Step 3: Implement `buildOfflineTileHTML`**
Add this function in `app.js`, right after `buildAuthTileHTML`:
```javascript
/**
* Format a millisecond timestamp into a relative "last seen" string.
* @param {number|null} ms - Unix timestamp in milliseconds
* @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 HTML for an unreachable/offline source tile.
* Shows device name, "Offline" badge, and last-seen timestamp.
* @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 (
'<article class="source-tile source-tile--offline">' +
'<span class="source-tile__name">' + escapedName + '</span>' +
'<span class="source-tile__badge">Offline</span>' +
'<span class="source-tile__last-seen">Last seen ' + escapeHtml(lastSeen) + '</span>' +
'</article>'
);
}
```
Add both to `module.exports`:
```javascript
buildOfflineTileHTML,
formatLastSeen,
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): add buildOfflineTileHTML for unreachable sources"
```
---
### Task 4: Integrate source status tiles into `renderGrid`
**Files:**
- Modify: `muxplex/frontend/app.js` (modify `renderGrid`)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
Currently `renderGrid` only renders session tiles. We need it to also render auth-required and offline tiles for non-authenticated sources.
**Step 1: Write the failing tests**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- renderGrid with source status tiles ---
test('renderGrid includes auth tile HTML when a source is auth_required', () => {
const mockGrid = { innerHTML: '' };
const mockEmpty = { style: {}, classList: { add: () => {}, remove: () => {} } };
const origGetById = globalThis.document.getElementById;
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.getElementById = (id) => {
if (id === 'session-grid') return mockGrid;
if (id === 'empty-state') return mockEmpty;
return null;
};
globalThis.document.querySelectorAll = () => [];
// Set sources with one authenticated, one auth_required
if (app._setSources) {
app._setSources([
{ url: '', name: 'Local', type: 'local', status: 'authenticated' },
{ url: 'http://work:8088', name: 'Workstation', type: 'remote', status: 'auth_required', lastSeenAt: null },
]);
}
const sessions = [{ name: 'local-session', snapshot: '', deviceName: 'Local', sourceUrl: '' }];
app.renderGrid(sessions);
assert.ok(mockGrid.innerHTML.includes('source-tile--auth'), 'grid should include auth tile for auth_required source');
assert.ok(mockGrid.innerHTML.includes('Workstation'), 'auth tile should show device name');
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
if (app._setSources) app._setSources([]);
});
test('renderGrid includes offline tile HTML when a source is unreachable', () => {
const mockGrid = { innerHTML: '' };
const mockEmpty = { style: {}, classList: { add: () => {}, remove: () => {} } };
const origGetById = globalThis.document.getElementById;
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.getElementById = (id) => {
if (id === 'session-grid') return mockGrid;
if (id === 'empty-state') return mockEmpty;
return null;
};
globalThis.document.querySelectorAll = () => [];
if (app._setSources) {
app._setSources([
{ url: '', name: 'Local', type: 'local', status: 'authenticated' },
{ url: 'http://dev:8088', name: 'Dev Server', type: 'remote', status: 'unreachable', lastSeenAt: Date.now() - 300000 },
]);
}
const sessions = [{ name: 'local-session', snapshot: '', deviceName: 'Local', sourceUrl: '' }];
app.renderGrid(sessions);
assert.ok(mockGrid.innerHTML.includes('source-tile--offline'), 'grid should include offline tile for unreachable source');
assert.ok(mockGrid.innerHTML.includes('Dev Server'), 'offline tile should show device name');
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
if (app._setSources) app._setSources([]);
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: 2 failures (grid doesn't include source status tiles yet).
**Step 3: Modify `renderGrid` to append source status tiles**
In `muxplex/frontend/app.js`, find the `renderGrid` function. After the line that sets `grid.innerHTML` (currently: `if (grid) grid.innerHTML = html;`), add logic to append status tiles for non-authenticated sources:
Replace the line:
```javascript
if (grid) grid.innerHTML = html;
```
With:
```javascript
// Append status tiles for auth_required and unreachable sources
var statusTilesHtml = '';
if (typeof _sources !== 'undefined' && _sources) {
_sources.forEach(function(source) {
if (source.status === 'auth_required') {
statusTilesHtml += buildAuthTileHTML(source);
} else if (source.status === 'unreachable') {
statusTilesHtml += buildOfflineTileHTML(source);
}
});
}
if (grid) grid.innerHTML = html + statusTilesHtml;
```
Also ensure `_setSources` is defined as a test helper (if not already from Phase 2). Near the other `_set*` helpers (around line 1630):
```javascript
/** Test-only: set _sources directly. */
function _setSources(sources) {
_sources = sources;
}
```
And add to `module.exports`:
```javascript
_setSources,
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): render auth-required and offline tiles in grid"
```
---
### Task 5: Login popup flow — click handler
**Files:**
- Modify: `muxplex/frontend/app.js` (add `openLoginPopup` + delegated click handler)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
Clicking the "Log in" button on an auth tile opens the remote instance's `/login` page in a popup window. No callback — the next poll cycle detects the new cookie.
**Step 1: Write the failing tests**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- openLoginPopup ---
test('openLoginPopup is exported as a function', () => {
assert.strictEqual(typeof app.openLoginPopup, 'function');
});
test('openLoginPopup calls window.open with correct URL and dimensions', () => {
let openCalledWith = null;
const origOpen = globalThis.window.open;
globalThis.window.open = (url, target, features) => {
openCalledWith = { url, target, features };
};
app.openLoginPopup('http://work:8088');
assert.ok(openCalledWith, 'window.open should have been called');
assert.strictEqual(openCalledWith.url, 'http://work:8088/login', 'should open /login on the remote URL');
assert.strictEqual(openCalledWith.target, '_blank');
assert.ok(openCalledWith.features.includes('width=500'), 'should set width=500');
assert.ok(openCalledWith.features.includes('height=600'), 'should set height=600');
globalThis.window.open = origOpen;
});
test('openLoginPopup appends /login to URL without trailing slash', () => {
let capturedUrl = null;
const origOpen = globalThis.window.open;
globalThis.window.open = (url) => { capturedUrl = url; };
app.openLoginPopup('http://work:8088');
assert.strictEqual(capturedUrl, 'http://work:8088/login');
globalThis.window.open = origOpen;
});
test('openLoginPopup handles URL with trailing slash', () => {
let capturedUrl = null;
const origOpen = globalThis.window.open;
globalThis.window.open = (url) => { capturedUrl = url; };
app.openLoginPopup('http://work:8088/');
assert.strictEqual(capturedUrl, 'http://work:8088/login');
globalThis.window.open = origOpen;
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: 4 failures.
**Step 3: Implement `openLoginPopup`**
Add this function in `app.js` right after `buildOfflineTileHTML`:
```javascript
/**
* Open the remote instance's login page in a popup window.
* After the user authenticates and closes the popup, the next poll cycle
* will detect the cookie and transition the source to authenticated.
* @param {string} remoteUrl - base URL of the remote instance (e.g. "http://work:8088")
*/
function openLoginPopup(remoteUrl) {
var baseUrl = remoteUrl.replace(/\/+$/, '');
window.open(baseUrl + '/login', '_blank', 'width=500,height=600');
}
```
Now wire up a delegated click handler for `.source-tile__login-btn` buttons. Add this inside `bindStaticEventListeners`, near the top (right after the existing delegated kill-session handler):
```javascript
// Delegated login button handler for federation auth-required tiles
document.addEventListener('click', function(e) {
var loginBtn = e.target.closest && e.target.closest('.source-tile__login-btn');
if (!loginBtn) return;
e.stopPropagation();
var url = loginBtn.dataset.url;
if (url) openLoginPopup(url);
});
```
Add `openLoginPopup` to `module.exports`:
```javascript
openLoginPopup,
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): add login popup flow for auth-required remote instances"
```
---
### Task 6: `terminal.js` — accept `sourceUrl` parameter for remote WebSocket
**Files:**
- Modify: `muxplex/frontend/terminal.js` (modify `connectWebSocket` and `openTerminal`)
- Modify: `muxplex/frontend/tests/test_terminal.mjs` (add tests)
Currently `connectWebSocket(name)` always builds the WebSocket URL from `location.host`. For remote sessions, it needs to connect to the remote instance's WebSocket instead.
**Step 1: Write the failing tests**
Add to the end of `muxplex/frontend/tests/test_terminal.mjs`:
```javascript
// --- Remote sourceUrl support ---
test('connectWebSocket uses remote origin when sourceUrl is provided', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
// openTerminal now accepts (sessionName, sourceUrl)
t.openTerminal('remote-session', 'http://work:8088');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok(
t.capturedWsUrl.startsWith('ws://work:8088/'),
`WebSocket URL should start with ws://work:8088/, got: ${t.capturedWsUrl}`,
);
assert.ok(
t.capturedWsUrl.endsWith('/terminal/ws'),
`WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`,
);
});
test('connectWebSocket uses local origin when sourceUrl is empty string', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('local-session', '');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok(
t.capturedWsUrl.includes('localhost'),
`WebSocket URL should use localhost for empty sourceUrl, got: ${t.capturedWsUrl}`,
);
});
test('connectWebSocket uses local origin when sourceUrl is undefined', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('local-session');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok(
t.capturedWsUrl.includes('localhost'),
`WebSocket URL should use localhost for undefined sourceUrl, got: ${t.capturedWsUrl}`,
);
});
test('connectWebSocket converts https sourceUrl to wss protocol', () => {
const t = loadTerminal();
const orig = globalThis.setTimeout;
globalThis.setTimeout = (_fn, _ms) => 0;
t.openTerminal('secure-session', 'https://devserver:8088');
globalThis.setTimeout = orig;
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
assert.ok(
t.capturedWsUrl.startsWith('wss://devserver:8088/'),
`HTTPS sourceUrl should produce wss:// WebSocket URL, got: ${t.capturedWsUrl}`,
);
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_terminal.mjs 2>&1 | tail -20
```
Expected: failures (openTerminal doesn't accept sourceUrl yet, so the remote URL tests fail).
Note: The `loadTerminal()` helper's `openTerminal` reference needs updating. Currently it captures `window._openTerminal` which only passes `name`. After our change, `window._openTerminal(name, sourceUrl)` will work — the test just calls `t.openTerminal('remote-session', 'http://work:8088')` which passes `sourceUrl` through.
**Step 3: Modify `connectWebSocket` to accept `sourceUrl`**
In `muxplex/frontend/terminal.js`, change `connectWebSocket(name)` to `connectWebSocket(name, sourceUrl)`:
Find the current URL construction (line ~15-16):
```javascript
function connectWebSocket(name) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/terminal/ws`;
```
Replace with:
```javascript
function connectWebSocket(name, sourceUrl) {
var url;
if (sourceUrl) {
// Remote session: derive WS URL from the source's HTTP URL
url = sourceUrl.replace(/^http/, 'ws').replace(/\/+$/, '') + '/terminal/ws';
} else {
// Local session: same origin
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = proto + '//' + location.host + '/terminal/ws';
}
```
Then modify `openTerminal` to accept and forward `sourceUrl`:
Change:
```javascript
function openTerminal(sessionName) {
```
To:
```javascript
function openTerminal(sessionName, sourceUrl) {
```
And change the call at the bottom of `openTerminal`:
```javascript
connectWebSocket(sessionName);
```
To:
```javascript
connectWebSocket(sessionName, sourceUrl);
```
And update the window exposure:
```javascript
window._openTerminal = openTerminal;
```
(This already works — the signature change is backward compatible.)
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_terminal.mjs 2>&1 | tail -20
```
Expected: all pass. Old tests still pass because calling `openTerminal('name')` without a second arg uses local origin (undefined → falsy → same-origin path).
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/terminal.js muxplex/frontend/tests/test_terminal.mjs && git commit -m "feat(federation): terminal.js supports remote WebSocket via sourceUrl parameter"
```
---
### Task 7: `openSession` — full remote routing
**Files:**
- Modify: `muxplex/frontend/app.js` (modify `openSession`)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
Remove the Phase 2 stub for remote sessions. For remote sessions: POST `/api/sessions/{name}/connect` on the **remote** instance (with `credentials: "include"`), then call `window._openTerminal(name, sourceUrl)`.
**Step 1: Write the failing tests**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- openSession remote routing ---
test('openSession with sourceUrl POSTs connect to remote instance URL', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
await app.openSession('work-project', { sourceUrl: 'http://work:8088' });
const connectCall = fetchCalls.find((c) => c.url === 'http://work:8088/api/sessions/work-project/connect');
assert.ok(connectCall, 'should POST to remote instance /api/sessions/work-project/connect');
assert.strictEqual(connectCall.opts.method, 'POST');
assert.strictEqual(connectCall.opts.credentials, 'include', 'must include credentials for cross-origin');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
test('openSession with sourceUrl passes sourceUrl to window._openTerminal', async () => {
let openTerminalArgs = null;
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = (name, sourceUrl) => { openTerminalArgs = { name, sourceUrl }; };
await app.openSession('remote-session', { sourceUrl: 'http://dev:8088' });
assert.ok(openTerminalArgs, '_openTerminal should have been called');
assert.strictEqual(openTerminalArgs.name, 'remote-session');
assert.strictEqual(openTerminalArgs.sourceUrl, 'http://dev:8088');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
test('openSession without sourceUrl still POSTs to local /api/sessions/{name}/connect', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
await app.openSession('local-session', {});
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
assert.ok(connectCall, 'should POST to local /api/sessions/local-session/connect');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: failures (openSession doesn't route to remote URLs yet).
**Step 3: Modify `openSession`**
In `muxplex/frontend/app.js`, modify the `openSession` function. The current `opts` parameter already exists (`opts.skipConnect`). We add `opts.sourceUrl`.
Find the connect block (around line 895-902):
```javascript
// Connect to session (kill old ttyd, spawn new one for this session)
try {
if (!opts.skipConnect) {
await api('POST', `/api/sessions/${name}/connect`);
}
} catch (err) {
showToast(err.message || 'Connection failed');
return closeSession();
}
```
Replace with:
```javascript
// Connect to session (kill old ttyd, spawn new one for this session)
var _sourceUrl = opts.sourceUrl || '';
try {
if (!opts.skipConnect) {
if (_sourceUrl) {
// Remote session: POST connect to the remote instance
var remoteConnectUrl = _sourceUrl.replace(/\/+$/, '') + '/api/sessions/' + encodeURIComponent(name) + '/connect';
await fetch(remoteConnectUrl, { method: 'POST', credentials: 'include' });
} else {
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
}
}
} catch (err) {
showToast(err.message || 'Connection failed');
return closeSession();
}
```
Then find the `_openTerminal` call (around line 908):
```javascript
if (window._openTerminal) window._openTerminal(name);
```
Replace with:
```javascript
if (window._openTerminal) window._openTerminal(name, _sourceUrl);
```
Also store the sourceUrl in a module-level variable so `closeSession` knows whether the current session is remote. Add near the other state variables (around line 121):
```javascript
let _viewingSourceUrl = '';
```
And in `openSession`, after `_viewingSession = name;`, add:
```javascript
_viewingSourceUrl = opts.sourceUrl || '';
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass. Note: the existing test `openSession without skipConnect POSTs to /api/sessions/{name}/connect` should still pass because the local path uses the same URL pattern (just with `encodeURIComponent`).
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): openSession routes connect POST and terminal to remote instances"
```
---
### Task 8: `closeSession` — remote-aware cleanup
**Files:**
- Modify: `muxplex/frontend/app.js` (modify `closeSession`)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests)
Per the design notes: for remote sessions, we do NOT call the remote's `/api/sessions/current` DELETE. We just disconnect the WebSocket and update local UI. The remote instance doesn't need to know we stopped watching.
**Step 1: Write the failing tests**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- closeSession remote-aware cleanup ---
test('closeSession does NOT fire DELETE for remote session (non-empty _viewingSourceUrl)', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { add: () => {}, remove: () => {} } });
globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); };
globalThis.window._openTerminal = () => {};
globalThis.window._closeTerminal = () => {};
// Open a remote session first to set _viewingSourceUrl
await app.openSession('remote-sess', { sourceUrl: 'http://work:8088' });
fetchCalls.length = 0; // reset
await app.closeSession();
await new Promise((r) => setTimeout(r, 0));
const deleteCall = fetchCalls.find((c) => c.opts && c.opts.method === 'DELETE');
assert.ok(!deleteCall, 'closeSession must NOT fire DELETE for remote sessions');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.setTimeout = origSetTimeout;
});
test('closeSession still fires DELETE /api/sessions/current for local session', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { add: () => {}, remove: () => {} } });
globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); };
globalThis.window._openTerminal = () => {};
globalThis.window._closeTerminal = () => {};
// Open a local session first (no sourceUrl)
await app.openSession('local-sess', {});
fetchCalls.length = 0;
await app.closeSession();
await new Promise((r) => setTimeout(r, 0));
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
assert.ok(deleteCall, 'closeSession must fire DELETE for local sessions');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.setTimeout = origSetTimeout;
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: The first test fails (DELETE is fired even for remote sessions).
**Step 3: Modify `closeSession`**
In `muxplex/frontend/app.js`, find the `closeSession` function. Change the DELETE logic from:
```javascript
// Fire-and-forget DELETE
api('DELETE', '/api/sessions/current').catch(() => {});
```
To:
```javascript
// Fire-and-forget DELETE — only for local sessions.
// Remote sessions: the remote instance doesn't need to know we stopped watching.
if (!_viewingSourceUrl) {
api('DELETE', '/api/sessions/current').catch(function() {});
}
```
Also reset `_viewingSourceUrl` in `closeSession`. After `_viewingSession = null;`, add:
```javascript
_viewingSourceUrl = '';
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): closeSession skips DELETE for remote sessions"
```
---
### Task 9: Grid click handler routes `sourceUrl` to `openSession`
**Files:**
- Modify: `muxplex/frontend/app.js` (modify click handler in `renderGrid`)
- Modify: `muxplex/frontend/tests/test_app.mjs` (add test)
Currently `renderGrid` binds click handlers that call `openSession(tile.dataset.session)`. For remote sessions, the tile needs a `data-source-url` attribute, and the click handler must pass it as `opts.sourceUrl`.
**Step 1: Write the failing test**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- Tile click passes sourceUrl ---
test('buildTileHTML includes data-source-url attribute when session has sourceUrl', () => {
const session = {
name: 'remote-work',
snapshot: '',
sourceUrl: 'http://work:8088',
deviceName: 'Workstation',
};
const html = app.buildTileHTML(session, 0, false);
assert.ok(
html.includes('data-source-url="http://work:8088"'),
'tile must include data-source-url attribute for remote sessions',
);
});
test('buildTileHTML does not include data-source-url for local sessions (empty sourceUrl)', () => {
const session = {
name: 'local-work',
snapshot: '',
sourceUrl: '',
deviceName: 'Laptop',
};
const html = app.buildTileHTML(session, 0, false);
assert.ok(
!html.includes('data-source-url'),
'tile must not include data-source-url for local sessions',
);
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: failures.
**Step 3: Modify `buildTileHTML` and `renderGrid`**
In `buildTileHTML`, add a `data-source-url` attribute to the `<article>` tag when the session has a non-empty `sourceUrl`. Find the return statement in `buildTileHTML` (around line 402):
```javascript
return (
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
```
Replace with:
```javascript
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
return (
`<article class="${classes}" data-session="${escapedName}"${sourceUrlAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
```
Then in `renderGrid`, modify the click handler to pass `sourceUrl`. Find (around line 633):
```javascript
on(tile, 'click', () => openSession(tile.dataset.session));
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session);
}
});
```
Replace with:
```javascript
on(tile, 'click', () => openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' }));
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' });
}
});
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat(federation): tile clicks pass sourceUrl through to openSession"
```
---
### Task 10: Auto-recovery detection tests
**Files:**
- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests verifying Phase 2 backoff/recovery behavior)
Phase 2 already implemented exponential backoff and source status transitions. Phase 3 verifies the auto-recovery path: when an unreachable source starts responding again, its status transitions back, and the grid updates on the next cycle. These are documentation/verification tests confirming the existing behavior works end-to-end.
**Step 1: Write the tests**
Add to `muxplex/frontend/tests/test_app.mjs`:
```javascript
// --- Auto-recovery detection ---
test('formatLastSeen returns seconds for recent timestamps', () => {
const now = Date.now();
const result = app.formatLastSeen(now - 30000);
assert.match(result, /^\d+s ago$/, 'should format as seconds ago');
});
test('formatLastSeen returns minutes for older timestamps', () => {
const result = app.formatLastSeen(Date.now() - 5 * 60 * 1000);
assert.match(result, /^\d+m ago$/, 'should format as minutes ago');
});
test('formatLastSeen returns hours for much older timestamps', () => {
const result = app.formatLastSeen(Date.now() - 3 * 3600 * 1000);
assert.match(result, /^\d+h ago$/, 'should format as hours ago');
});
test('formatLastSeen returns days for very old timestamps', () => {
const result = app.formatLastSeen(Date.now() - 2 * 86400 * 1000);
assert.match(result, /^\d+d ago$/, 'should format as days ago');
});
test('formatLastSeen returns Never for null', () => {
assert.strictEqual(app.formatLastSeen(null), 'Never');
});
test('formatLastSeen returns Never for undefined', () => {
assert.strictEqual(app.formatLastSeen(undefined), 'Never');
});
```
**Step 2: Run tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
```
Expected: all pass (formatLastSeen was implemented in Task 3).
**Step 3: Commit**
```bash
cd muxplex && git add muxplex/frontend/tests/test_app.mjs && git commit -m "test(federation): add formatLastSeen and auto-recovery verification tests"
```
---
### Task 11: End-to-end smoke verification
**Files:**
- No file changes — verification only
This task runs the full test suite to confirm nothing is broken and all Phase 3 functionality works.
**Step 1: Run all app.js tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs
```
Expected: all tests pass. No failures, no errors.
**Step 2: Run all terminal.js tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_terminal.mjs
```
Expected: all tests pass. No failures, no errors.
**Step 3: Verify CSS syntax**
```bash
cd muxplex && grep -c '{' muxplex/frontend/style.css && grep -c '}' muxplex/frontend/style.css
```
Expected: the counts should be equal (no unclosed braces).
**Step 4: Check for any new exported functions not in module.exports**
```bash
cd muxplex && grep -E '^function ' muxplex/frontend/app.js | head -40
```
Cross-reference with the `module.exports` block. Ensure `buildAuthTileHTML`, `buildOfflineTileHTML`, `formatLastSeen`, `openLoginPopup`, and `_setSources` are all exported.
**Step 5: Final commit (if any lint/format fixes were needed)**
```bash
cd muxplex && git add -A && git status
```
If clean: no commit needed. If there are formatting fixes:
```bash
cd muxplex && git commit -m "chore: phase 3 cleanup"
```
---
## Summary of Changes
| File | What changed |
|------|-------------|
| `muxplex/frontend/style.css` | Added `.source-tile`, `.source-tile--offline`, `.source-tile--auth`, and related child element styles |
| `muxplex/frontend/app.js` | Added `buildAuthTileHTML()`, `buildOfflineTileHTML()`, `formatLastSeen()`, `openLoginPopup()`; modified `renderGrid()` to append status tiles; modified `openSession()` to route remote connect POSTs and pass `sourceUrl` to terminal; modified `closeSession()` to skip DELETE for remote sessions; modified `buildTileHTML()` to emit `data-source-url`; added `_viewingSourceUrl` state; added `_setSources` test helper; added delegated login-button click handler |
| `muxplex/frontend/terminal.js` | Modified `connectWebSocket(name, sourceUrl)` to derive WS URL from remote origin; modified `openTerminal(sessionName, sourceUrl)` to forward parameter |
| `muxplex/frontend/tests/test_app.mjs` | Added ~30 new tests for auth tiles, offline tiles, login popup, remote openSession routing, remote closeSession behavior, formatLastSeen, sourceUrl in tiles |
| `muxplex/frontend/tests/test_terminal.mjs` | Added 4 tests for remote WebSocket URL derivation |