From 2b2bf0fb908ba510cedc21002eeeb95af532daae Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:36:47 +0100 Subject: [PATCH] fix(ui): refine provider endpoint controls --- static/js/admin.js | 82 ++++++++--- static/style.css | 68 ++++++++- tests/test_endpoint_card_ui_polish.py | 193 ++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 tests/test_endpoint_card_ui_polish.py diff --git a/static/js/admin.js b/static/js/admin.js index e302acfd6..dd00c59d3 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -599,6 +599,44 @@ function _ensureEndpointBulkControls() { _updateEndpointBulkControls(); } +function shouldDisplayEndpointBaseUrl(ep) { + if (!ep || !ep.base_url) return false; + if (isChatgptSubscriptionEndpoint(ep)) return false; + if (isFeatherlessEndpoint(ep)) return false; + return true; +} + +function isFeatherlessEndpoint(ep) { + if (!ep) return false; + if (ep.provider === 'featherless') return true; + const url = String(ep.base_url || '').toLowerCase(); + try { + const host = new URL(url).hostname; + return host === 'api.featherless.ai' || host.endsWith('.featherless.ai'); + } catch (_) { + return url.includes('api.featherless.ai'); + } +} + +function endpointDetailHtml(ep, category) { + const showUrl = shouldDisplayEndpointBaseUrl(ep); + const copyBtn = (showUrl && category === 'local') + ? `` + : ''; + const keyLabel = ep.has_key + ? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)') + : ''; + const parts = []; + if (showUrl) { + parts.push(esc(ep.base_url) + copyBtn); + if (keyLabel) parts.push(keyLabel); + } else if (keyLabel) { + parts.push(keyLabel.trim()); + } + if (!parts.length) return ''; + return `
${parts.join('')}
`; +} + // ChatGPT per-endpoint usage panel expanded state persistence. // Preserves only endpoint/auth identifiers, never tokens, secrets, or labels. const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded'; @@ -690,20 +728,24 @@ async function loadEndpoints() { // `ep.models` is the *visible* set — when every model is hidden it's // empty, but we still need to render the expand panel so the user can // un-hide them. Gate on the total instead. - const hasModels = ep.online && totalCount > 0; + const isChatgptAccount = isChatgptSubscriptionEndpoint(ep); + const isFeatherless = isFeatherlessEndpoint(ep); + const hasModels = ep.online && (totalCount > 0 || isFeatherless); + const countText = (isChatgptAccount || isFeatherless) + ? `${visibleCount} models enabled` + : `${visibleCount}/${totalCount} models enabled`; const statusBadge = ep.status === 'empty' ? 'no models' : ep.online - ? `${visibleCount}/${totalCount} models enabled` + ? `${countText}` : 'offline'; const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api'); const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : ''; - const keyLabel = ep.has_key - ? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)') - : ''; - const isChatgptAccount = isChatgptSubscriptionEndpoint(ep); const isUsageExpanded = isChatgptAccount && _isChatgptUsageExpanded(ep.id, ep.provider_auth_id); + const epTitle = isChatgptAccount + ? chatgptAccountTitle(ep) + : (isFeatherless && (!ep.name || ep.name === 'api.featherless.ai') ? 'Featherless.ai' : ep.name); return `
@@ -713,7 +755,7 @@ async function loadEndpoints() { ` : ''} - ${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)} + ${esc(epTitle)} ${ep.model_type === 'image' ? 'Image' : ''} ${kindLabel ? `${esc(kindLabel)}` : ''} ${statusBadge} @@ -735,7 +777,7 @@ async function loadEndpoints() {
` : ''} -
${esc(ep.base_url)}${category === 'local' ? `` : ''}${keyLabel}
+ ${endpointDetailHtml(ep, category)} ${hasModels ? `` : ''}
`; }); @@ -994,19 +1036,21 @@ async function loadEndpoints() { const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) ? String(m.tool_mode).toLowerCase() : ''; - return `
-
'; @@ -3765,5 +3809,7 @@ export function close() { settingsModule.close(); } +export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }; + const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; export default adminModule; diff --git a/static/style.css b/static/style.css index 14f77835f..7c0ff6016 100644 --- a/static/style.css +++ b/static/style.css @@ -17954,6 +17954,69 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn { transform-origin: center; transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); } +.adm-model-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 2px 4px; + box-sizing: border-box; + width: 100%; +} +.adm-model-label { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + cursor: pointer; +} +.adm-model-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.adm-model-tools-col { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + margin-left: auto; +} +.adm-model-tools-label { + font-size: 10px; + opacity: 0.45; + flex-shrink: 0; + user-select: none; +} +.adm-model-tool-mode, +.admin-tools-select { + height: 24px; + min-height: 24px; + line-height: 22px; + font-size: 11px; + padding: 0 18px 0 6px; + width: 124px; + max-width: 130px; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid var(--border); + background-color: var(--select-bg); + color: var(--select-fg); + box-sizing: border-box; + vertical-align: middle; +} +@media (max-width: 480px) { + .adm-model-tool-mode, + .admin-tools-select { + width: 105px; + max-width: 110px; + font-size: 10px; + padding: 0 14px 0 4px; + } +} .adm-model-row:hover .adm-check-dot { border-color: var(--accent, var(--red)); transform: scale(1.15); @@ -49631,9 +49694,10 @@ body.theme-frosted .modal { /* ChatGPT Subscription per-account usage card (Settings → Added Models) */ .adm-chatgpt-controls { display: flex; + justify-content: flex-end; gap: 6px; align-items: center; - margin: 4px 0 4px 22px; + margin: 4px 0 2px auto; } .adm-chatgpt-usage-chevron { font-size: 10px; @@ -49642,7 +49706,7 @@ body.theme-frosted .modal { margin-left: 2px; } .adm-chatgpt-usage-host { - margin: 4px 0 6px 22px; + margin: 4px 0 6px 0; } .adm-chatgpt-usage { display: flex; diff --git a/tests/test_endpoint_card_ui_polish.py b/tests/test_endpoint_card_ui_polish.py new file mode 100644 index 000000000..afd160b25 --- /dev/null +++ b/tests/test_endpoint_card_ui_polish.py @@ -0,0 +1,193 @@ +"""Tests for Part A — Endpoint Card UI Polish and Model Tools Selector Layout.""" + +import json +import shutil +import subprocess +from pathlib import Path +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_ADMIN_JS = _REPO / "static" / "js" / "admin.js" +_ADMIN = _ADMIN_JS.read_text(encoding="utf-8") +_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") + + +def _run_node(script: str): + proc = subprocess.run( + ["node", "--input-type=module"], + input=script, + capture_output=True, + text=True, + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +class TestEndpointCardUrlPresentation: + def test_should_display_endpoint_base_url_policy(self): + js = f""" + import fs from 'node:fs'; + import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}'; + const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8'); + const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl'); + const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart); + const fnCode = source.slice(fnStart, fnEnd); + const fns = new Function('isChatgptSubscriptionEndpoint', 'esc', + fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};' + )(isChatgptSubscriptionEndpoint, x => String(x)); + + const results = {{ + chatgpt: fns.shouldDisplayEndpointBaseUrl({{ + base_url: 'https://chatgpt.com/backend-api/codex', + provider: 'chatgpt-subscription', + provider_auth_id: 'auth-123' + }}), + featherless: fns.shouldDisplayEndpointBaseUrl({{ + base_url: 'https://api.featherless.ai/v1', + name: 'Featherless.ai' + }}), + featherless_subdomain: fns.shouldDisplayEndpointBaseUrl({{ + base_url: 'https://eu.featherless.ai/v1' + }}), + generic: fns.shouldDisplayEndpointBaseUrl({{ + base_url: 'https://api.example.com/v1', + name: 'Custom AI' + }}), + local: fns.shouldDisplayEndpointBaseUrl({{ + base_url: 'http://127.0.0.1:11434', + name: 'Ollama' + }}), + null_ep: fns.shouldDisplayEndpointBaseUrl(null) + }}; + console.log(JSON.stringify(results)); + """ + out = _run_node(js) + assert out["chatgpt"] is False, "ChatGPT raw transport URL must be hidden" + assert out["featherless"] is False, "Featherless raw transport URL must be hidden" + assert out["featherless_subdomain"] is False, "Featherless domain variants must be hidden" + assert out["generic"] is True, "Generic custom endpoint URL must remain visible" + assert out["local"] is True, "Local endpoint URL must remain visible" + assert out["null_ep"] is False + + def test_endpoint_detail_html_suppresses_first_class_urls_and_keeps_others(self): + js = f""" + import fs from 'node:fs'; + import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}'; + const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8'); + const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl'); + const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart); + const fnCode = source.slice(fnStart, fnEnd); + const fns = new Function('isChatgptSubscriptionEndpoint', 'esc', + fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};' + )(isChatgptSubscriptionEndpoint, x => String(x)); + + const chatgptEp = {{ + base_url: 'https://chatgpt.com/backend-api/codex', + provider: 'chatgpt-subscription', + provider_auth_id: 'auth-1', + has_key: false + }}; + const featherlessEp = {{ + base_url: 'https://api.featherless.ai/v1', + name: 'Featherless.ai', + has_key: true, + api_key_fingerprint: 'abcd1234' + }}; + const genericEp = {{ + base_url: 'https://api.custom.com/v1', + name: 'Custom', + has_key: true, + api_key_fingerprint: 'ef5678' + }}; + const localEp = {{ + base_url: 'http://127.0.0.1:11434', + name: 'Local', + has_key: false + }}; + + const results = {{ + chatgptHtml: fns.endpointDetailHtml(chatgptEp, 'api'), + featherlessHtml: fns.endpointDetailHtml(featherlessEp, 'api'), + genericHtml: fns.endpointDetailHtml(genericEp, 'api'), + localHtml: fns.endpointDetailHtml(localEp, 'local') + }}; + console.log(JSON.stringify(results)); + """ + out = _run_node(js) + # ChatGPT card suppresses raw base URL and has no key, so no detail line + assert "chatgpt.com" not in out["chatgptHtml"] + assert out["chatgptHtml"] == "" + + # Featherless suppresses raw base URL but can display key fingerprint if present + assert "api.featherless.ai" not in out["featherlessHtml"] + assert "abcd1234" in out["featherlessHtml"] + + # Generic custom endpoint retains URL and key + assert "https://api.custom.com/v1" in out["genericHtml"] + assert "ef5678" in out["genericHtml"] + + # Local endpoint retains URL and copy affordance + assert "http://127.0.0.1:11434" in out["localHtml"] + assert 'data-adm-copy-url="http://127.0.0.1:11434"' in out["localHtml"] + assert "admin-ep-copy-btn" in out["localHtml"] + + +class TestChatGPTCardLayoutAndControls: + def test_chatgpt_controls_secondary_action_layout(self): + # Verify right-alignment in style.css + assert ".adm-chatgpt-controls {" in _STYLE + controls_block = _STYLE.split(".adm-chatgpt-controls {")[1].split("}")[0] + assert "justify-content: flex-end" in controls_block + assert "display: flex" in controls_block + + def test_chatgpt_usage_attributes_and_lazy_fetch_retained(self): + assert 'aria-controls="adm-chatgpt-usage-${esc(ep.id)}"' in _ADMIN + assert 'aria-expanded="${isUsageExpanded ? \'true\' : \'false\'}"' in _ADMIN + assert "data-adm-chatgpt-usage-toggle" in _ADMIN + assert "data-adm-chatgpt-reconnect" in _ADMIN + assert "adm-chatgpt-usage-host" in _ADMIN + # Lazy loading on toggle click + assert "_loadChatgptUsage(host, authId, epId)" in _ADMIN + assert "_isChatgptUsageExpanded(epId, authId)" in _ADMIN + + +class TestModelRowToolsSelectLayout: + def test_css_classes_prevent_clipping_and_guarantee_layout(self): + # Audit classes in style.css + assert ".adm-model-row {" in _STYLE + assert ".adm-model-label {" in _STYLE + assert ".adm-model-name {" in _STYLE + assert ".adm-model-tools-col {" in _STYLE + assert ".adm-model-tool-mode," in _STYLE or ".adm-model-tool-mode {" in _STYLE + assert ".admin-tools-select" in _STYLE + + # Verify tool-mode select styling + tool_mode_css = _STYLE.split(".adm-model-tool-mode,")[1].split("}")[0] + assert "height: 24px" in tool_mode_css + assert "line-height: 22px" in tool_mode_css + assert "padding: 0 18px 0 6px" in tool_mode_css + assert "box-sizing: border-box" in tool_mode_css + assert "width: 124px" in tool_mode_css + assert "vertical-align: middle" in tool_mode_css + + # Verify right column is fixed / shrink-safe + col_css = _STYLE.split(".adm-model-tools-col {")[1].split("}")[0] + assert "flex: 0 0 auto" in col_css + assert "margin-left: auto" in col_css + + # Verify model name is flexible with ellipsis + name_css = _STYLE.split(".adm-model-name {")[1].split("}")[0] + assert "overflow: hidden" in name_css + assert "text-overflow: ellipsis" in name_css + assert "white-space: nowrap" in name_css + assert "flex: 1" in name_css + + def test_admin_js_uses_classes_instead_of_inline_clipping_styles(self): + assert 'class="adm-model-row"' in _ADMIN + assert 'class="adm-model-label"' in _ADMIN + assert 'class="adm-model-name"' in _ADMIN + assert 'class="adm-model-tools-col"' in _ADMIN + assert 'class="adm-model-tool-mode admin-tools-select"' in _ADMIN