From 62ffcb6236f49473e74a74cee2c02e206ba44500 Mon Sep 17 00:00:00 2001 From: Ocean Bennett <204957658+undergroundrap@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:36:10 -0400 Subject: [PATCH] fix(cookbook): preserve same-host ssh profile selection (#3373) * fix(cookbook): preserve same-host ssh profile selection * fix(cookbook): resolve same-host ssh profiles in running tab and port lookups --- static/js/cookbook-hwfit.js | 32 ++++--- static/js/cookbook.js | 92 ++++++++++++++----- static/js/cookbookDownload.js | 15 +-- static/js/cookbookRunning.js | 16 +++- static/js/cookbookServe.js | 27 +++--- ...t_cookbook_same_host_server_profiles_js.py | 62 +++++++++++++ 6 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 tests/test_cookbook_same_host_server_profiles_js.py diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index 7d57d1c48..74571bae9 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -18,6 +18,8 @@ import { _lastCacheHost, _setLastCacheHost, _serverByVal, + _serverKey, + _currentServerValue, _shellQuote, _MODELDIR_CHECK_ON, _MODELDIR_CHECK_OFF, @@ -358,6 +360,7 @@ function _scanSig() { const tc = document.getElementById('hwfit-gpu-toggles'); return JSON.stringify({ h: _envState.remoteHost || '', + hk: _currentServerValue(), u: document.getElementById('hwfit-usecase')?.value || '', s: document.getElementById('hwfit-search')?.value?.trim() || '', o: sortEl?.value || 'score', @@ -467,9 +470,10 @@ export async function _hwfitFetch(fresh = false) { _hwfitCache = null; // no instant paint — clear until the fetch returns } // Only fetch cached model IDs when server changes, not on every search/sort - if (!_cachedModelIds || _lastCacheHost() !== remoteHost) { - _setLastCacheHost(remoteHost); - const _cacheSrv = _envState.servers.find(s => s.host === remoteHost); + const remoteKey = _currentServerValue(); + if (!_cachedModelIds || _lastCacheHost() !== remoteKey) { + _setLastCacheHost(remoteKey); + const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost); const _cachePort = _cacheSrv?.port || ''; const _cacheParams = new URLSearchParams({ host: remoteHost }); if (_cachePort) _cacheParams.set('ssh_port', _cachePort); if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform); fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' }) @@ -510,7 +514,7 @@ export async function _hwfitFetch(fresh = false) { if (search) params.set('search', search); if (remoteHost) { params.set('host', remoteHost); - const _srv = _envState.servers.find(s => s.host === remoteHost); + const _srv = _serverByVal(_envState.remoteServerKey || remoteHost); const _hp = _srv?.port || ''; if (_hp) params.set('ssh_port', _hp); if (_srv?.platform) params.set('platform', _srv.platform); @@ -1024,11 +1028,13 @@ function _syncHostFromScanDropdown() { let host = ''; if (ss.value === 'local') { _envState.remoteHost = ''; + _envState.remoteServerKey = ''; } else { const s = _serverByVal(ss.value); if (s) { host = s.host; _envState.remoteHost = s.host; + _envState.remoteServerKey = _serverKey(s); _envState.env = s.env; _envState.envPath = s.envPath; _envState.platform = s.platform || ''; @@ -1209,7 +1215,7 @@ export function _expandModelRow(row, modelData) { // Launch via serve API. Field names must match the backend ServeRequest // schema (repo_id + cmd) — sending `command`/`model` failed Pydantic // validation (422), which is why Run silently did nothing. - const _srv = (_envState.servers || []).find(s => s.host === host); + const _srv = _serverByVal(_envState.remoteServerKey || host); const payload = { repo_id: modelData.name, cmd: cmd, @@ -1428,7 +1434,7 @@ export function _hwfitInit() { // dropdown still showed odysseus. The user's selection must only change via // an explicit dropdown pick. Here we just refresh env/path if we can match // the current host; otherwise leave remoteHost untouched. - const sel = _envState.servers.find(s => s.host === _envState.remoteHost); + const sel = _serverByVal(_envState.remoteServerKey || _envState.remoteHost); if (sel) { _envState.env = sel.env; _envState.envPath = sel.envPath; } _persistEnvState(); } @@ -1604,15 +1610,16 @@ export function _hwfitInit() { // (inline — _applyServerSelection lives in cookbook.js and isn't imported here). const _dk = _envState.defaultServer; if (_dk) { - if (_dk === 'local') { _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; } - else { const _s = (_envState.servers || []).find(x => x.host === _dk); if (_s) { _envState.remoteHost = _s.host; _envState.env = _s.env || 'none'; _envState.envPath = _s.envPath || ''; _envState.platform = _s.platform || ''; } } + if (_dk === 'local') { _envState.remoteHost = ''; _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; } + else { const _s = _serverByVal(_dk); if (_s) { _envState.remoteHost = _s.host; _envState.remoteServerKey = _serverKey(_s); _envState.env = _s.env || 'none'; _envState.envPath = _s.envPath || ''; _envState.platform = _s.platform || ''; } } _persistEnvState(); document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { - if (sel && sel.tagName === 'SELECT') sel.value = _envState.remoteHost || 'local'; + if (sel && sel.tagName === 'SELECT') sel.value = _currentServerValue(); }); } + const defaultSrv = _serverByVal(_envState.defaultServer); uiModule.showToast(_envState.defaultServer - ? 'Default server: ' + (_envState.defaultServer === 'local' ? 'Local' : _envState.defaultServer) + ? 'Default server: ' + (_envState.defaultServer === 'local' ? 'Local' : (defaultSrv?.name || defaultSrv?.host || 'selected server')) : 'Default server cleared'); }); } @@ -1866,12 +1873,14 @@ export function _hwfitInit() { const val = serverSelect.value; if (val === 'local') { _envState.remoteHost = ''; + _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; } else { const s = _serverByVal(val); if (s) { _envState.remoteHost = s.host; + _envState.remoteServerKey = _serverKey(s); _envState.env = s.env; _envState.envPath = s.envPath; } @@ -1881,10 +1890,9 @@ export function _hwfitInit() { // download-input button reads #hwfit-dl-server *directly*, so without this // it kept its old value and downloads went to the wrong host even // though the scan here correctly switched to the selected server. - // Option values are host strings now ('local' for the local box). document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { if (!sel || sel.tagName !== 'SELECT') return; - sel.value = _envState.remoteHost || 'local'; + sel.value = _currentServerValue(); }); _hwfitCache = null; // Reset GPU-toggle state (no flicker) so the new server's hardware re-renders. diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 9ababdbce..0c51d0366 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -72,7 +72,7 @@ function _platformIcon(platform) { return ''; } -export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' }; +export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', remoteServerKey: '', servers: [], modelPaths: [], platform: '', defaultServer: '' }; let _lastCacheHostVal = null; let _cookbookOpeningSpinners = []; export function _lastCacheHost() { return _lastCacheHostVal; } @@ -114,18 +114,44 @@ function _setCookbookOpening(on) { // True for the local server entry (empty / "local" / "localhost" host). function _isLocalEntry(s) { return !s || !s.host || s.host === 'local' || s.host.toLowerCase() === 'localhost'; } -// Resolve a dropdown option value to a server entry. Option values are the -// stable HOST string ('local' for the local box) — NOT array indices — because -// `_envState.servers` gets deduped/reordered, which made index-based selection -// silently resolve to the wrong (or local) server. Accepts a numeric index too -// for backwards-compat with any stale value. +// Resolve a dropdown option value to a server entry. New option values are +// stable per-profile keys, so same-host SSH profiles stay distinguishable. +// Host strings and numeric indices remain accepted for stale saved state. +export function _serverKey(s) { + if (_isLocalEntry(s)) return 'local'; + return 'srv:' + [ + s?.name || '', + s?.host || '', + s?.port || '', + s?.envPath || '', + s?.platform || '', + ].map(v => encodeURIComponent(String(v).trim())).join('|'); +} + function _serverByVal(val) { if (val == null || val === 'local' || val === '') return null; - let s = _envState.servers.find(x => x.host === val); + const raw = String(val); + let s = _envState.servers.find(x => _serverKey(x) === raw); + if (!s) s = _envState.servers.find(x => x.host === raw); if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)]; return s || null; } +export function _selectedServer() { + if (_envState.remoteServerKey) { + const keyed = _serverByVal(_envState.remoteServerKey); + if (keyed) return keyed; + } + if (_envState.remoteHost) return _envState.servers.find(s => s.host === _envState.remoteHost) || null; + return null; +} + +export function _currentServerValue() { + const selected = _selectedServer(); + if (selected) return _serverKey(selected); + return _envState.remoteHost || 'local'; +} + function _buildServerOpts(excludeLocal = false) { // The local server is ALWAYS represented by the synthetic value="local" option // (showing its custom name from the "server name" feature). We must therefore @@ -134,13 +160,20 @@ function _buildServerOpts(excludeLocal = false) { const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null; const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local'; let html = ``; + const selectedKey = _envState.remoteServerKey || ''; + let legacyHostSelected = false; for (let i = 0; i < _envState.servers.length; i++) { const s = _envState.servers[i]; if (i === _localIdx) continue; // already the synthetic "local" option if (excludeLocal && _isLocalEntry(s)) continue; const label = s.name || s.host || `Server ${i + 1}`; - const selected = _envState.remoteHost === s.host ? ' selected' : ''; - html += ``; + const value = _serverKey(s); + let selected = selectedKey ? value === selectedKey : false; + if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) { + selected = true; + legacyHostSelected = true; + } + html += ``; } return html; } @@ -154,8 +187,9 @@ export function _sshCmd(host, cmd, port) { /** Get SSH port for a given host (or task object) */ function _getPort(hostOrTask) { if (!hostOrTask) return ''; - if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteHost); - const srv = _envState.servers.find(s => s.host === hostOrTask); + if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteServerKey || hostOrTask.remoteHost); + const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; + const srv = selected || _serverByVal(hostOrTask); return srv?.port || ''; } @@ -184,9 +218,10 @@ export function _getPlatform(hostOrTask) { if (!h || h === 'local') { return hostOrTask.platform || localPlatform(); } - return hostOrTask.platform || _getPlatform(h); + return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || h); } - const srv = _envState.servers.find(s => s.host === hostOrTask); + const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; + const srv = selected || _serverByVal(hostOrTask); return srv?.platform || ''; } @@ -929,6 +964,7 @@ async function _fetchDependencies() { function _applyServerSelection(val) { if (val === 'local') { _envState.remoteHost = ''; + _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; @@ -936,6 +972,7 @@ function _applyServerSelection(val) { const s = _serverByVal(val); if (s) { _envState.remoteHost = s.host; + _envState.remoteServerKey = _serverKey(s); _envState.env = s.env || 'none'; _envState.envPath = s.envPath || ''; _envState.platform = s.platform || ''; @@ -946,10 +983,9 @@ function _applyServerSelection(val) { // bug: the Download/Cache/Deps dropdowns set the host but never saved it, so // it silently reverted and downloads/scans hit the wrong server). _persistEnvState(); - const _want = _envState.remoteHost || 'local'; + const _want = _currentServerValue(); document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { if (!sel || sel.tagName !== 'SELECT') return; - // Option values are host strings now ('local' for the local box). sel.value = _want; // If the host isn't among this select's current options (stale options after // the server list changed), the browser leaves the box BLANK/grey even though @@ -957,7 +993,7 @@ function _applyServerSelection(val) { // re-apply; fall back to 'local' only if it's genuinely gone. if (sel.selectedIndex < 0) { sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server'); - sel.value = _want; + sel.value = _currentServerValue(); if (sel.selectedIndex < 0) sel.value = 'local'; } }); @@ -1045,11 +1081,13 @@ function _wireTabEvents(body) { const remotes = servers.filter(s => !_isLocalEntry(s)); if (remotes.length === 1) { _envState.remoteHost = remotes[0].host; + _envState.remoteServerKey = _serverKey(remotes[0]); _envState.env = remotes[0].env || 'none'; _envState.envPath = remotes[0].envPath || ''; } } - const activeSrv = servers.find(s => s.host === _envState.remoteHost); + const activeSrv = _selectedServer(); + if (activeSrv) _envState.remoteServerKey = _serverKey(activeSrv); _envState.platform = activeSrv?.platform || ''; localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage())); _saveTasks(_loadTasks()); @@ -1057,7 +1095,7 @@ function _wireTabEvents(body) { // UI matches the resolved host. Done in a microtask so the dropdowns // exist by the time we set their .value. Promise.resolve().then(() => { - const _want = _envState.remoteHost || 'local'; + const _want = _currentServerValue(); document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { if (sel && sel.tagName === 'SELECT') sel.value = _want; }); @@ -1345,7 +1383,7 @@ function _wireTabEvents(body) { if (srvVal !== 'local') { host = _serverByVal(srvVal)?.host || ''; } - const _hsrv = _envState.servers.find(sv => sv.host === host) || {}; + const _hsrv = srvVal !== 'local' ? (_serverByVal(srvVal) || {}) : {}; let env = host ? (_hsrv.env || 'none') : _envState.env; let envPath = host ? (_hsrv.envPath || '') : _envState.envPath; const payload = { repo_id: repo }; @@ -1605,8 +1643,9 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { let html = ''; html += `
`; const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`)); - const _srvKey = isLocal ? 'local' : (s.host || ''); - const _isDefaultSrv = (defaultServer || '') === _srvKey; + const _srvKey = isLocal ? 'local' : _serverKey(s); + const _legacyDefault = !String(defaultServer || '').startsWith('srv:') && !isLocal && (defaultServer || '') === (s.host || ''); + const _isDefaultSrv = (defaultServer || '') === _srvKey || _legacyDefault; const _pIco = _platformIcon(s.platform); const _keyBtn = ``; const _checkBtn = ``; @@ -1846,7 +1885,7 @@ function _renderRecipes() { html += '
'; html += '

Serve

'; html += '
'; - const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {}; + const _selSrv = _selectedServer() || _es.servers[0] || {}; const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean); html += '
'; html += _srvDirs.map(d => `${esc(d)}`).join(''); @@ -2052,10 +2091,10 @@ export async function open(opts) { if (_envState.defaultServer) { const _dk = _envState.defaultServer; if (_dk === 'local') { - _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; + _envState.remoteHost = ''; _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; } else { - const _ds = (_envState.servers || []).find(s => s.host === _dk); - if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } + const _ds = _serverByVal(_dk); + if (_ds) { _envState.remoteHost = _ds.host; _envState.remoteServerKey = _serverKey(_ds); _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } } } // Re-render on every open AFTER sync so the freshly-fetched state (servers, @@ -2178,6 +2217,9 @@ const shared = { _getPort, _sshPrefix, _getPlatform, + _serverByVal, + _selectedServer, + _currentServerValue, _isWindows, _isMetal, _buildEnvPrefix, diff --git a/static/js/cookbookDownload.js b/static/js/cookbookDownload.js index b15e909c4..6c155c8d7 100644 --- a/static/js/cookbookDownload.js +++ b/static/js/cookbookDownload.js @@ -12,6 +12,7 @@ let _envState; let _sshCmd; let _getPort; let _getPlatform; +let _serverByVal; let _isWindows; let _buildEnvPrefix; let _buildServeCmd; @@ -118,7 +119,7 @@ export function _buildDownloadCmd(model, backend) { const includeArg = includePattern ? `, allow_patterns=["${includePattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` : ''; // Reflect the server's download target in the preview (matches the real // download path built server-side). '' = default HF cache. - const _dlDir = (_envState.servers.find(s => s.host === (_envState.remoteHost || '')) || {}).downloadDir || ''; + const _dlDir = (_serverByVal?.(_envState.remoteServerKey || _envState.remoteHost || '') || {}).downloadDir || ''; const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : ''; const _py = _isWindows() ? 'python' : 'python3'; cmd = `${_py} -u -c " @@ -475,10 +476,10 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { // No explicit host passed: resolve from the visible server dropdown rather // than _envState.remoteHost (unreliable — multiple state copies disagree). const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); - // Dropdown values are host strings now ('local' for local); resolve by host - // (numeric fallback for any stale value). + // Dropdown values are profile keys now ('local' for local); stale host + // strings and numeric indices still resolve for backwards compatibility. const _ssv = ssEl ? ssEl.value : null; - const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null; + const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null; if (_dsrv) { host = _dsrv.host; } else if (ssEl && ssEl.value === 'local') { @@ -487,7 +488,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { host = _envState.remoteHost || ''; } } - const srv = _envState.servers.find(s => s.host === host) || {}; + const srv = _serverByVal?.(_envState.remoteServerKey || host) || {}; const env = host ? (srv.env || 'none') : (_envState.env || 'none'); const envPath = host ? (srv.envPath || '') : (_envState.envPath || ''); const platform = host ? (srv.platform || '') : (_envState.platform || ''); @@ -546,7 +547,8 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { if (zombieCandidate) { try { const _zh = zombieCandidate.remoteHost || ''; - const _zPort = (_envState.servers || []).find(s => s.host === _zh)?.port; + const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh) + || (_envState.servers || []).find(s => s.host === _zh) || {}).port; const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : ''; const _sshSf = _zh ? `'` : ''; const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`; @@ -615,6 +617,7 @@ export function initDownload(shared) { _sshCmd = shared._sshCmd; _getPort = shared._getPort; _getPlatform = shared._getPlatform; + _serverByVal = shared._serverByVal; _isWindows = shared._isWindows; _buildEnvPrefix = shared._buildEnvPrefix; _buildServeCmd = shared._buildServeCmd; diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 425430989..a4e7b83eb 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -255,6 +255,8 @@ let _savePresets; let _copyText; let _persistEnvState; let _refreshDependencies; +let _serverByVal; +let _selectedServer; let modelLogo; let esc; let _detectBackend; @@ -1263,7 +1265,8 @@ async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) { // Switch the active server to the one this serve ran on (mirrors _openEdit). const _tHost = task.remoteHost || ''; _envState.remoteHost = _tHost; - const _tSrv = _envState.servers.find(s => s.host === _tHost); + const _tSrv = _serverByVal(_envState.remoteServerKey || _tHost) + || _envState.servers.find(s => s.host === _tHost); if (_tSrv) { _envState.env = _tSrv.env || 'none'; _envState.envPath = _tSrv.envPath || ''; _envState.platform = _tSrv.platform || ''; } else if (!_tHost) { _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; } document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { @@ -1473,7 +1476,8 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid // up that server's port/platform from the shared servers list. Only fall back // to _envState.remoteHost for legacy callers (diagnosis/pip-update). const _host = (hostOverride !== undefined) ? (hostOverride || '') : (_envState.remoteHost || ''); - const _hsrv = _envState.servers.find(s => s.host === _host) || {}; + const _hsrv = _serverByVal(_envState.remoteServerKey || _host) + || _envState.servers.find(s => s.host === _host) || {}; const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || ''); // Replace any serve already targeting this same host:port — you can't run two @@ -1700,7 +1704,8 @@ export function _renderRunningTab() { // Group tasks by server const _serverName = (host) => { if (!host) return 'Local'; - const srv = _envState.servers.find(s => s.host === host); + const srv = _serverByVal(_envState.remoteServerKey || host) + || _envState.servers.find(s => s.host === host); return srv?.name || host; }; const serverGroups = {}; @@ -1971,7 +1976,8 @@ export function _renderRunningTab() { // Point the active server at the one it downloaded to. const _tHost = task.remoteHost || ''; _envState.remoteHost = _tHost; - const _tSrv = _envState.servers.find(s => s.host === _tHost); + const _tSrv = _serverByVal(_envState.remoteServerKey || _tHost) + || _envState.servers.find(s => s.host === _tHost); if (_tSrv) { _envState.env = _tSrv.env || 'none'; _envState.envPath = _tSrv.envPath || ''; _envState.platform = _tSrv.platform || ''; } else if (!_tHost) { _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; } document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { @@ -3707,6 +3713,8 @@ export function initRunning(shared) { _copyText = shared._copyText; _persistEnvState = shared._persistEnvState; _refreshDependencies = shared._refreshDependencies; + _serverByVal = shared._serverByVal; + _selectedServer = shared._selectedServer; modelLogo = shared.modelLogo; esc = shared.esc; _detectBackend = shared._detectBackend; diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index 69a912c0e..3f7e53916 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -14,6 +14,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; let _envState; let _sshCmd; let _getPort; +let _serverByVal; let _sshPrefix; let _getPlatform; let _isWindows; @@ -97,14 +98,14 @@ function _selectedServeTarget(panel) { const select = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); const servers = Array.isArray(_envState.servers) ? _envState.servers : []; let host = _envState.remoteHost || ''; - let server = host ? servers.find(s => s.host === host) : null; + let server = host ? (_serverByVal?.(_envState.remoteServerKey || host) || servers.find(s => s.host === host)) : null; if (select && select.value != null) { if (select.value === 'local') { host = ''; server = servers.find(s => !s.host || s.host === 'local') || null; } else { const idx = /^\d+$/.test(String(select.value)) ? parseInt(select.value, 10) : -1; - server = servers.find(s => s.host === select.value) || (idx >= 0 ? servers[idx] : null) || null; + server = _serverByVal?.(select.value) || (idx >= 0 ? servers[idx] : null) || null; host = server?.host || ''; } } @@ -114,7 +115,7 @@ function _selectedServeTarget(panel) { : (server?.name || 'local server'); return { host, - port: host ? (_getPort(host) || server?.port || '') : '', + port: host ? (server?.port || _getPort(host) || '') : '', venv, label, }; @@ -536,7 +537,7 @@ function _rerenderCachedModels() { // The venv set per-server in Settings (server.envPath). Used as the venv // field default when the global active env path isn't carrying it, so a // configured server venv shows up without re-typing it. - const _selSrv = (_es.servers || []).find(s => s.host === (_es.remoteHost || '')) || {}; + const _selSrv = _serverByVal?.(_es.remoteServerKey || _es.remoteHost || '') || {}; const _srvVenv = _selSrv.envPath || ''; // Serve state schema: { _byRepo: { : {...} }, _lastUsed: {...} }. // Loading priority: this-repo's saved settings → last-used (from any @@ -894,10 +895,11 @@ function _rerenderCachedModels() { if (!wrap) return; try { const host = (_es.remoteHost || '').trim(); + const selected = _serverByVal?.(_es.remoteServerKey || host); const params = new URLSearchParams({ model: repo }); if (host) { params.set('host', host); - const _sp = (_es.servers || []).find(s => s.host === host)?.port; + const _sp = selected?.port; if (_sp) params.set('ssh_port', _sp); } // SERVE mode: this is a specific GGUF file already on disk, so its quant @@ -960,10 +962,11 @@ function _rerenderCachedModels() { if (!el || !document.body.contains(el)) return false; // panel closed → stop try { const host = (_es.remoteHost || '').trim(); + const selected = _serverByVal?.(_es.remoteServerKey || host); const params = new URLSearchParams(); if (host) { params.set('host', host); - const _sp = (_es.servers || []).find(s => s.host === host)?.port; + const _sp = selected?.port; if (_sp) params.set('ssh_port', _sp); } const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : '')); @@ -1787,7 +1790,7 @@ function _rerenderCachedModels() { const _probeParams = new URLSearchParams(); if (_probeHost) { _probeParams.set('host', _probeHost); - const _sp = (_envState.servers || []).find(s => s.host === _probeHost)?.port; + const _sp = (_serverByVal?.(_envState.remoteServerKey || _probeHost) || {}).port; if (_sp) _probeParams.set('ssh_port', _sp); } const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' }); @@ -1879,8 +1882,7 @@ function _rerenderCachedModels() { if (_ssEl && _ssEl.value != null) { if (_ssEl.value === 'local') serveHost = ''; else { - // Values are host strings now; resolve by host (numeric fallback). - const _srv = _envState.servers.find(s => s.host === _ssEl.value) || _envState.servers[parseInt(_ssEl.value)]; + const _srv = _serverByVal?.(_ssEl.value) || _envState.servers[parseInt(_ssEl.value)]; if (_srv) { serveHost = _srv.host; _srvEnv = _srv.env || ''; @@ -1939,7 +1941,7 @@ function _resolveCacheHost() { if (cacheSrv) { const val = cacheSrv.value; if (val === 'local') host = ''; - else { const s = _envState.servers.find(x => x.host === val) || _envState.servers[parseInt(val)]; if (s) host = s.host; } + else { const s = _serverByVal?.(val) || _envState.servers[parseInt(val)]; if (s) host = s.host; } } return host; } @@ -2135,11 +2137,11 @@ export async function _fetchCachedModels() { host = ''; selectedServer = _envState.servers.find(s => !s.host || s.host === 'local') || _envState.servers[0]; } else { - const s = _envState.servers.find(x => x.host === val) || _envState.servers[parseInt(val)]; + const s = _serverByVal?.(val) || _envState.servers[parseInt(val)]; if (s) { host = s.host; selectedServer = s; } } } else { - selectedServer = _envState.servers.find(s => s.host === host) || _envState.servers[0]; + selectedServer = _serverByVal?.(_envState.remoteServerKey || host) || _envState.servers[0]; } // Read extra model dirs from the SELECTED server's modelDirs (canonical source) const modelDirs = []; @@ -2266,6 +2268,7 @@ export function initServe(shared) { _envState = shared._envState; _sshCmd = shared._sshCmd; _getPort = shared._getPort; + _serverByVal = shared._serverByVal; _sshPrefix = shared._sshPrefix; _getPlatform = shared._getPlatform; _isWindows = shared._isWindows; diff --git a/tests/test_cookbook_same_host_server_profiles_js.py b/tests/test_cookbook_same_host_server_profiles_js.py new file mode 100644 index 000000000..de9649fd6 --- /dev/null +++ b/tests/test_cookbook_same_host_server_profiles_js.py @@ -0,0 +1,62 @@ +"""Regression guards for same-host Cookbook SSH server profiles (#3337).""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +COOKBOOK = (ROOT / "static/js/cookbook.js").read_text(encoding="utf-8") +HWFIT = (ROOT / "static/js/cookbook-hwfit.js").read_text(encoding="utf-8") +DOWNLOAD = (ROOT / "static/js/cookbookDownload.js").read_text(encoding="utf-8") +SERVE = (ROOT / "static/js/cookbookServe.js").read_text(encoding="utf-8") +RUNNING = (ROOT / "static/js/cookbookRunning.js").read_text(encoding="utf-8") + + +def test_server_dropdown_options_use_profile_keys_not_hosts(): + assert "remoteServerKey" in COOKBOOK + assert "export function _serverKey(s)" in COOKBOOK + assert "s?.name || ''" in COOKBOOK + assert "s?.host || ''" in COOKBOOK + assert "s?.port || ''" in COOKBOOK + assert "s?.envPath || ''" in COOKBOOK + assert 'const value = _serverKey(s);' in COOKBOOK + assert 'option value="${esc(s.host)}"' not in COOKBOOK + + +def test_selected_server_helpers_prefer_profile_key_before_host_fallback(): + assert "_envState.remoteServerKey = _serverKey(s);" in COOKBOOK + assert "const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null;" in COOKBOOK + assert "const srv = selected || _serverByVal(hostOrTask);" in COOKBOOK + assert "const _want = _currentServerValue();" in COOKBOOK + + +def test_cookbook_submodules_resolve_visible_profile_selection(): + assert "_serverByVal?.(_ssv)" in DOWNLOAD + assert "_serverByVal?.(_envState.remoteServerKey || host)" in DOWNLOAD + assert "_serverByVal?.(_envState.remoteServerKey || _zh)" in DOWNLOAD + assert "_serverByVal(_envState.remoteServerKey || remoteHost)" in HWFIT + assert "hk: _currentServerValue()" in HWFIT + assert "sel.value = _currentServerValue();" in HWFIT + assert "_serverByVal?.(_ssEl.value)" in SERVE + assert "_serverByVal?.(val)" in SERVE + assert "_serverByVal?.(_es.remoteServerKey || _es.remoteHost || '')" in SERVE + assert "_serverByVal?.(_envState.remoteServerKey || _probeHost)" in SERVE + + +def test_running_tab_resolves_profile_key_not_first_host(): + assert "_serverByVal(_envState.remoteServerKey || _tHost)" in RUNNING + assert "_serverByVal(_envState.remoteServerKey || _host)" in RUNNING + assert "_serverByVal(_envState.remoteServerKey || host)" in RUNNING + assert "_serverByVal = shared._serverByVal;" in RUNNING + assert "_selectedServer = shared._selectedServer;" in RUNNING + + +def test_no_same_host_selector_paths_resolve_by_first_matching_host(): + forbidden = [ + "servers.find(s => s.host === select.value)", + "servers.find(s => s.host === _ssEl.value)", + "servers.find(x => x.host === val)", + "servers.find(s => s.host === _ssv)", + ] + combined = "\n".join([DOWNLOAD, HWFIT, SERVE]) + for needle in forbidden: + assert needle not in combined