feat: update frontend to use device_id-based session keys and API URLs

- buildTileHTML: prefer session.deviceId over legacy integer remoteId for data-remote-id
- buildSidebarHTML: prefer session.deviceId over legacy integer remoteId for data-remote-id
- getVisibleSessions: check s.sessionKey (device_id:name) in hidden_sessions list for
  backward compatibility with both old (plain name) and new (device_id:name) formats
- openSession: rename _remoteId → _deviceId to reflect value is now a device_id string
- createNewSession: rename internal remoteId variable → deviceId for clarity

All federation API URLs continue to work via opts.remoteId (now contains deviceId value).
Backward compatibility maintained: fallback to remoteId when deviceId not present (old server).

Tests: updated test_open_session_fires_bell_clear_for_remote to check _deviceId guard;
added 5 new tests covering all structural changes.
This commit is contained in:
Brian Krabach
2026-04-15 13:59:03 -07:00
parent 5510d9d008
commit 68fa80b694
2 changed files with 125 additions and 18 deletions
+20 -15
View File
@@ -444,7 +444,9 @@ function buildTileHTML(session, index, mobile) {
} }
const lastLines = allLines.slice(_lineCount).join('\n'); const lastLines = allLines.slice(_lineCount).join('\n');
const remoteIdAttr = session.remoteId != null ? ` data-remote-id="${escapeHtml(session.remoteId)}"` : ''; // Prefer deviceId (device_id string from backend) over legacy integer remoteId
var _effRemoteId = session.deviceId != null ? session.deviceId : session.remoteId;
const remoteIdAttr = _effRemoteId != null ? ` data-remote-id="${escapeHtml(_effRemoteId)}"` : '';
return ( return (
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` + `<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
`<div class="tile-header">` + `<div class="tile-header">` +
@@ -498,8 +500,10 @@ function buildSidebarHTML(session, currentSession) {
} }
const lastLines = allLines.slice(-20).join('\n'); const lastLines = allLines.slice(-20).join('\n');
// Prefer deviceId (device_id string from backend) over legacy integer remoteId
var _sidebarEffRemoteId = session.deviceId != null ? session.deviceId : (session.remoteId != null ? session.remoteId : '');
return ( return (
`<article class="${classes}" data-session="${escapedName}" data-remote-id="${escapeHtml(session.remoteId != null ? session.remoteId : '')}" tabindex="0" role="listitem">` + `<article class="${classes}" data-session="${escapedName}" data-remote-id="${escapeHtml(_sidebarEffRemoteId)}" tabindex="0" role="listitem">` +
`<div class="sidebar-item-header">` + `<div class="sidebar-item-header">` +
`<span class="sidebar-item-name">${escapedName}</span>` + `<span class="sidebar-item-name">${escapedName}</span>` +
badgeHtml + badgeHtml +
@@ -539,7 +543,7 @@ function getVisibleSessions(sessions) {
return (sessions || []).filter(function(s) { return (sessions || []).filter(function(s) {
// Skip status entries (unreachable, auth_failed) — rendered separately as status tiles // Skip status entries (unreachable, auth_failed) — rendered separately as status tiles
if (s.status) return false; if (s.status) return false;
if (hidden.length > 0 && hidden.includes(s.name)) { if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) {
return false; return false;
} }
return true; return true;
@@ -1227,11 +1231,12 @@ async function openSession(name, opts = {}) {
if (fab) fab.classList.add('hidden'); if (fab) fab.classList.add('hidden');
// Always spawn ttyd for this session — ensures correct session after service restart or page restore // Always spawn ttyd for this session — ensures correct session after service restart or page restore
var _remoteId = opts.remoteId != null ? opts.remoteId : ''; // _deviceId holds the device_id string (was integer remoteId index in old protocol)
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
try { try {
if (_remoteId !== '') { if (_deviceId !== '') {
// Remote session: route connect POST through same-origin federation proxy // Remote session: route connect POST through same-origin federation proxy
await api('POST', '/api/federation/' + encodeURIComponent(_remoteId) + '/connect/' + encodeURIComponent(name)); await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
} else { } else {
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect'); await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
} }
@@ -1241,18 +1246,18 @@ async function openSession(name, opts = {}) {
} }
// Persist active_remote_id so restoreState() can reopen remote sessions after page refresh // Persist active_remote_id so restoreState() can reopen remote sessions after page refresh
api('PATCH', '/api/state', { active_session: name, active_remote_id: _remoteId || null }).catch(function() {}); api('PATCH', '/api/state', { active_session: name, active_remote_id: _deviceId || null }).catch(function() {});
// Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server // Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server
if (_remoteId !== '') { if (_deviceId !== '') {
api('POST', '/api/federation/' + encodeURIComponent(_remoteId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {}); api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {});
} }
// Wait for animation to finish (may already be done if /connect was slow) // Wait for animation to finish (may already be done if /connect was slow)
await animDone; await animDone;
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name, _remoteId, getDisplaySettings().fontSize); if (window._openTerminal) window._openTerminal(name, _deviceId, getDisplaySettings().fontSize);
} }
/** /**
@@ -2087,9 +2092,9 @@ function showFabSessionInput() {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function createNewSession(name, remoteId) { async function createNewSession(name, remoteId) {
remoteId = remoteId || ''; var deviceId = remoteId || ''; // Accept device_id string (was integer index in old protocol)
try { try {
var endpoint = remoteId ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions' : '/api/sessions'; var endpoint = deviceId ? '/api/federation/' + encodeURIComponent(deviceId) + '/sessions' : '/api/sessions';
const res = await api('POST', endpoint, { name }); const res = await api('POST', endpoint, { name });
const data = await res.json(); const data = await res.json();
const sessionName = data.name || name; const sessionName = data.name || name;
@@ -2122,8 +2127,8 @@ async function createNewSession(name, remoteId) {
return; return;
} }
// Compute expectedKey: for remote sessions, use 'remoteId:sessionName' (sessionKey format) // Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
var expectedKey = remoteId ? (remoteId + ':' + sessionName) : sessionName; var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
// Poll until the session appears in _currentSessions (max 30s, every 2s) // Poll until the session appears in _currentSessions (max 30s, every 2s)
var attempts = 0; var attempts = 0;
@@ -2138,7 +2143,7 @@ async function createNewSession(name, remoteId) {
clearInterval(pollForSession); clearInterval(pollForSession);
removeLoadingTile(); removeLoadingTile();
showToast('Session \'' + sessionName + '\' ready'); showToast('Session \'' + sessionName + '\' ready');
openSession(sessionName, { remoteId: remoteId }); openSession(sessionName, { remoteId: deviceId });
} else if (attempts >= maxAttempts) { } else if (attempts >= maxAttempts) {
clearInterval(pollForSession); clearInterval(pollForSession);
removeLoadingTile(); removeLoadingTile();
+105 -3
View File
@@ -2580,9 +2580,9 @@ def test_open_session_fires_bell_clear_for_remote() -> None:
assert "/bell/clear" in body, ( assert "/bell/clear" in body, (
"openSession must POST to /api/federation/{remoteId}/sessions/{name}/bell/clear" "openSession must POST to /api/federation/{remoteId}/sessions/{name}/bell/clear"
) )
# Must guard bell-clear with a _remoteId !== '' check # Must guard bell-clear with a _deviceId !== '' check (renamed from _remoteId in task-11)
assert "_remoteId !== ''" in body, ( assert "_deviceId !== ''" in body, (
"openSession must guard bell-clear POST with _remoteId !== '' check" "openSession must guard bell-clear POST with _deviceId !== '' check"
) )
@@ -2770,3 +2770,105 @@ def test_update_favicon_badge_filters_through_visible_sessions() -> None:
"updateFaviconBadge must filter sessions through getVisibleSessions() " "updateFaviconBadge must filter sessions through getVisibleSessions() "
"to exclude hidden sessions from the bell activity check" "to exclude hidden sessions from the bell activity check"
) )
# ─── Task 11: Frontend — Change Session Key Format to `device_id:name` ────────
def test_build_tile_html_prefers_device_id_for_data_remote_id() -> None:
"""buildTileHTML must use session.deviceId (falling back to session.remoteId) for data-remote-id.
The backend now sends deviceId (device_id string) alongside remoteId on every session.
The tile's data-remote-id must prefer deviceId so the click handler passes the correct
device_id to openSession(), enabling federation API calls with the new device_id format.
"""
match = re.search(
r"function buildTileHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*\*)",
_JS,
re.DOTALL,
)
assert match, "buildTileHTML function not found"
body = match.group(1)
assert "session.deviceId" in body, (
"buildTileHTML must use session.deviceId for data-remote-id attribute "
"(with fallback to session.remoteId for backward compatibility with old servers)"
)
def test_build_sidebar_html_prefers_device_id_for_data_remote_id() -> None:
"""buildSidebarHTML must use session.deviceId (falling back to session.remoteId) for data-remote-id.
The sidebar click handler reads data-remote-id and passes it to openSession().
Must prefer deviceId so federation API calls use the new device_id:name format.
"""
match = re.search(
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*\*)",
_JS,
re.DOTALL,
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert "session.deviceId" in body, (
"buildSidebarHTML must use session.deviceId for data-remote-id attribute "
"(with fallback to session.remoteId for backward compatibility with old servers)"
)
def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
"""getVisibleSessions must check sessionKey (as well as name) against the hidden_sessions list.
hidden_sessions may contain either:
- old format: plain session name (e.g. 'dev')
- new format: device_id:name key (e.g. 'abc-123:dev')
Both formats must be matched for backward compatibility.
"""
match = re.search(
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
_JS,
re.DOTALL,
)
assert match, "getVisibleSessions function not found"
body = match.group(1)
assert "sessionKey" in body, (
"getVisibleSessions must check s.sessionKey (in addition to s.name) against hidden_sessions "
"for backward compatibility: hidden_sessions may contain plain names OR device_id:name keys"
)
def test_open_session_uses_device_id_variable_for_federation() -> None:
"""openSession must use _deviceId variable (from opts.remoteId) for federation API calls.
The local variable _remoteId is renamed to _deviceId to reflect that the value
is now a device_id string, not an integer remote instance index.
"""
match = re.search(
r"async function openSession\s*\(.*?\)\s*\{(.*?)^\}",
_JS,
re.DOTALL | re.MULTILINE,
)
assert match, "openSession function not found"
body = match.group(1)
assert "_deviceId" in body, (
"openSession must use _deviceId variable (assigned from opts.remoteId) "
"for federation API URL construction — reflects that the value is now a device_id string"
)
def test_create_new_session_uses_device_id_variable_internally() -> None:
"""createNewSession must use a deviceId variable internally for federation endpoint construction.
The internal variable is renamed from 'remoteId' to 'deviceId' to reflect that
the value is now a device_id string (e.g. 'abc-123'), not an integer index.
"""
match = re.search(
r"async function createNewSession\s*\([\w,\s]+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "createNewSession function not found"
body = match.group(1)
assert "var deviceId" in body, (
"createNewSession must declare 'var deviceId' internally (assigned from the remoteId parameter) "
"to reflect that the value is now a device_id string used in federation API URLs"
)