diff --git a/packages/tracker/meshnet_tracker/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index dc055e8..12076cc 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -127,9 +127,9 @@ display:flex; align-items:center; gap:8px; color:var(--dim); margin:0; } .chat-toolbar select { - min-width:220px; max-width:min(420px, 50vw); + min-width:280px; max-width:min(520px, 60vw); color:var(--fg); background:var(--chat-input-bg); border:1px solid var(--border); - border-radius:6px; padding:6px 8px; + border-radius:6px; padding:6px 8px; font-size:13px; } .chat-status { color:var(--dim); font-size:12px; margin-left:auto; } .chat-messages { @@ -831,6 +831,7 @@ let isLoggedIn = false; let accountApiKeys = []; let accountUsageRecords = []; let lastStats = null; +let lastRouting = null; let availableModels = []; let chatHistory = []; let chatBusy = false; @@ -956,11 +957,7 @@ function selectChatSession(sessionId) { renderChatAuthHint(); } -function deleteChatSession(sessionId, event) { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } +function deleteChatSession(sessionId) { if (chatBusy) return; const index = chatSessions.findIndex(item => item.id === sessionId); if (index < 0) return; @@ -1020,18 +1017,43 @@ function renderChatSessionList() { const active = session.id === activeChatSessionId ? " active" : ""; const title = esc(chatSessionTitle(session)); const when = esc(formatSessionTime(session.updatedAt || session.createdAt)); - const id = JSON.stringify(session.id); + const sessionId = esc(session.id); return `
` + + ` data-session-id="${sessionId}">` + `
${title}
` + (when ? `
${when}
` : "") + `` + + ` data-delete-session="${sessionId}">×` + `
`; }).join(""); } +function bindChatSessionList() { + const list = $("chat-session-list"); + if (!list || list.dataset.bound) return; + list.dataset.bound = "1"; + list.addEventListener("click", event => { + const deleteBtn = event.target.closest("[data-delete-session]"); + if (deleteBtn) { + event.preventDefault(); + event.stopPropagation(); + deleteChatSession(deleteBtn.getAttribute("data-delete-session")); + return; + } + const item = event.target.closest("[data-session-id]"); + if (item) selectChatSession(item.getAttribute("data-session-id")); + }); + list.addEventListener("keydown", event => { + if (event.key !== "Enter" && event.key !== " ") return; + const deleteBtn = event.target.closest("[data-delete-session]"); + if (deleteBtn) return; + const item = event.target.closest("[data-session-id]"); + if (!item) return; + event.preventDefault(); + selectChatSession(item.getAttribute("data-session-id")); + }); +} + function switchDashboardTab(name) { if (name === "admin" && !isAdmin) name = "overview"; if (name === "billing" && !isLoggedIn) name = "overview"; @@ -1079,6 +1101,48 @@ function renderChatHistory() { history.scrollTop = history.scrollHeight; } +function findRoutingForModel(model, routing) { + const models = (routing && routing.models) || {}; + const keys = [model.id, model.name, model.hf_repo, ...(model.aliases || [])].filter(Boolean); + for (const key of keys) { + if (models[key]) return models[key]; + const aliasKey = modelAliasKey(key); + for (const [routeKey, info] of Object.entries(models)) { + if (modelAliasKey(routeKey) === aliasKey) return info; + } + } + return null; +} + +function chatModelHealthLabel(coverage) { + if (coverage === null || coverage === undefined) return "unknown"; + const pct = Math.round(Number(coverage)); + if (pct >= 100) return "healthy"; + if (pct > 0) return `${pct}% cov`; + return "no coverage"; +} + +function chatModelTypicalTps(model, routing) { + const info = findRoutingForModel(model, routing); + const routes = (info && info.routes) || []; + if (!routes.length) return null; + const proven = routes.filter(r => r.status === "proven" && Number(r.tps) > 0); + if (proven.length) return Math.max(...proven.map(r => Number(r.tps))); + const priors = routes.map(r => Number(r.prior_tps ?? r.tps ?? 0)).filter(v => v > 0); + return priors.length ? Math.max(...priors) : null; +} + +function chatModelOptionLabel(model, routing) { + const base = model.name && model.name !== model.id + ? `${model.name} (${model.id})` + : model.id; + const health = chatModelHealthLabel(model.coverage); + const speed = chatModelTypicalTps(model, routing); + const speedText = speed === null ? "?" : `${tps(speed)} tok/s`; + const suffix = model.recommended ? " · recommended" : ""; + return `${base} · ${health} · ${speedText}${suffix}`; +} + function renderChatModels() { const select = $("chat-model"); if (!select) return; @@ -1094,11 +1158,8 @@ function renderChatModels() { selectedChatModel = preferred.id; localStorage.setItem("meshnet_chat_model", selectedChatModel); select.innerHTML = models.map(model => { - const label = model.name && model.name !== model.id - ? `${model.name} (${model.id})` - : model.id; - const suffix = model.recommended ? " [recommended]" : ""; - return ``; + const label = chatModelOptionLabel(model, lastRouting); + return ``; }).join(""); select.value = selectedChatModel; } @@ -1575,12 +1636,14 @@ async function refresh() { ]); const [summary, settlements, wallets] = adminData; lastStats = stats; + lastRouting = routing; availableModels = ((models && models.data) || []).map(model => ({ id: model.id, name: model.name || model.id, hf_repo: model.hf_repo, recommended: Boolean(model.recommended), aliases: model.aliases || [], + coverage: model.shard_coverage_percentage, })).filter(model => model.id); renderHive(raft); renderNodes(map); @@ -1610,6 +1673,7 @@ async function refreshIfIdle() { } refresh(); +bindChatSessionList(); initChatSessions(); bindChatPromptShortcuts(); renderAccountPanel(); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index a077842..affa31d 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -52,6 +52,40 @@ def test_dashboard_chat_uses_streaming_fetch(): assert 'console.error("chat stream failed", err)' in html +def test_dashboard_chat_model_selector_shows_health_and_speed(): + tracker = TrackerServer() + port = tracker.start() + try: + html = urllib.request.urlopen( + f"http://127.0.0.1:{port}/dashboard" + ).read().decode() + finally: + tracker.stop() + + assert "chatModelHealthLabel" in html + assert "chatModelTypicalTps" in html + assert "chatModelOptionLabel" in html + assert "findRoutingForModel" in html + assert "tok/s" in html + + +def test_dashboard_chat_sessions_use_delegated_handlers(): + tracker = TrackerServer() + port = tracker.start() + try: + html = urllib.request.urlopen( + f"http://127.0.0.1:{port}/dashboard" + ).read().decode() + finally: + tracker.stop() + + assert "bindChatSessionList" in html + assert "data-session-id=" in html + assert "data-delete-session=" in html + assert 'onclick="selectChatSession(' not in html + assert 'onclick="deleteChatSession(' not in html + + def test_dashboard_served_by_follower(): """A tracker that is not the leader (unreachable peers → never elected) still serves the dashboard from its own replicated state."""