// FAST_ERROR_MS is the timing threshold used to distinguish "no listener // on :443" (very fast browser error, usually TCP RST) from "something // answered TCP, TLS handshake failed because of untrusted cert" (slower // error). The exact cutoff is fuzzy and varies by browser/network, but // the gap between the two cases is large enough (single-digit ms vs. // 100+ ms) that this works as a heuristic. We don't expose milliseconds // to the user — they'd be misleading without context. const FAST_ERROR_MS = 150; // copyTextToClipboard attempts navigator.clipboard.writeText first (modern // async API, requires a secure context — HTTPS or localhost). On insecure // contexts (plain HTTP at a LAN IP), the Clipboard API is unavailable, so // we fall back to the legacy document.execCommand("copy") path using a // throwaway off-screen textarea. Returns true on success, false on // failure. Both paths preserve the page's current focus. async function copyTextToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { // Fall through to the legacy path — some browsers still reject // even when isSecureContext claims true (e.g. iframes without // the clipboard-write permission). } } const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "absolute"; ta.style.left = "-9999px"; ta.style.top = "0"; document.body.appendChild(ta); const previousActive = document.activeElement; ta.select(); let ok = false; try { ok = document.execCommand("copy"); } catch (e) { ok = false; } document.body.removeChild(ta); if (previousActive && typeof previousActive.focus === "function") { previousActive.focus(); } return ok; } async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) { const line = document.createElement("div"); line.style.fontSize = "0.85em"; line.style.marginTop = "2px"; line.style.color = "#666"; line.innerText = "⏱ Checking from your browser too…"; statusEl.appendChild(line); const start = performance.now(); let outcome; try { // mode:"no-cors" lets the request go on the wire even though the response // would be opaque. We only care about success-or-fail and timing — not // the response body, which we can't read anyway with an untrusted cert. await fetch("https://" + lanHost + ":443/", { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(2000), }); outcome = { reached: true, elapsed: performance.now() - start }; } catch (e) { outcome = { reached: false, elapsed: performance.now() - start, err: e }; } let msg; let color; if (outcome.reached) { color = "#2e7d32"; msg = "✅ Your browser also reaches :443 on " + lanHost + "."; } else if (outcome.elapsed >= FAST_ERROR_MS) { color = "#2e7d32"; msg = "✅ Your browser reached :" + lanHost + ":443 — the failure that follows is the expected " + "untrusted-CA error, not a missing listener."; } else { color = "#c62828"; msg = "❌ Your browser sees no listener on " + lanHost + ":443 " + "(fast error, likely connection refused)."; } // Hint when server and browser disagree — that almost always means NAT, // split-horizon DNS, or a host firewall sitting between AfterTouch and // the speaker. Worth pointing out because it's invisible to the server. const browserSees443 = outcome.reached || outcome.elapsed >= FAST_ERROR_MS; if (serverLanOK && !browserSees443) { msg += " (Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)"; color = "#c62828"; } else if (!serverLanOK && browserSees443) { msg += " (Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)"; color = "#c62828"; } line.style.color = color; line.innerHTML = msg; } async function fetchSpotifyStatus() { try { const settingsResponse = await fetch("/api/setup/settings"); const settings = await settingsResponse.json(); const header = document.getElementById("spotify-status-header"); const nameEl = document.getElementById("spotify-account-name"); const linkBtn = document.getElementById("link-spotify-btn"); if (!settings.spotify_configured) { if (header) header.style.display = "none"; return; } if (header) header.style.display = "flex"; const response = await fetch("/api/mgmt/spotify/accounts"); if (!response.ok) { // Don't fail silently: a stale/hidden Spotify status here is exactly // what made #269 look like a Spotify bug instead of a Management API // login that never completed. if (header) { header.style.background = "#f8d7da"; header.style.border = "1px solid #dc3545"; } if (nameEl) { nameEl.innerText = response.status === 401 ? "Unable to check (Management login required — retry, or reload the page)" : `Unable to check (HTTP ${response.status})`; } return; } const data = await response.json(); if (data.accounts && data.accounts.length > 0) { header.style.background = "#e6ffed"; header.style.border = "1px solid #28a745"; nameEl.innerText = data.accounts[0].display_name || data.accounts[0].user_id || "Linked"; if (linkBtn) linkBtn.style.display = "none"; // Show Prime Spotify buttons on all devices document.querySelectorAll(".btn-spotify").forEach((btn) => { btn.style.display = "inline-block"; }); } else { header.style.background = "#f0f0f0"; header.style.border = "1px solid #ccc"; nameEl.innerText = "Not Linked"; if (linkBtn) linkBtn.style.display = "inline-block"; document.querySelectorAll(".btn-spotify").forEach((btn) => { btn.style.display = "none"; }); } } catch (error) { console.error("Failed to fetch Spotify status", error); } } function toggleInfo(id) { const el = document.getElementById(id); if (el) { el.style.display = el.style.display === "block" ? "none" : "block"; } } async function linkSpotify() { try { const response = await fetch("/api/mgmt/spotify/init", {method: "POST"}); if (!response.ok) { if (response.status === 401) { alert("Management login failed or was cancelled. Please try again — your browser should prompt for the Management API username/password."); } else { const err = await response.text(); alert("Failed to initialize Spotify link: " + err); } return; } const data = await response.json(); if (data.redirectUrl) { // Open in a new tab const win = window.open(data.redirectUrl, "_blank"); if (win) { win.focus(); // Start polling for status change const pollInterval = setInterval(async () => { const statusResponse = await fetch("/api/mgmt/spotify/accounts"); if (statusResponse.ok) { const statusData = await statusResponse.json(); if (statusData.accounts && statusData.accounts.length > 0) { clearInterval(pollInterval); fetchSpotifyStatus(); } } }, 2000); // Stop polling after 2 minutes setTimeout(() => clearInterval(pollInterval), 120000); } else { alert("Please allow popups to link your Spotify account."); } } } catch (error) { alert("Error linking Spotify: " + error.message); } } async function primeSpotify(deviceId) { const btn = document.getElementById("prime-spotify-" + deviceId); const originalText = btn.innerText; btn.innerText = "Priming..."; btn.disabled = true; try { const response = await fetch(`/api/mgmt/spotify/prime?deviceId=${encodeURIComponent(deviceId)}`, { method: "POST", },); if (response.ok) { btn.innerText = "✅ Primed"; btn.style.background = "#28a745"; setTimeout(() => { btn.innerText = originalText; btn.style.background = ""; btn.disabled = false; }, 3000); } else { const err = await response.text(); alert("Failed to prime Spotify: " + err); btn.innerText = "❌ Failed"; setTimeout(() => { btn.innerText = originalText; btn.disabled = false; }, 3000); } } catch (error) { alert("Error priming Spotify: " + error.message); btn.innerText = originalText; btn.disabled = false; } } // setIntegrationBadge updates the Active/Saved/Inactive pill shown in an // integration panel's summary (visible even when the panel is collapsed). function setIntegrationBadge(id, state) { const el = document.getElementById(id); if (!el) return; const map = { active: {cls: "badge-active", text: "✅ Active"}, saved: {cls: "badge-saved", text: "⚠ Saved — re-save to apply"}, inactive: {cls: "badge-inactive", text: "❌ Inactive"}, }; const m = map[state] || map.inactive; el.className = "integration-badge " + m.cls; el.textContent = m.text; } async function fetchSettings() { try { const response = await fetch("/api/setup/settings"); const settings = await response.json(); if (settings.server_url) { document.getElementById("target-domain").value = settings.server_url; } const httpsEff = document.getElementById("https-url-effective"); if (httpsEff) { httpsEff.textContent = settings.https_server_url || "—"; } const httpsOverrideInput = document.getElementById("https-url-override"); if (httpsOverrideInput) { httpsOverrideInput.value = settings.https_server_url_override || ""; } const httpsEffNote = document.getElementById("https-url-effective-note"); if (httpsEffNote) { httpsEffNote.textContent = settings.https_server_url_override ? "(override)" : "(derived from Target Domain)"; } const resolved = document.getElementById("target-domain-resolved"); if (resolved) { if (settings.server_url_resolved_ip) { resolved.style.color = "#2e7d32"; resolved.innerHTML = "✅ DNS will hand out " + settings.server_url_resolved_ip + " for intercepted Bose hostnames. Speakers must be able to reach this address."; } else if (settings.server_url_resolve_error) { resolved.style.color = "#c62828"; resolved.innerText = "❌ " + settings.server_url_resolve_error; } else { resolved.innerText = ""; } } const port443 = document.getElementById("https-443-status"); if (port443) { // The :443 check only applies to the DNS-migration path. Hide the row // entirely when AfterTouch's DNS interception is off — those users are // either using SDK overrides (port-explicit URLs) or external DNS // interception (in which case they can read /api/setup/settings JSON // directly if they want the result). if (!settings.dns_enabled) { port443.innerHTML = ""; } else if (settings.https_443_check_skipped) { port443.style.color = "#2e7d32"; port443.innerHTML = "✅ HTTPS listener bound directly to :443 — speakers can connect."; } else if (settings.https_443_not_applicable) { port443.style.color = "#1565c0"; port443.innerHTML = "ℹ️ :443 reachability check not applicable. " + (settings.https_443_reason || ""); } else { const localhostOK = settings.https_443_localhost_reachable; const lanOK = settings.https_443_lan_reachable; const lanHost = settings.https_443_lan_host || ""; const listenerPort = settings.https_listener_port || "8443"; if (localhostOK && lanOK) { port443.style.color = "#2e7d32"; port443.innerHTML = "✅ :443 reachable on localhost and " + (lanHost || "LAN address") + " (forwarded to :" + listenerPort + ")."; } else { port443.style.color = "#c62828"; const details = []; details.push("localhost:443 " + (localhostOK ? "✓" : "❌ " + (settings.https_443_localhost_error || "unreachable"))); details.push((lanHost || "LAN") + ":443 " + (lanOK ? "✓" : "❌ " + (settings.https_443_lan_error || "unreachable"))); port443.innerHTML = "❌ Speakers connect to :443 but AfterTouch listens on :" + listenerPort + ". " + details.join(" · ") + ". Set up iptables / setcap / reverse proxy — see " + "HTTPS-SETUP.md."; } // Browser-side probe runs in parallel. Mirrors what speakers see from // the LAN; the server-side probe runs from inside AfterTouch's host // and can disagree when there is NAT / split-horizon / a firewall in // between. We can't see TLS-cert vs. TCP-RST from JS, so we fall back // to timing: a fast error suggests no listener; a slower error // suggests the connection got far enough to start TLS, which proves // something is answering. The CA cert is not trusted by the browser // by default, so a clean ✅ resolution is rare — that's fine, the // timing alone is the diagnostic signal. if (lanHost) { probeBrowser443(lanHost, listenerPort, port443, localhostOK, lanOK); } } } if (settings.discovery_interval) { document.getElementById("discovery-interval").value = settings.discovery_interval; } if (settings.discovery_enabled !== undefined) { document.getElementById("discovery-enabled").checked = settings.discovery_enabled; } if (settings.update_check_interval) { document.getElementById("update-check-interval").value = settings.update_check_interval; } if (settings.update_check_enabled !== undefined) { document.getElementById("update-check-enabled").checked = settings.update_check_enabled; } if (settings.default_landing) { document.getElementById("default-landing").value = settings.default_landing; } if (settings.admin_area_auth !== undefined) { document.getElementById("admin-area-auth").value = settings.admin_area_auth || ""; } if (settings.dns_enabled !== undefined) { document.getElementById("dns-enabled").checked = settings.dns_enabled; } if (settings.dns_upstream) { document.getElementById("dns-upstream").value = settings.dns_upstream; } if (settings.dns_bind_addr) { document.getElementById("dns-bind").value = settings.dns_bind_addr; } const dnsCurrentUpstream = document.getElementById("dns-current-upstream"); if (dnsCurrentUpstream && settings.dns_upstream) { dnsCurrentUpstream.innerText = "Current upstreams: " + settings.dns_upstream; } else if (dnsCurrentUpstream) { dnsCurrentUpstream.innerText = ""; } if (settings.internal_paths) { document.getElementById("internal-paths").value = settings.internal_paths.join("\n"); } if (Array.isArray(settings.tls_extra_hosts)) { document.getElementById("tls-extra-hosts").value = settings.tls_extra_hosts.join("\n"); } const effective = document.getElementById("tls-san-effective"); if (effective) { if (Array.isArray(settings.tls_san_hosts) && settings.tls_san_hosts.length) { effective.innerText = "Currently covered by TLS cert: " + settings.tls_san_hosts.join(", "); } else { effective.innerText = ""; } } // Spotify credential fields if (settings.spotify_client_id !== undefined) { document.getElementById("spotify-client-id").value = settings.spotify_client_id || ""; } // Secret is masked to "***" by the backend when set; leave the password input blank // so the placeholder "(leave blank to keep existing)" is shown. document.getElementById("spotify-client-secret").value = ""; if (settings.spotify_redirect_uri !== undefined) { document.getElementById("spotify-redirect-uri").value = settings.spotify_redirect_uri || ""; } const spotifyStatus = document.getElementById("spotify-config-status"); if (settings.spotify_configured) { if (spotifyStatus) spotifyStatus.innerHTML = '✅ Active'; setIntegrationBadge("spotify-badge", "active"); } else if (settings.spotify_client_id) { if (spotifyStatus) spotifyStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate'; setIntegrationBadge("spotify-badge", "saved"); } else { if (spotifyStatus) spotifyStatus.innerHTML = '❌ Not configured'; setIntegrationBadge("spotify-badge", "inactive"); } // Amazon credential fields if (settings.amazon_client_id !== undefined) { document.getElementById("amazon-client-id").value = settings.amazon_client_id || ""; } document.getElementById("amazon-client-secret").value = ""; if (settings.amazon_redirect_uri !== undefined) { document.getElementById("amazon-redirect-uri").value = settings.amazon_redirect_uri || ""; } const amazonStatus = document.getElementById("amazon-config-status"); if (settings.amazon_configured) { if (amazonStatus) amazonStatus.innerHTML = '✅ Active'; setIntegrationBadge("amazon-badge", "active"); } else if (settings.amazon_client_id) { if (amazonStatus) amazonStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate'; setIntegrationBadge("amazon-badge", "saved"); } else { if (amazonStatus) amazonStatus.innerHTML = '❌ Not configured'; setIntegrationBadge("amazon-badge", "inactive"); } // TTS fields (secrets are masked server-side; leave password inputs blank) if (settings.tts_provider !== undefined) { document.getElementById("tts-provider").value = settings.tts_provider || "translate"; } document.getElementById("tts-google-api-key").value = ""; document.getElementById("tts-app-key").value = ""; if (settings.tts_language !== undefined) { document.getElementById("tts-language").value = settings.tts_language || ""; } if (settings.tts_voice !== undefined) { document.getElementById("tts-voice").value = settings.tts_voice || ""; } if (settings.tts_volume !== undefined) { document.getElementById("tts-volume").value = settings.tts_volume || 0; } const ttsStatus = document.getElementById("tts-config-status"); if (settings.tts_configured) { if (ttsStatus) ttsStatus.innerHTML = '✅ Active (' + (settings.tts_provider || "translate") + ")"; setIntegrationBadge("tts-badge", "active"); } else { const need = settings.tts_provider === "google-cloud" ? "an app_key and a Google API key" : "an app_key"; if (ttsStatus) ttsStatus.innerHTML = '❌ Not active — set ' + need + ""; setIntegrationBadge("tts-badge", "inactive"); } fetchLoggingSettings(); fetchSpotifyStatus(); return settings; } catch (error) { console.error("Failed to fetch settings", error); } } async function fetchLoggingSettings() { try { const response = await fetch("/api/setup/logging-settings"); const settings = await response.json(); document.getElementById("logging-redact").checked = settings.redact; document.getElementById("logging-log-body").checked = settings.log_body; document.getElementById("logging-record").checked = settings.record; } catch (error) { console.error("Failed to fetch proxy settings", error); } } async function updateLoggingSettings() { const settings = { redact: document.getElementById("logging-redact").checked, log_body: document.getElementById("logging-log-body").checked, record: document.getElementById("logging-record").checked, }; try { await fetch("/api/setup/logging-settings", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(settings), }); } catch (error) { console.error("Failed to update proxy settings", error); } } async function updateSettings() { const httpsOverrideEl = document.getElementById("https-url-override"); const settings = { server_url: document.getElementById("target-domain").value, https_server_url_override: httpsOverrideEl ? httpsOverrideEl.value.trim() : "", default_landing: document.getElementById("default-landing").value, admin_area_auth: document.getElementById("admin-area-auth").value, discovery_interval: document.getElementById("discovery-interval").value, discovery_enabled: document.getElementById("discovery-enabled").checked, update_check_interval: document.getElementById("update-check-interval").value, update_check_enabled: document.getElementById("update-check-enabled").checked, dns_enabled: document.getElementById("dns-enabled").checked, dns_upstream: document.getElementById("dns-upstream").value, dns_bind_addr: document.getElementById("dns-bind").value, internal_paths: document .getElementById("internal-paths") .value.split("\n") .map((s) => s.trim()) .filter((s) => s !== ""), spotify_client_id: document.getElementById("spotify-client-id").value, spotify_client_secret: document.getElementById("spotify-client-secret").value, spotify_redirect_uri: document.getElementById("spotify-redirect-uri").value, amazon_client_id: document.getElementById("amazon-client-id").value, amazon_client_secret: document.getElementById("amazon-client-secret").value, amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value, tts_provider: document.getElementById("tts-provider").value, tts_google_api_key: document.getElementById("tts-google-api-key").value, tts_app_key: document.getElementById("tts-app-key").value, tts_language: document.getElementById("tts-language").value, tts_voice: document.getElementById("tts-voice").value, tts_volume: parseInt(document.getElementById("tts-volume").value, 10) || 0, tls_extra_hosts: document .getElementById("tls-extra-hosts") .value.split("\n") .map((s) => s.trim()) .filter((s) => s !== ""), }; const status = document.getElementById("settings-status"); status.innerText = "Saving..."; status.style.color = "blue"; try { const response = await fetch("/api/setup/settings", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(settings), }); if (response.ok) { status.innerText = "✅ Settings saved. Restart service to apply all changes (like certificate SANs)."; status.style.color = "green"; setTimeout(() => fetchSettings(), 500); // Give backend a moment to settle } else { const err = await response.text(); status.innerText = "❌ Failed: " + err; status.style.color = "red"; } } catch (error) { status.innerText = "❌ Error: " + error.message; status.style.color = "red"; } } // LIVE_INFO_CONCURRENCY caps how many live /info probes run at once. The // browser allows ~6 connections per origin over HTTP/1.1; staying below // that leaves sockets free so a navigation (or other request) is never // stuck behind a batch of slow/offline-device probes. const LIVE_INFO_CONCURRENCY = 3; // mapLimit runs fn over items with at most `limit` concurrent invocations, // resolving when all have settled. fn may be async; its rejections are // swallowed so one failure does not stop the rest. async function mapLimit(items, limit, fn) { let next = 0; const worker = async () => { while (next < items.length) { const item = items[next++]; try { await fn(item); } catch (_) { /* individual probe failures are non-fatal */ } } }; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, worker), ); } async function fetchDevices() { try { const response = await fetch("/api/setup/devices"); const devices = await response.json(); const container = document.getElementById("device-list"); const syncSelector = document.getElementById("sync-device-list"); const migrationSelector = document.getElementById("migration-device-list"); if (devices.length === 0) { container.innerHTML = "No devices known yet."; } else { let html = ""; // Clear and repopulate selectors const currentSyncVal = syncSelector.value; const currentMigrationVal = migrationSelector.value; const eventSelector = document.getElementById("event-device-selector"); const currentEventVal = eventSelector ? eventSelector.value : ""; syncSelector.innerHTML = ''; migrationSelector.innerHTML = ''; if (eventSelector) eventSelector.innerHTML = ''; devices.forEach((d) => { const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto"; html += ` `; const optSync = document.createElement("option"); optSync.value = d.device_id; optSync.textContent = `${d.name} (${d.ip_address})`; syncSelector.appendChild(optSync); const optMigrate = document.createElement("option"); optMigrate.value = d.device_id; optMigrate.textContent = `${d.name} (${d.ip_address})`; migrationSelector.appendChild(optMigrate); if (eventSelector) { const optEvent = document.createElement("option"); optEvent.value = d.device_id; optEvent.textContent = `${d.name} (${d.ip_address})`; eventSelector.appendChild(optEvent); } }); html += "
Name & ModelIP AddressDevice & Account IDFirmware & SerialMethodAction
${d.name}
${d.product_code}
${d.ip_address}
${d.device_id}
${d.account_id || "default"}
${d.firmware_version || "0.0.0"}
${d.device_serial_number}
${methodLabel}
"; container.innerHTML = html; if (currentSyncVal) syncSelector.value = currentSyncVal; if (currentMigrationVal) migrationSelector.value = currentMigrationVal; if (eventSelector && currentEventVal) eventSelector.value = currentEventVal; // Refresh each device's live /info, but cap how many run at // once. A browser only opens ~6 connections per origin over // HTTP/1.1; an offline speaker holds its /info request until the // timeout, so probing every device at once can starve the pool // and make the whole page (including navigating away) wait for // those requests to clear. Capping below the limit keeps sockets // free for navigation and other requests. mapLimit(devices, LIVE_INFO_CONCURRENCY, (d) => updateDeviceInfo(d.device_id, d.ip_address)); fetchSpotifyStatus(); } return devices.length; } catch (error) { document.getElementById("device-list").textContent = "Error loading devices: " + error; return 0; } } function prepareSync(deviceId) { document.getElementById("sync-device-list").value = deviceId; openTab(null, "tab-sync"); } function prepareMigration(deviceId) { document.getElementById("migration-device-list").value = deviceId; openTab(null, "tab-migration"); showSummary(deviceId); } function openTab(evt, tabId) { const tabcontents = document.getElementsByClassName("tab-content"); for (let i = 0; i < tabcontents.length; i++) { tabcontents[i].className = tabcontents[i].className.replace(" active", ""); } const tablinks = document.getElementsByClassName("tab-btn"); for (let i = 0; i < tablinks.length; i++) { tablinks[i].className = tablinks[i].className.replace(" active", ""); } const content = document.getElementById(tabId); if (content) { content.className += " active"; } if (tabId === "tab-interactions") { fetchInteractionStats(); fetchInteractions(); fetchDNSDiscoveries(); } if (tabId === "tab-account") { fetchAccountList(); } if (tabId === "tab-health") { fetchHealth(); } if (tabId === "tab-logs") { startLogsPolling(); } else { stopLogsPolling(); } if (evt) { evt.currentTarget.className += " active"; let hash = tabId; if (tabId === "tab-migration") { const dev = document.getElementById("migration-device-list").value; if (dev) hash += "?" + dev; } history.pushState(null, '', '#' + hash); } else { // Find the button that corresponds to the tabId and activate it for (let i = 0; i < tablinks.length; i++) { const onclick = tablinks[i].getAttribute("onclick"); if (onclick && onclick.includes(tabId)) { tablinks[i].className += " active"; break; } } } } window.addEventListener('popstate', function() { const hash = window.location.hash.slice(1); const [tabId, extra] = hash.split('?'); if (!tabId || !document.getElementById(tabId)) { openTab(null, "tab-overview"); return; } openTab(null, tabId); if (tabId === "tab-migration") { selectMigrationDevice(extra); } }); function selectMigrationDevice(deviceId) { const sel = document.getElementById("migration-device-list"); if (sel) { for (let i = 0; i < sel.options.length; i++) { if (sel.options[i].value === deviceId) { sel.value = deviceId; showSummary(deviceId); return; } } sel.value = ""; document.getElementById("migration-summary").style.display = "none"; history.replaceState(null, '', '#tab-migration'); } } function getDeviceDisplayName(deviceId) { if (!deviceId) return "Unknown Device"; // 1. Try migration selector const migrationSelector = document.getElementById("migration-device-list"); if (migrationSelector) { for (let opt of migrationSelector.options) { if (opt.value === deviceId && opt.textContent !== "-- Select a device --") { return opt.textContent; } } } // 2. Try sync selector const syncSelector = document.getElementById("sync-device-list"); if (syncSelector) { for (let opt of syncSelector.options) { if (opt.value === deviceId && opt.textContent !== "-- Select a device --") { return opt.textContent; } } } // 3. Try table lookup const rows = document.querySelectorAll("#device-list tr"); for (const row of rows) { const idCell = row.querySelector(".col-deviceid"); if (idCell && idCell.innerText === deviceId) { const name = row.querySelector(".col-name").innerText; const ip = row.querySelector(".col-ip").innerText; return `${name} (${ip})`; } } return deviceId; } async function startSync() { const deviceId = document.getElementById("sync-device-list").value; if (!deviceId) { alert("Please select a device first"); return; } const status = document.getElementById("sync-status"); const results = document.getElementById("sync-results"); const log = document.getElementById("sync-log"); status.style.display = "block"; status.style.backgroundColor = "#eef"; const display = getDeviceDisplayName(deviceId); status.textContent = "Syncing data from " + display + "..."; results.style.display = "none"; log.innerHTML = ""; try { const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},); if (response.ok) { status.style.backgroundColor = "#dfd"; status.textContent = "✅ Sync completed successfully for " + display + "!"; results.style.display = "block"; log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK"; } else { const err = await response.text(); throw new Error(err); } } catch (error) { status.style.backgroundColor = "#fdd"; status.textContent = "❌ Sync failed for " + display + ": " + error.message; } } // fetchAnnouncements loads and renders the active announcements for this // area (see #419 design doc, _/i419/design-admin-area-auth-gate.md). Not // tab-scoped: the container lives outside the tab-content divs so a banner // stays visible regardless of which tab is open. async function fetchAnnouncements() { const container = document.getElementById("announcements-banner"); if (!container) return; try { const response = await fetch("/api/announcements?target=admin"); if (!response.ok) return; const data = await response.json(); const announcements = data.announcements || []; container.innerHTML = announcements.map(a => `
${escapeHtml(a.message)}${a.link_url ? ` ${escapeHtml(a.link_text || a.link_url)}` : ""}
`).join(""); } catch (error) { console.error("Failed to fetch announcements", error); } } // dismissAnnouncement records the dismissal server-side (so it stays // dismissed across sessions/devices — not a client-only localStorage flag) // and removes it from the DOM immediately rather than waiting on a refetch. async function dismissAnnouncement(id) { try { await fetch(`/api/announcements/${encodeURIComponent(id)}/dismiss`, {method: "POST"}); } catch (error) { console.error("Failed to dismiss announcement", error); } const el = document.querySelector(`[data-announcement-id="${id}"]`); if (el) el.remove(); } async function fetchVersion() { try { const response = await fetch("/api/setup/version"); const data = await response.json(); const info = document.getElementById("version-info"); if (info && data.version) { let versionStr = data.version; if (data.release_url) { versionStr = `${data.version}`; } let commitStr = data.commit; if (data.commit_url) { const shortCommit = data.commit.substring(0, 7); commitStr = `${shortCommit}`; } info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) • ${data.date}`; } const dataDirEl = document.getElementById("settings-data-dir"); if (dataDirEl && data.data_dir) { dataDirEl.textContent = data.data_dir; } } catch (error) { console.error("Failed to fetch version info", error); } } async function fetchAccountList() { try { const response = await fetch("/api/mgmt/accounts"); if (!response.ok) { const selector = document.getElementById("account-selector"); if (selector) { selector.innerHTML = response.status === 401 ? `` : ``; } return; } const data = await response.json(); const selector = document.getElementById("account-selector"); if (selector) { selector.innerHTML = data.accounts.map(acc => ``).join(""); if (data.accounts.length > 0) { fetchAccountDetails(selector.value); } } } catch (error) { console.error("Failed to fetch account list", error); } } async function fetchAccountDetails(accountId) { if (!accountId) return; const metadataEl = document.getElementById("account-metadata"); const devicesEl = document.getElementById("account-devices-list"); const regStatus = document.getElementById("spotify-reg-status"); const amazonRegStatus = document.getElementById("amazon-reg-status"); if (regStatus) regStatus.innerText = ""; if (amazonRegStatus) amazonRegStatus.innerText = ""; if (metadataEl) metadataEl.innerHTML = "Loading..."; if (devicesEl) devicesEl.innerHTML = "Loading devices..."; try { const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`); if (!response.ok) { if (metadataEl) metadataEl.innerHTML = `Failed to load account details: ${response.statusText}`; return; } const data = await response.json(); // Render Metadata if (metadataEl) { const warningNotice = data.account.is_placeholder ? `
Notice: This account hasn't saved any custom settings yet (language, provider preferences). Defaults are in effect — they'll be saved once you change something below.
` : ""; metadataEl.innerHTML = ` ${warningNotice}
Account ID:${data.account.account_id}
Language:
Provider Settings: ${data.account.provider_settings && data.account.provider_settings.length > 0 ? (() => { const grouped = data.account.provider_settings.reduce((acc, s) => { const pName = s.provider_name || s.provider_id; if (!acc[pName]) acc[pName] = []; acc[pName].push(s); return acc; }, {}); return Object.entries(grouped).map(([pName, settings]) => `
${pName}
    ${settings.map(s => { if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") { return `
  • Music Streaming Quality:
  • `; } return `
  • ${s.key_name}: ${s.value}
  • `; }).join("")}
`).join(""); })() : "None"}
`; const languageSelect = document.getElementById("account-language-select"); if (languageSelect) { languageSelect.addEventListener("change", async (e) => { const statusEl = document.getElementById("language-update-status"); const newLang = e.target.value; if (statusEl) { statusEl.innerText = "Saving..."; statusEl.style.display = "inline"; statusEl.style.color = "#666"; } try { const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ language: newLang }), }); if (response.ok) { if (statusEl) { statusEl.innerText = "Saved!"; statusEl.style.color = "#28a745"; setTimeout(() => { statusEl.style.display = "none"; }, 2000); } } else { throw new Error(await response.text()); } } catch (error) { console.error("Failed to update language", error); if (statusEl) { statusEl.innerText = "Error!"; statusEl.style.color = "#dc3545"; } } }); } const providerSettingSelects = document.querySelectorAll(".provider-setting-select"); providerSettingSelects.forEach(select => { select.addEventListener("change", async (e) => { const statusEl = e.target.parentElement.querySelector(".setting-update-status"); const accID = e.target.dataset.accountId; const provID = e.target.dataset.providerId; const key = e.target.dataset.key; const newValue = e.target.value; if (statusEl) { statusEl.innerText = "Saving..."; statusEl.style.display = "inline"; statusEl.style.color = "#666"; } try { const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ provider_id: provID, key: key, value: newValue }), }); if (response.ok) { if (statusEl) { statusEl.innerText = "Saved!"; statusEl.style.color = "#28a745"; setTimeout(() => { statusEl.style.display = "none"; }, 2000); } } else { throw new Error(await response.text()); } } catch (error) { console.error("Failed to update provider setting", error); if (statusEl) { statusEl.innerText = "Error!"; statusEl.style.color = "#dc3545"; } } }); }); } // Render Devices if (devicesEl) { if (!data.devices || data.devices.length === 0) { devicesEl.innerHTML = "No devices found for this account."; return; } devicesEl.innerHTML = data.devices.map(device => `

${device.name || "Unnamed Device"} (${device.product_code})

${device.ip_address} | ${device.device_id}
`).join(""); } } catch (error) { if (metadataEl) metadataEl.innerHTML = `Error: ${error.message}`; console.error("Failed to fetch account details", error); } } async function connectSpotifyToAccount() { const selector = document.getElementById("account-selector"); const accountId = selector ? selector.value : "default"; const statusEl = document.getElementById("spotify-reg-status"); if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization..."; try { const response = await fetch(`/api/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, { method: "POST" }); if (!response.ok) { const err = await response.text(); throw new Error(err || response.statusText); } const data = await response.json(); const redirectUrl = data.redirectUrl; if (statusEl) { statusEl.innerHTML = `Spotify authorization window opened.
If it didn't open, click here to authorize.`; } // Open Spotify auth in a new window window.open(redirectUrl, "SpotifyAuth", "width=600,height=800"); // Simple poll to see when we might be done (refresh every 5s for 5 mins) let pollCount = 0; const interval = setInterval(async () => { pollCount++; if (pollCount > 60) { clearInterval(interval); return; } // Refresh account details to see if source appeared await fetchAccountDetails(accountId); }, 5000); } catch (error) { if (statusEl) statusEl.innerHTML = `Error: ${error.message}`; console.error("Spotify link failed", error); } } async function connectAmazonToAccount() { const selector = document.getElementById("account-selector"); const accountId = selector ? selector.value : "default"; const statusEl = document.getElementById("amazon-reg-status"); if (statusEl) statusEl.innerHTML = "Initializing Amazon Music authorization..."; try { const response = await fetch(`/api/mgmt/amazon/init?account=${encodeURIComponent(accountId)}`, { method: "POST" }); if (!response.ok) { const err = await response.text(); throw new Error(err || response.statusText); } const data = await response.json(); const redirectUrl = data.redirectUrl; if (statusEl) { statusEl.innerHTML = `Amazon Music authorization window opened.
If it didn't open, click here to authorize.`; } window.open(redirectUrl, "AmazonAuth", "width=600,height=800"); let pollCount = 0; const interval = setInterval(async () => { pollCount++; if (pollCount > 60) { clearInterval(interval); return; } await fetchAccountDetails(accountId); }, 5000); } catch (error) { if (statusEl) statusEl.innerHTML = `Error: ${error.message}`; console.error("Amazon link failed", error); } } async function fetchInteractionStats() { console.log("Fetching interaction stats..."); try { const response = await fetch("/api/setup/interaction-stats"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const stats = await response.json(); console.log("Fetched interaction stats:", stats); document.getElementById("total-requests").innerText = stats.total_requests || stats.TotalRequests || 0; const statsContainer = document.getElementById("interaction-stats-container",); if (statsContainer) { statsContainer.style.display = "block"; } const serviceList = document.getElementById("stats-by-service"); serviceList.innerHTML = ""; const byService = stats.by_service || stats.ByService; if (byService) { Object.entries(byService).forEach(([service, count]) => { const li = document.createElement("li"); li.innerHTML = `${service || "unknown"}: ${count || 0} requests`; serviceList.appendChild(li); }); } const sessionList = document.getElementById("stats-by-session"); const sessionFilter = document.getElementById("filter-session"); const currentFilter = sessionFilter.value; sessionList.innerHTML = ""; sessionFilter.innerHTML = ''; const bySession = stats.by_session || stats.BySession; if (bySession) { // Sort by session ID (timestamp) descending const sortedSessions = Object.entries(bySession).sort((a, b) => { const sessionA = a[0] || ""; const sessionB = b[0] || ""; return sessionB.localeCompare(sessionA); }); sortedSessions.forEach(([session, count]) => { // Session format is like 20260215-160705-99213 // Try to make it more readable: 2026-02-15 16:07:05 (PID 99213) let sessionDisplay = session || "unknown"; if (session && session.includes("-")) { const parts = session.split("-"); if (parts.length >= 2) { const date = parts[0]; // 20260215 const time = parts[1]; // 160705 if (date.length === 8 && time.length === 6) { sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`; if (parts.length >= 3) { sessionDisplay += ` (PID ${parts[2]})`; } } } } const li = document.createElement("li"); li.innerHTML = ` ${sessionDisplay}: ${count || 0} requests
`; sessionList.appendChild(li); const opt = document.createElement("option"); opt.value = session || ""; opt.innerText = sessionDisplay; sessionFilter.appendChild(opt); }); sessionFilter.value = currentFilter; } } catch (error) { console.error("Failed to fetch interaction stats", error); } } function downloadSession(sessionId) { if (!sessionId) return; window.location.href = `/api/setup/interactions/sessions/${sessionId}/download`; } async function filterBySession(sessionId) { document.getElementById("filter-session").value = sessionId; fetchInteractions(); const browseContainer = document.getElementById("browse-recordings"); if (browseContainer) { browseContainer.scrollIntoView({behavior: "smooth"}); } } async function deleteSession(sessionId) { if (!sessionId) return; if (!confirm(`Are you sure you want to delete session ${sessionId}?`)) { return; } try { const response = await fetch(`/api/setup/interactions/sessions/${sessionId}`, { method: "DELETE", }); if (response.ok) { // If the deleted session was selected in the filter, clear the filter const sessionFilter = document.getElementById("filter-session"); if (sessionFilter.value === sessionId) { sessionFilter.value = ""; fetchInteractions(); } fetchInteractionStats(); } else { const err = await response.text(); alert("Failed to delete session: " + err); } } catch (error) { alert("Error deleting session: " + error.message); } } async function cleanupSessions() { if (!confirm("Are you sure you want to cleanup old sessions? Only the 10 most recent ones will be kept.",)) { return; } try { const response = await fetch("/api/setup/interactions/sessions?keep=10", { method: "DELETE", }); if (response.ok) { // Refresh everything document.getElementById("filter-session").value = ""; fetchInteractionStats(); fetchInteractions(); } else { const err = await response.text(); alert("Failed to cleanup sessions: " + err); } } catch (error) { alert("Error cleaning up sessions: " + error.message); } } async function fetchInteractions() { console.log("Fetching interactions..."); const session = document.getElementById("filter-session").value; const category = document.getElementById("filter-category").value; const since = document.getElementById("filter-since").value; let url = "/api/setup/interactions"; const params = []; if (session) params.push(`session=${encodeURIComponent(session)}`); if (category) params.push(`category=${encodeURIComponent(category)}`); if (since) params.push(`since=${encodeURIComponent(since)}`); if (params.length > 0) url += "?" + params.join("&"); try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const interactions = await response.json(); console.log("Fetched interactions:", interactions); const list = document.getElementById("interactions-list"); if (!list) { console.error("Could not find interactions-list element"); return; } // Show the parent summary box if it was hidden const browseContainer = list.closest(".summary-box"); if (browseContainer) { browseContainer.style.display = "block"; } list.innerHTML = ""; if (!interactions || interactions.length === 0) { list.innerHTML = 'No interactions found for current filters.'; return; } // Default sort: Session desc, then Counter asc // If a specific session is selected, sort primarily by counter asc interactions.sort((a, b) => { const sessionA = a.session || a.Session || ""; const sessionB = b.session || b.Session || ""; if (sessionA !== sessionB) { return sessionB.localeCompare(sessionA); } const counterA = a.counter || a.Counter || 0; const counterB = b.counter || b.Counter || 0; return counterA - counterB; }); interactions.forEach((i) => { const tr = document.createElement("tr"); tr.style.borderBottom = "1px solid #eee"; const counter = i.counter || i.Counter || 0; const timestamp = i.timestamp || i.Timestamp || ""; const method = i.method || i.Method || ""; const path = i.path || i.Path || ""; const status = i.status || i.Status || ""; const category = i.category || i.Category || ""; const session = i.session || i.Session || ""; const file = i.file || i.File || ""; const scmudcData = i.scmudc_data || i.SCMUDCData || null; let statusClass = ""; if (status >= 200 && status < 300) statusClass = "status-success"; else if (status >= 400) statusClass = "status-error"; // Create SCMUDC event details let eventDetails = ""; if (scmudcData) { const originIcon = getOriginIcon(scmudcData.origin); const actionIcon = getActionIcon(scmudcData.action); const truncatedCommand = scmudcData.command && scmudcData.command.length > 30 ? scmudcData.command.substring(0, 27) + "..." : scmudcData.command || ""; eventDetails = ` ${originIcon} ${actionIcon} ${truncatedCommand} ${scmudcData.decoded_data ? '(...)" : ""} `; } tr.innerHTML = ` ${counter} ${timestamp} ${method} ${path} ${status || "???"} ${category} ${eventDetails} `; list.appendChild(tr); }); } catch (error) { console.error("Failed to fetch interactions", error); } } // Helper functions for SCMUDC event display function getOriginIcon(origin) { const icons = { gabbo: "📱", // SoundTouch App console: "🎛️", // Device Hardware device: "🔄", // Internal System }; return icons[origin] || "❓"; } function getActionIcon(action) { const icons = { "play-pressed": "▶️", "pause-pressed": "⏸️", "power-pressed": "⚡", "preset-pressed": "⭐", "play-item": "🎵", "skip-forward-pressed": "⏭️", "stop-pressed": "⏹️", }; return icons[action] || "🔘"; } function showSCMUDCDetails(file) { // Find the interaction data for this file fetch("/api/setup/interactions") .then((response) => response.json()) .then((interactions) => { const interaction = interactions.find((i) => (i.file || i.File) === file); if (interaction && interaction.scmudc_data) { displaySCMUDCPopover(interaction.scmudc_data); } }) .catch((error) => { console.error("Failed to fetch SCMUDC details:", error); }); } function displaySCMUDCPopover(scmudcData) { const modal = document.createElement("div"); modal.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; `; const content = document.createElement("div"); content.style.cssText = ` background: white; padding: 20px; border-radius: 8px; max-width: 600px; max-height: 80%; overflow-y: auto; box-shadow: 0 4px 6px rgba(0,0,0,0.1); `; let html = `

SCMUDC Event Details

Origin: ${getOriginDescription(scmudcData.origin)} (${scmudcData.origin})

Action: ${scmudcData.action}

Summary: ${scmudcData.summary}

`; if (scmudcData.decoded_data) { html += `

Decoded Content:

Source: ${scmudcData.decoded_data.content_type}

Item: ${scmudcData.decoded_data.item_name}

`; if (scmudcData.decoded_data.source_account) { html += `

Account: ${scmudcData.decoded_data.source_account}

`; } if (scmudcData.decoded_data.artwork_url) { html += `

Artwork: View

`; } if (scmudcData.decoded_data.is_presetable) { html += `

Presetable: Yes

`; } if (scmudcData.decoded_data.xml_content) { html += `

Full XML Content:

${escapeHtml(scmudcData.decoded_data.xml_content)}
`; } } html += '
'; content.innerHTML = html; modal.appendChild(content); document.body.appendChild(modal); // Close on background click modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); } function getOriginDescription(origin) { const descriptions = { gabbo: "SoundTouch App", console: "Device Hardware", device: "Internal System", }; return descriptions[origin] || origin; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } async function viewInteraction(file) { try { const response = await fetch(`/api/setup/interaction-content?file=${encodeURIComponent(file)}`,); const content = await response.text(); document.getElementById("viewer-filename").innerText = file; document.getElementById("interaction-content").innerText = content; document.getElementById("interaction-viewer").style.display = "block"; document .getElementById("interaction-viewer") .scrollIntoView({behavior: "smooth"}); } catch (error) { alert("Failed to load interaction content: " + error); } } async function fetchDNSDiscoveries() { console.log("Fetching DNS discoveries..."); try { const response = await fetch("/api/setup/dns-discoveries"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const discoveries = await response.json(); console.log("Fetched DNS discoveries:", discoveries); const list = document.getElementById("dns-discoveries-list"); if (!list) { console.error("Could not find dns-discoveries-list element"); return; } list.innerHTML = ""; if (!discoveries || discoveries.length === 0) { list.innerHTML = 'No DNS queries discovered yet.'; return; } discoveries.forEach((d) => { const tr = document.createElement("tr"); tr.style.borderBottom = "1px solid #eee"; const hostname = d.hostname || ""; const lastSeen = d.last_seen || ""; const count = d.query_count || 0; const isBose = d.is_bose_service ? "✅" : "❌"; const category = d.is_intercepted ? "self" : "upstream"; const remoteAddr = d.remote_addr || "unknown"; tr.innerHTML = ` ${hostname} ${lastSeen} ${count} ${isBose} ${category} ${remoteAddr} `; list.appendChild(tr); }); } catch (error) { console.error("Failed to fetch DNS discoveries", error); } } async function clearDNSDiscoveries() { if (!confirm("Are you sure you want to clear all DNS discovery logs?")) { return; } try { const response = await fetch("/api/setup/dns-discoveries", { method: "DELETE", }); if (response.ok) { fetchDNSDiscoveries(); } else { const err = await response.text(); alert("Failed to clear DNS discoveries: " + err); } } catch (error) { alert("Error clearing DNS discoveries: " + error.message); } } function downloadDNSDiscoveries() { window.location.href = "/api/setup/dns-discoveries/download"; } async function showDeviceEvents() { const overlay = document.getElementById("device-events-overlay"); overlay.style.display = "block"; overlay.scrollIntoView({behavior: "smooth"}); // Ensure device selector is populated (handled by fetchDevices) // but if it's still empty, we can try to trigger a fetch const selector = document.getElementById("event-device-selector"); if (selector.options.length <= 1) { fetchDevices(); } } async function fetchDeviceEvents(deviceId) { if (!deviceId) return; const list = document.getElementById("events-list"); list.innerHTML = 'Loading events...'; try { const response = await fetch(`/api/setup/devices/${deviceId}/events`); const data = await response.json(); const events = data.events; list.innerHTML = ""; if (!events || events.length === 0) { list.innerHTML = 'No events found for this device.'; return; } // Sort events by time descending events.sort((a, b) => (b.time || "").localeCompare(a.time || "")); events.forEach((e) => { const tr = document.createElement("tr"); tr.style.borderBottom = "1px solid #eee"; const time = e.time || ""; const type = e.type || ""; const data = JSON.stringify(e.data || {}); tr.innerHTML = ` ${time} ${type} ${data} `; list.appendChild(tr); }); } catch (error) { list.innerHTML = `Error loading events: ${error.message}`; } } function formatBody(body, contentType) { if (!body) return ""; contentType = (contentType || "").toLowerCase(); if (contentType.includes("json")) { return formatJSON(body); } if (contentType.includes("xml")) { return formatXML(body); } return body; } function formatJSON(json) { if (!json) return ""; try { const obj = typeof json === "string" ? JSON.parse(json) : json; return JSON.stringify(obj, null, 2); } catch (e) { return json; } } function formatXML(xml) { if (!xml) return ""; try { let formatted = ""; let reg = /(>)(<)(\/*)/g; xml = xml.replace(reg, "$1\r\n$2$3"); let pad = 0; xml.split("\r\n").forEach(function (node) { let indent = 0; if (node.match(/.+<\/\w[^>]*>$/)) { indent = 0; } else if (node.match(/^<\/\w/)) { if (pad !== 0) pad -= 1; } else if (node.match(/^<\w[^>]*[^\/]>.*$/)) { indent = 1; } else { indent = 0; } let padding = ""; for (let i = 0; i < pad; i++) padding += " "; formatted += padding + node + "\r\n"; pad += indent; }); return formatted.trim(); } catch (e) { return xml; } } document.addEventListener("DOMContentLoaded", async () => { const cfg = await fetchSettings(); fetchVersion(); fetchAnnouncements(); const deviceCount = await fetchDevices(); // Only sweep automatically on a cold start (no devices known yet). When // devices are already in the store, rely on the cached list plus the // periodic sweep and the explicit Discover button. Re-probing offline // speakers on every admin page load was slow and surprising. if (deviceCount === 0 && cfg?.discovery_enabled !== false) { triggerDiscovery(); } const hash = window.location.hash.slice(1); const [tabId, extra] = hash.split('?'); if (tabId && document.getElementById(tabId)) { openTab(null, tabId); if (tabId === "tab-migration") { selectMigrationDevice(extra); } } const syncBtn = document.getElementById("sync-now-btn"); if (syncBtn) syncBtn.onclick = startSync; }); async function addManualDevice() { const ip = document.getElementById("add-manual-ip").value.trim(); if (!ip) { alert("Please enter an IP address"); return; } try { const response = await fetch("/api/setup/devices", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}), }); if (response.ok) { document.getElementById("add-manual-ip").value = ""; fetchDevices(); } else { const err = await response.text(); alert("Failed to add device: " + err); } } catch (error) { alert("Error adding device: " + error.message); } } async function removeDevice(deviceId, name) { if (!confirm(`Are you sure you want to remove device "${name}"?`)) { return; } try { const response = await fetch(`/api/setup/devices/${deviceId}`, { method: "DELETE", }); if (response.ok) { fetchDevices(); } else { const err = await response.text(); alert("Failed to remove device: " + err); } } catch (error) { alert("Error removing device: " + error.message); } } async function triggerDiscovery() { const indicator = document.getElementById("discovery-indicator"); if (indicator) indicator.style.display = "inline"; try { const response = await fetch("/api/setup/discover", {method: "POST"}); if (!response.ok) { const err = await response.text(); alert("Failed to start discovery: " + err); if (indicator) indicator.style.display = "none"; return; } pollDiscoveryStatus(); } catch (error) { console.error("Failed to trigger discovery", error); if (indicator) indicator.style.display = "none"; } } async function pollDiscoveryStatus() { const indicator = document.getElementById("discovery-indicator"); try { const response = await fetch("/api/setup/discovery-status"); const data = await response.json(); if (data.discovering) { setTimeout(pollDiscoveryStatus, 2000); } else { if (indicator) indicator.style.display = "none"; fetchDevices(); } } catch (error) { console.error("Failed to check discovery status", error); if (indicator) indicator.style.display = "none"; } } async function updateDeviceInfo(deviceId, ip) { try { const response = await fetch("/api/setup/info/" + encodeURIComponent(deviceId)); if (!response.ok) return; const info = await response.json(); const rowId = "device-row-" + deviceId; const row = document.getElementById(rowId); if (row) { const nameEl = row.querySelector(".col-name"); if (nameEl && info.name) nameEl.innerText = info.name; const modelEl = row.querySelector(".col-model"); if (modelEl && info.type) modelEl.innerText = info.type; const serialEl = row.querySelector(".col-serial"); if (serialEl && info.serialNumber) serialEl.innerText = info.serialNumber; const firmwareEl = row.querySelector(".col-firmware"); if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion; const deviceIdEl = row.querySelector(".col-deviceid"); if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID; const accountIdEl = row.querySelector(".col-accountid"); if (accountIdEl) { if (info.margeAccountUUID) { accountIdEl.innerText = info.margeAccountUUID; accountIdEl.style.color = "#666"; } else { // Empty in /info → speaker is // either factory-reset or never paired. The // Migration tab's wizard already detects this // state and prompts for re-pairing; this badge // surfaces the affordance from the devices list // so users don't have to know to open the // Migration tab cold. See issue #234. accountIdEl.replaceChildren(); const badge = document.createElement("a"); badge.href = "#"; badge.onclick = (e) => { e.preventDefault(); prepareMigration(deviceId); }; badge.innerText = "⚠ Not paired — re-pair"; badge.style.color = "#c62828"; badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired)."; accountIdEl.appendChild(badge); } } } } catch (error) { console.warn("Failed to fetch live info for " + ip, error); } } async function showSummary(deviceId) { if (!deviceId) { document.getElementById("migration-summary").style.display = "none"; return; } const newHash = '#tab-migration?' + deviceId; if (window.location.hash !== newHash) { history.pushState(null, '', newHash); } const targetUrl = document.getElementById("target-domain").value; // Per-field URL overrides (Plan card). The summary endpoint uses // these to render the planned-config diff so the user sees the // exact XML that the migration will write. const opts = readPlanURLOptions(); const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; const display = getDeviceDisplayName(deviceId); statusDiv.textContent = "Fetching summary for " + display + "..."; const outputBox = document.getElementById("command-output-box"); if (outputBox) outputBox.style.display = "none"; let query = "?target_url=" + encodeURIComponent(targetUrl); for (let k in opts) { query += "&" + k + "=" + encodeURIComponent(opts[k]); } try { const response = await fetch("/api/setup/summary/" + encodeURIComponent(deviceId) + query,); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText); } const summary = await response.json(); statusDiv.style.display = "none"; const ip = summary.ip_address || deviceId; const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip; document.getElementById("summary-device-display").innerText = finalDisplay; // Detect a device switch BEFORE we clobber the hidden id input. // When the user moves between speakers in the dropdown, any // per-device form state (plan-card URL edits, the soundcork // checkbox, the "saved" hint) belongs to the previous device // and would otherwise leak into the new device's preview. const prevDeviceId = document.getElementById("summary-device-id").value; const deviceChanged = prevDeviceId && prevDeviceId !== deviceId; // Keep deviceId hidden for subsequent calls document.getElementById("summary-device-id").value = deviceId; if (deviceChanged) { resetPlanCardForDeviceSwitch(); } // Update table row if it exists const rowId = "device-row-" + deviceId; const row = document.getElementById(rowId); if (row) { const nameEl = row.querySelector(".col-name"); if (nameEl && summary.device_name) nameEl.innerText = summary.device_name; const modelEl = row.querySelector(".col-model"); if (modelEl && summary.device_model) modelEl.innerText = summary.device_model; const serialEl = row.querySelector(".col-serial"); if (serialEl && summary.device_serial) serialEl.innerText = summary.device_serial; const firmwareEl = row.querySelector(".col-firmware"); if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version; const deviceIdEl = row.querySelector(".col-deviceid"); if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id; const accountIdEl = row.querySelector(".col-accountid"); if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id; } renderMigrationState(summary, targetUrl); renderPlan(summary); renderPlanCurrentURLs(summary); renderPlanPairing(summary, deviceId); renderPreflightWarnings(summary); // Mirror the global target URL into the Plan card's input. // Reset the "saved" feedback to the persisted value so a // subsequent edit can flag it as unsaved without confusing // the user. const planInput = document.getElementById("plan-target-url"); if (planInput) planInput.value = targetUrl || ""; const planSaved = document.getElementById("plan-target-saved"); if (planSaved) { planSaved.dataset.savedValue = targetUrl || ""; planSaved.innerText = ""; } // Pre-fill the Plan card per-field URL inputs with the canonical // defaults from target URL. fillPlanURLInputs preserves any // existing user edits across summary refreshes — the user has // to click "Reset to defaults" to clobber them, which matches // the Telnet pane's existing semantics. const soundcork = document.getElementById("plan-soundcork-mode") && document.getElementById("plan-soundcork-mode").checked; fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork})); const migrationStatus = document.getElementById("migration-status"); const otherTarget = isMigratedToOtherTarget(summary); migrationStatus.innerText = summary.is_migrated ? "✅ Migrated to AfterTouch" : otherTarget ? "⚠️ Migrated (URL mismatch)" : "❌ Not Migrated"; migrationStatus.style.color = summary.is_migrated ? "green" : otherTarget ? "orange" : "red"; migrationStatus.style.fontWeight = "bold"; // Trust CA Now button now lives inside the state card's CA / TLS // cell. Show only when SSH is reachable AND the CA isn't already // trusted on the device. const trustBtn = document.getElementById("trust-ca-btn"); if (trustBtn) { const canTrust = summary.ssh_success && !summary.ca_cert_trusted; trustBtn.style.display = canTrust ? "inline-block" : "none"; trustBtn.onclick = () => trustCA(deviceId, ip); } // The HTTPS Connection Test runs `curl` on the device via SSH — // upload-temp-CA + run-curl — so the panel is irrelevant when // SSH isn't reachable. The implicit telnet-poke + observation // alternative is on the roadmap but not implemented yet. const connectionTestPane = document.getElementById("connection-test"); if (connectionTestPane) { connectionTestPane.style.display = summary.ssh_success ? "block" : "none"; } // Stays visible either way (the user may still want to check it), // but the default Suggested Plan never needs HTTPS — only note it // as required when the Target URL itself is https://. const connectionTestNote = document.getElementById("connection-test-relevance-note"); if (connectionTestNote) { if (isHttpsTarget(targetUrl)) { connectionTestNote.innerText = "Required for your current plan (HTTPS)"; connectionTestNote.style.color = "#c62828"; } else { connectionTestNote.innerText = "Optional for your current plan (HTTP)"; connectionTestNote.style.color = "#666"; } } const currentConfigElem = document.getElementById("current-config"); currentConfigElem.innerText = summary.current_config; currentConfigElem.style.color = summary.ssh_success ? "black" : "red"; document.getElementById("planned-config").innerText = summary.planned_config; document.getElementById("planned-resolv").innerText = summary.planned_resolv || ""; const resolveErrEl = document.getElementById("resolve-ip-error"); if (summary.resolve_ip_error) { document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error; resolveErrEl.style.display = "block"; } else { resolveErrEl.style.display = "none"; } const currentResolvElem = document.getElementById("current-resolv-content"); if (currentResolvElem) { currentResolvElem.innerText = summary.current_resolv_conf || "Not available"; } const testUrlElem = document.getElementById("test-url"); testUrlElem.innerText = summary.server_https_url || "N/A"; const testResultDiv = document.getElementById("test-result"); testResultDiv.style.display = "none"; testResultDiv.innerText = ""; document.getElementById("test-connection-explicit-btn").onclick = () => testConnection(deviceId, true); document.getElementById("test-connection-trusted-btn").onclick = () => testConnection(deviceId, false); document.getElementById("test-dns-btn").onclick = () => testDNSRedirection(deviceId); renderCustomizeForm(summary); // Migration and reboot are gated on having *some* transport // reachable. Telnet is enough for the telnet method (no SSH // required); the server returns a clear error if the user picks a // method whose transport isn't actually available. const anyTransport = summary.ssh_success || summary.telnet_reachable; // Stash device id/ip on the customize form so applyCustomPlan // can find them without globals. const customizeForm = document.getElementById("customize-form"); if (customizeForm) { customizeForm.dataset.deviceId = deviceId; customizeForm.dataset.deviceIp = ip || ""; } const revertBtn = document.getElementById("revert-migrate-btn"); revertBtn.onclick = () => revert(deviceId, ip); revertBtn.disabled = !summary.ssh_success; revertBtn.style.display = summary.original_config ? "inline-block" : "none"; const rebootBtn = document.getElementById("reboot-speaker-btn"); rebootBtn.onclick = () => reboot(deviceId, ip); rebootBtn.disabled = !anyTransport; rebootBtn.style.border = "none"; // Reset border if it was set during migration const remoteBtn = document.getElementById("ensure-remote-btn"); remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip); remoteBtn.disabled = !summary.ssh_success; const removeRemoteBtn = document.getElementById("remove-remote-btn"); removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip); removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled; document.getElementById("migration-summary").style.display = "block"; document.getElementById("migration-summary").scrollIntoView(); } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error fetching summary for " + display + ": " + error; } } function refreshSummary() { // Prefer the device id of the most recently shown summary; fall back // to the dropdown so the refresh button works even before the user // has loaded a summary once. const deviceId = document.getElementById("summary-device-id").value || document.getElementById("migration-device-list").value; if (deviceId) { showSummary(deviceId); } } function showCommandOutput(result) { const outputBox = document.getElementById("command-output-box"); const outputText = document.getElementById("command-output"); if (outputBox && outputText && result.output) { outputBox.style.display = "block"; outputText.innerText = result.output; } else if (outputBox) { outputBox.style.display = "none"; } } async function revert(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const display = getDeviceDisplayName(deviceId); if (!confirm("Are you sure you want to revert " + display + " to Bose cloud defaults?",)) { return; } const summaryDiv = document.getElementById("migration-summary"); summaryDiv.style.display = "none"; const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; statusDiv.textContent = "Reverting " + display + " to defaults..."; try { const response = await fetch("/api/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully started revert for " + display + "."; } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Revert failed for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error reverting " + display + ": " + error; } } async function reboot(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const display = getDeviceDisplayName(deviceId); if (!confirm("Are you sure you want to reboot the speaker at " + display + "?")) { return; } // Pick the reboot transport from the Customize form's URL flip // selection — telnet there strongly implies the user is going // SSH-less, so reboot via telnet too. Fall back to ssh otherwise. const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || ""; const rebootMethod = flip === "telnet" ? "telnet" : "ssh"; const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; // textContent avoids reinterpreting the (user-controlled) device name as HTML. statusDiv.textContent = "Rebooting " + display + " via " + rebootMethod + "..."; try { const url = "/api/setup/reboot/" + encodeURIComponent(deviceId) + "?method=" + encodeURIComponent(rebootMethod); const response = await fetch(url, {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully started reboot for " + display + "."; } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Reboot failed for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error rebooting " + display + ": " + error; } } // --- Plan card: account pairing ---------------------------------------- // renderPlanPairing populates the Account pairing section of the Plan // card from the live summary (margeAccountUUID is in summary.account_id // after populateDeviceInfo) and loads known IDs from the datastore. async function renderPlanPairing(summary, deviceId) { const currentP = document.getElementById("plan-pair-current"); const input = document.getElementById("plan-pair-id"); const status = document.getElementById("plan-pair-status"); if (currentP) { if (summary.is_paired && summary.account_id) { currentP.replaceChildren( document.createTextNode("Current: ✅ Paired (account "), Object.assign(document.createElement("strong"), {textContent: summary.account_id}), document.createTextNode(") — leave as-is to keep, or change the ID to re-pair"), ); } else { currentP.replaceChildren( document.createTextNode("Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply"), ); } } if (input) { input.value = summary.account_id || ""; input.dataset.currentId = summary.account_id || ""; input.style.borderColor = ""; } if (status) { status.innerText = ""; status.style.color = "#666"; } await loadPlanAccountSuggestions(deviceId); // Run the change handler once so the status hint reflects the // pre-filled value (matches current → "no pairing needed"). onPlanPairIDChange(); } // loadPlanAccountSuggestions populates the datastore-pick dropdown // from /api/setup/account-id-suggestions. Quietly degrades on failure — // the input + Generate button still work standalone. async function loadPlanAccountSuggestions(deviceId) { const select = document.getElementById("plan-pair-existing"); if (!select) return; select.replaceChildren(); const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.innerText = "— pick from datastore —"; select.appendChild(placeholder); try { const resp = await fetch("/api/setup/account-id-suggestions/" + encodeURIComponent(deviceId)); if (!resp.ok) return; const data = await resp.json(); for (const id of data.known || []) { const opt = document.createElement("option"); opt.value = String(id); opt.textContent = String(id); select.appendChild(opt); } } catch (e) { /* best-effort */ } } // onPlanPairPick mirrors the dropdown choice into the input and // triggers validation. function onPlanPairPick() { const select = document.getElementById("plan-pair-existing"); const input = document.getElementById("plan-pair-id"); if (select && select.value && input) { input.value = select.value; onPlanPairIDChange(); } } // generatePlanAccountID picks a random 7-digit ID, avoiding values // already shown in the dropdown so we don't accidentally collide with // known datastore entries on this machine. function generatePlanAccountID() { const input = document.getElementById("plan-pair-id"); if (!input) return; const select = document.getElementById("plan-pair-existing"); const known = new Set(); if (select) { for (const o of select.options) if (o.value) known.add(o.value); } for (let i = 0; i < 32; i++) { const n = Math.floor(Math.random() * 9_000_000) + 1_000_000; const s = String(n); if (!known.has(s)) { input.value = s; onPlanPairIDChange(); return; } } input.value = ""; onPlanPairIDChange(); } // onPlanPairIDChange validates the input and surfaces the implicit // intent: empty/matches-current → no pairing step queued; differs // → pairing step queued at Apply time. function onPlanPairIDChange() { const input = document.getElementById("plan-pair-id"); const status = document.getElementById("plan-pair-status"); if (!input || !status) return; const v = (input.value || "").trim(); const currentId = input.dataset.currentId || ""; if (!v) { if (currentId) { status.innerText = "→ no pairing step (current ID retained)"; status.style.color = "#666"; } else { status.innerText = "→ no pairing step (device stays unpaired — pair via the official Bose app later if needed)"; status.style.color = "#bf6900"; } input.style.borderColor = ""; return; } if (!/^\d{7}$/.test(v)) { status.innerText = "❌ Account ID must be exactly 7 digits"; status.style.color = "#c62828"; input.style.borderColor = "#c62828"; return; } if (v === currentId) { status.innerText = "→ matches current ID — no pairing step needed"; status.style.color = "#666"; input.style.borderColor = ""; return; } if (currentId) { status.innerText = `→ will re-pair from ${currentId} to ${v}`; } else { status.innerText = `→ will pair with ${v}`; } status.style.color = "#1976d2"; input.style.borderColor = ""; } // readPlanPairTarget returns null when no pairing step should run, or // the {accountId, valid} for Apply orchestration. Empty input or input // matching current = null (no step). Invalid input also returns null // but with valid=false so callers can refuse to continue. function readPlanPairTarget() { const input = document.getElementById("plan-pair-id"); if (!input) return null; const v = (input.value || "").trim(); if (!v) return null; if (!/^\d{7}$/.test(v)) return {accountId: v, valid: false}; const currentId = input.dataset.currentId || ""; if (v === currentId) return null; return {accountId: v, valid: true}; } // pairAccount POSTs to /api/setup/pair-account and throws on failure so // the Apply orchestrator's first-failure-aborts logic kicks in. async function pairAccount(deviceId, accountId) { const url = `/api/setup/pair-account/${encodeURIComponent(deviceId)}?account_id=${encodeURIComponent(accountId)}`; const resp = await fetch(url, {method: "POST"}); const result = await resp.json(); if (!resp.ok || !result.ok) { throw new Error(result.error || result.message || `pair-account returned ${resp.status}`); } } // migrate runs a single migrate call against the backend. method is // passed explicitly by the caller (suggested-plan or custom-plan // orchestrator); the legacy migration-method dropdown is gone. async function migrate(deviceId, ip, method) { if (!deviceId) { alert("Please select a device."); return; } if (!method) { alert("Migration method is required"); return; } const targetUrl = document.getElementById("target-domain").value; // Refuse to migrate when the Plan card's per-field URLs don't pass // basic validation — typoed URLs would silently brick the speaker // until reverted, and the validator highlights exactly which field // is broken. if (!validatePlanURLs()) { const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Cannot migrate: one or more service URLs are invalid (see the validation errors above)."; return; } // Per-field URL overrides from the Plan card. The backend's // applyURLOverrides honors these for both XML and Telnet // migrations. Empty inputs are omitted so the canonical-default // fallback runs server-side. const opts = readPlanURLOptions(); const summaryDiv = document.getElementById("migration-summary"); summaryDiv.style.display = "none"; const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; const display = getDeviceDisplayName(deviceId); statusDiv.textContent = "Migrating " + display + " using " + method + "..."; let query = "?method=" + encodeURIComponent(method) + "&target_url=" + encodeURIComponent(targetUrl); for (let k in opts) { query += "&" + k + "=" + encodeURIComponent(opts[k]); } try { const response = await fetch("/api/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; // replaceChildren keeps the user-controlled `display` outside any // HTML-parsing context, while preserving the intentional . statusDiv.replaceChildren( document.createTextNode("Successfully started migration for " + display + ". "), Object.assign(document.createElement("strong"), { textContent: "Please reboot the device to activate the changes.", }), ); // Make reboot button available and prominent. It lives in the // always-visible "Speaker controls" row (see #621 — it used to // be reachable only after expanding "Customize this migration"), // so no need to force any collapsed container open here. const rebootBtn = document.getElementById("reboot-speaker-btn"); rebootBtn.style.display = "inline-block"; rebootBtn.disabled = false; rebootBtn.style.border = "2px solid #000"; // Re-show summary but with prominence on reboot summaryDiv.style.display = "block"; } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Migration failed for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error migrating " + display + ": " + error; } } async function trustCA(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; const display = getDeviceDisplayName(deviceId); statusDiv.textContent = "Injecting Root CA into shared trust store on " + display + "..."; try { const response = await fetch("/api/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully injected Root CA on " + display + "."; showSummary(deviceId); // Refresh to update status } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Failed to trust CA on " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error trusting CA on " + display + ": " + error; } } async function ensureRemoteServices(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const summaryDiv = document.getElementById("migration-summary"); summaryDiv.style.display = "none"; const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; const display = getDeviceDisplayName(deviceId); statusDiv.textContent = "Ensuring remote services for " + display + "..."; try { const response = await fetch("/api/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully ensured remote services for " + display + "."; } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Failed to ensure remote services for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error ensuring remote services for " + display + ": " + error; } } async function removeRemoteServices(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const display = getDeviceDisplayName(deviceId); if (!confirm("Remove the remote_services file from " + display + "? SSH will be disabled after the next reboot.")) { return; } const summaryDiv = document.getElementById("migration-summary"); summaryDiv.style.display = "none"; const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; statusDiv.textContent = "Removing remote services for " + display + "..."; try { const response = await fetch("/api/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully removed remote services from " + display + "."; } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Failed to remove remote services for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error removing remote services for " + display + ": " + error; } } async function backupConfig(deviceId, ip) { if (!deviceId) { alert("Please select a device."); return; } const statusDiv = document.getElementById("status"); statusDiv.style.display = "block"; statusDiv.style.backgroundColor = "#ffffcc"; const display = getDeviceDisplayName(deviceId); statusDiv.textContent = "Creating backup for " + display + "..."; try { const response = await fetch("/api/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},); const result = await response.json(); showCommandOutput(result); if (result.ok) { statusDiv.style.backgroundColor = "#ccffcc"; statusDiv.textContent = "Successfully created backup for " + display + "."; showSummary(deviceId); // Refresh } else { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Backup failed for " + display + ": " + (result.message || "Unknown error"); } } catch (error) { statusDiv.style.backgroundColor = "#ffcccc"; statusDiv.textContent = "Error creating backup for " + display + ": " + error; } } async function testConnection(deviceId, useExplicitCA) { const testUrl = document.getElementById("test-url").innerText; const testResultDiv = document.getElementById("test-result"); const display = getDeviceDisplayName(deviceId); testResultDiv.style.display = "block"; testResultDiv.style.backgroundColor = "#f0f0f0"; testResultDiv.style.color = "black"; testResultDiv.innerText = "Running connection test from " + display + "...\n(This may take a few seconds)"; try { const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`; const response = await fetch(`/api/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},); const result = await response.json(); if (result.ok) { testResultDiv.style.backgroundColor = "#ccffcc"; testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output; } else { testResultDiv.style.backgroundColor = "#ffcccc"; testResultDiv.innerText = "❌ Connection failed: " + result.message + "\n\nOutput:\n" + result.output; } } catch (error) { testResultDiv.style.backgroundColor = "#ffcccc"; testResultDiv.innerText = "❌ Error triggering test: " + error; } } async function testDNSRedirection(deviceId) { const targetUrl = document.getElementById("target-domain").value; const testResultDiv = document.getElementById("dns-test-result"); const display = getDeviceDisplayName(deviceId); testResultDiv.style.display = "block"; testResultDiv.style.backgroundColor = "#f0f0f0"; testResultDiv.style.color = "black"; testResultDiv.innerText = "Running DNS redirection test from " + display + "...\n(This may take a few seconds)"; try { const query = `?target_url=${encodeURIComponent(targetUrl)}`; const response = await fetch(`/api/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},); const result = await response.json(); if (result.ok) { testResultDiv.style.backgroundColor = "#ccffcc"; testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output; } else { testResultDiv.style.backgroundColor = "#ffcccc"; testResultDiv.innerText = "❌ Test failed: " + result.message + "\n\nOutput:\n" + result.output; } } catch (error) { testResultDiv.style.backgroundColor = "#ffcccc"; testResultDiv.innerText = "❌ Error triggering test: " + error; } } // onPlanTargetURLChange mirrors the migration tab's plan-target-url // input back into the canonical #target-domain input on the Settings // tab. Keeps the rest of the migration flow (which still reads // #target-domain) working transparently. function onPlanTargetURLChange() { const v = document.getElementById("plan-target-url").value; const canonical = document.getElementById("target-domain"); if (canonical) canonical.value = v; // Hide stale "saved" feedback now that the value diverged from the // last persisted state. const saved = document.getElementById("plan-target-saved"); if (saved && saved.dataset.savedValue && saved.dataset.savedValue !== v) { saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist"; saved.style.color = "#bf6900"; } // Re-derive the four service URL fields from the new Target URL, same // as the initial pre-fill on summary render. fillPlanURLInputs still // only overwrites fields the user hasn't hand-edited (tracked via // dataset.autofilled), so this doesn't clobber genuinely manual edits. // Without this, changing Target Domain to e.g. localhost left the four // fields pointed at a stale default with no warning until the user // edited them by hand (#621 follow-up). const soundcork = document.getElementById("plan-soundcork-mode") && document.getElementById("plan-soundcork-mode").checked; fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork})); } // onPlanURLFieldEdited marks a Plan-card URL input as manually edited so // fillPlanURLInputs stops treating it as an auto-fillable default, then // re-validates. Wired from each of the four fields' oninput instead of // calling validatePlanURLs() directly. function onPlanURLFieldEdited(el) { el.dataset.autofilled = ""; validatePlanURLs(); } // saveTargetURLAsDefault posts the current plan-target-url value to // /api/setup/settings as the global default. Other settings on that // endpoint are read first and re-posted unchanged ("***" secrets and // other fields are preserved verbatim — the backend treats "***" as // "unchanged"). async function saveTargetURLAsDefault() { const newURL = document.getElementById("plan-target-url").value.trim(); if (!newURL) { alert("Target URL is empty"); return; } const btn = document.getElementById("plan-save-default-btn"); const saved = document.getElementById("plan-target-saved"); btn.disabled = true; try { const cur = await (await fetch("/api/setup/settings")).json(); cur.server_url = newURL; const resp = await fetch("/api/setup/settings", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(cur), }); if (!resp.ok) throw new Error(await resp.text()); if (saved) { saved.innerText = "✅ Saved. Restart the service to apply changes that need a fresh certificate set."; saved.style.color = "green"; saved.dataset.savedValue = newURL; } } catch (e) { if (saved) { saved.innerText = "❌ Failed to save: " + e.message; saved.style.color = "red"; } } finally { btn.disabled = false; } } // defaultServiceURLs returns the canonical four URLs derived from a // service base, with an optional Soundcork-mode adjustment that // appends /marge to margeServerUrl. Mirrors setup.defaultTelnetURLs // (Go) for the soundtouch-service case; the soundcorkMode option is // the UI-side equivalent of the documented soundcork recipe — see // docs/analysis/TELNET-MIGRATION-METHOD.md §2.1. function defaultServiceURLs(targetUrl, options = {}) { const base = (targetUrl || "").replace(/\/+$/, ""); return { marge: options.soundcorkMode ? base + "/marge" : base, stats: base, sw_update: base + "/updates/soundtouch", bmx: base + "/bmx/registry/v1/services", }; } // fillPlanURLInputs writes the four URLs into the Plan card inputs. // force=true overwrites existing values (used by Reset and the // Soundcork toggle); force=false only fills empties and fields still // flagged dataset.autofilled=true (used on summary render and on Target // URL changes, so manual edits survive but a still-default value tracks // Target URL). Every field this function writes to is (re-)flagged // autofilled; onPlanURLFieldEdited clears the flag the moment a user // types into a field directly. function fillPlanURLInputs(urls, {force = false} = {}) { const fields = [ ["plan-marge-url", urls.marge], ["plan-stats-url", urls.stats], ["plan-sw_update-url", urls.sw_update], ["plan-bmx-url", urls.bmx], ]; for (const [id, value] of fields) { const el = document.getElementById(id); if (!el) continue; if (force || !el.value || el.dataset.autofilled === "true") { el.value = value; el.dataset.autofilled = "true"; } } validatePlanURLs(); } // readPlanURLOptions returns the four inputs as the option keys the // backend expects (marge_url / stats_url / sw_update_url / bmx_url). // Empty inputs are omitted so the service-side canonical-default // fallback runs. function readPlanURLOptions() { const out = {}; const pairs = [ ["marge_url", "plan-marge-url"], ["stats_url", "plan-stats-url"], ["sw_update_url", "plan-sw_update-url"], ["bmx_url", "plan-bmx-url"], ]; for (const [optKey, elemId] of pairs) { const el = document.getElementById(elemId); if (el && el.value) out[optKey] = el.value.trim(); } return out; } // validateURL classifies a string as an OK service URL. // Empty value is valid (means "use the canonical default"). Otherwise // the URL must parse, the scheme must be http or https, the hostname // must be non-empty, and we flag loopback hostnames because they only // reach AfterTouch in the on-device-install case (AfterTouch running // on the speaker itself). For the typical "AfterTouch on a separate // host" deployment, the speaker can't reach loopback on a different // machine, so the URL must be a LAN-reachable IP or hostname. // // referenceOrigin (optional) is the plan's own Target URL origin. A // loopback value that matches it is exempted from the warning: it means // this is exactly what the service itself is already configured to // answer as (e.g. an on-device install's `http://localhost:8000`, // auto-set since #546), not a mistaken paste. Without this exemption, // every on-device install's Suggested Plan fails validation by // default and silently disables Apply/Pre-flight before the user does // anything (#546 follow-up, reported via #621). function validateURL(value, referenceOrigin) { const v = (value || "").trim(); if (!v) return {ok: true, error: ""}; let u; try { u = new URL(v); } catch (e) { return {ok: false, error: "not a valid URL"}; } if (u.protocol !== "http:" && u.protocol !== "https:") { return {ok: false, error: "scheme must be http or https"}; } if (!u.hostname) return {ok: false, error: "hostname is empty"}; const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1"; if (isLoopback && u.origin !== referenceOrigin) { return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."}; } return {ok: true, error: ""}; } // validatePlanURLs validates each of the four Plan-card inputs. Returns // true when all are valid. Surfaces inline errors in the validation // box, colours invalid input borders red, and disables the Apply // Suggested Plan button when anything is invalid. function validatePlanURLs() { const fields = [ ["margeServerUrl", "plan-marge-url"], ["statsServerUrl", "plan-stats-url"], ["swUpdateUrl", "plan-sw_update-url"], ["bmxRegistryUrl", "plan-bmx-url"], ]; const targetUrl = (document.getElementById("plan-target-url") || {}).value || ""; let referenceOrigin = ""; try { referenceOrigin = new URL(targetUrl).origin; } catch (e) { // Target URL isn't a valid absolute URL yet (e.g. empty) — leave // referenceOrigin empty, so a loopback field simply won't match // it and falls back to today's warning, same as before this // exemption existed. } const errors = []; for (const [name, elemId] of fields) { const el = document.getElementById(elemId); if (!el) continue; const v = validateURL(el.value, referenceOrigin); el.style.borderColor = v.ok ? "" : "#c62828"; if (!v.ok) errors.push(`${name}: ${v.error}`); } const errorBox = document.getElementById("plan-url-validation"); if (errorBox) { if (errors.length === 0) { errorBox.style.display = "none"; errorBox.replaceChildren(); } else { errorBox.replaceChildren(); const ul = document.createElement("ul"); ul.style.cssText = "margin: 0; padding-left: 1.2em"; for (const e of errors) { const li = document.createElement("li"); li.innerText = e; ul.appendChild(li); } errorBox.appendChild(ul); errorBox.style.display = "block"; } } // Apply Suggested Plan is gated on URL validity (in addition to its // existing data-method check). The dataset.method field is set by // renderPlan based on what computeSuggestedPlan returned. The // Pre-flight sibling shares the same gate so the two buttons // enable/disable in lockstep. const applyBtn = document.getElementById("plan-apply-btn"); const preflightBtn = document.getElementById("plan-preflight-btn"); const noPlan = !applyBtn || !applyBtn.dataset.method; if (applyBtn) applyBtn.disabled = noPlan || errors.length > 0; if (preflightBtn) preflightBtn.disabled = noPlan || errors.length > 0; // Refresh the client-side XML preview so the Customize panel's // Planned Config pane tracks every keystroke without a backend // round-trip. renderPlannedXMLPreview(); return errors.length === 0; } // resetPlanURLsToDefaults wipes manual edits and reapplies the // canonical defaults derived from the current target URL, honoring // the Soundcork-mode checkbox. function resetPlanURLsToDefaults() { const targetUrl = document.getElementById("plan-target-url").value; const soundcork = document.getElementById("plan-soundcork-mode") && document.getElementById("plan-soundcork-mode").checked; fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}), {force: true}); } // resetPlanCardForDeviceSwitch clears every per-device form state on // the Plan card so the previously-edited values don't leak into the // new device's preview. Called from showSummary the moment the user // picks a different speaker in the dropdown. // // Doesn't refill defaults itself — that happens further down in // showSummary via fillPlanURLInputs(defaults), which writes only into // empty inputs. function resetPlanCardForDeviceSwitch() { const inputs = ["plan-marge-url", "plan-stats-url", "plan-sw_update-url", "plan-bmx-url"]; for (const id of inputs) { const el = document.getElementById(id); if (el) { el.value = ""; el.style.borderColor = ""; } } const soundcork = document.getElementById("plan-soundcork-mode"); if (soundcork) soundcork.checked = false; const saved = document.getElementById("plan-target-saved"); if (saved) { saved.innerText = ""; delete saved.dataset.savedValue; } const validationBox = document.getElementById("plan-url-validation"); if (validationBox) { validationBox.style.display = "none"; validationBox.replaceChildren(); } const applyStatus = document.getElementById("plan-apply-status"); if (applyStatus) applyStatus.innerText = ""; const customizeStatus = document.getElementById("customize-apply-status"); if (customizeStatus) customizeStatus.innerText = ""; // Pairing input — clear so the renderPlanPairing call later in // showSummary populates it from the new device's account_id // rather than the previous device's pre-typed value. const pairInput = document.getElementById("plan-pair-id"); if (pairInput) { pairInput.value = ""; pairInput.style.borderColor = ""; delete pairInput.dataset.currentId; } const pairStatus = document.getElementById("plan-pair-status"); if (pairStatus) pairStatus.innerText = ""; } // toggleSoundcorkMode reapplies defaults so the /marge suffix appears // or disappears on margeServerUrl. Manual edits are intentionally // reset — the checkbox is a deliberate "give me the canonical // soundcork shape" affordance, not a soft hint. function toggleSoundcorkMode() { resetPlanURLsToDefaults(); } // computeSuggestedPlan picks the most conservative migration recipe // for the device based on which transports are reachable. The chosen // default is XML over SSH with HTTP — fewest moving parts, no DNS or // CA install required. Telnet is the fallback when SSH is unavailable. function computeSuggestedPlan(summary) { if (summary.is_migrated) { return { available: false, summary: "✅ Already migrated to AfterTouch", note: "Use Customize below to make further changes, or click Reboot if needed.", }; } if (summary.ssh_success) { return { available: true, method: "xml", summary: "XML migration over SSH (HTTP)", steps: [ "Backup existing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml", "Write new XML pointing at AfterTouch (HTTP, no CA install)", "Reboot the device to load the new configuration", ], }; } if (summary.telnet_reachable) { return { available: true, method: "telnet", summary: "Telnet (Port 17000) URL flip (HTTP)", steps: [ "Connect to the diagnostic shell on TCP/17000", "Write the four URLs via `sys configuration` + `envswitch boseurls set`", "Reboot the device via telnet to persist the change", ], }; } return { available: false, summary: "❌ No supported transport reachable", note: "Neither SSH nor Telnet:17000 answers. Use the official Bose app to pair before EOS, or unlock remote_services via USB stick to enable SSH.", }; } // renderPlannedXMLPreview rewrites the #planned-config
 from the
// current Plan card inputs so the preview tracks the user's edits live
// — without a backend round-trip. The output mirrors what
// migrateViaXML actually writes: target-derived defaults, with each
// per-field override (marge_url / stats_url / sw_update_url /
// bmx_url) substituted in. The three boolean fields are constants
// matching the Go-side PrivateCfg{} the migration constructs.
//
// Pure client-side once GetMigrationSummary has populated everything,
// so the preview updates on every keystroke and stays in sync with
// validatePlanURLs's input border / Apply-button state.
function renderPlannedXMLPreview() {
    const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
    const overrides = readPlanURLOptions();
    const defaults = defaultServiceURLs(targetUrl);

    const marge    = overrides.marge_url     || defaults.marge;
    const stats    = overrides.stats_url     || defaults.stats;
    const swUpdate = overrides.sw_update_url || defaults.sw_update;
    const bmx      = overrides.bmx_url       || defaults.bmx;

    const xml = [
        '',
        '',
        `  ${escapeForXMLText(marge)}`,
        `  ${escapeForXMLText(stats)}`,
        `  ${escapeForXMLText(swUpdate)}`,
        '  true',
        '  true',
        '  false',
        `  ${escapeForXMLText(bmx)}`,
        '',
    ].join("\n");

    const el = document.getElementById("planned-config");
    if (el) el.innerText = xml;
}

// escapeForXMLText escapes the characters that have special meaning
// inside XML text content. URLs typically only need & escaping, but
// covering < and > too keeps the preview honest if the user pastes
// something exotic.
function escapeForXMLText(s) {
    return String(s)
        .replace(/&/g, "&")
        .replace(//g, ">");
}

// renderPlanCurrentURLs populates the "Current on Device" cells in the
// Service URLs table from whichever transport answered (telnet
// getpdo, falling back to the SSH-read XML config).
function renderPlanCurrentURLs(summary) {
    const live = parseTelnetVerifiedConfig(summary.telnet_verified_config || "");
    const xml = summary.parsed_current_config || {};
    const cells = [
        ["plan-current-marge",     live.margeServerUrl     || xml.margeServerUrl],
        ["plan-current-stats",     live.statsServerUrl     || xml.statsServerUrl],
        ["plan-current-sw_update", live.swUpdateUrl        || xml.swUpdateUrl],
        ["plan-current-bmx",       live.bmxRegistryUrl     || xml.bmxRegistryUrl],
    ];
    for (const [id, value] of cells) {
        const el = document.getElementById(id);
        if (el) el.innerText = value || "—";
    }
}

// renderPlan populates the Plan card: capabilities header + suggested
// plan box. Reads only fields the backend already exposes; the
// suggested-plan logic lives in computeSuggestedPlan.
function renderPlan(summary) {
    // Capabilities — list which transports are available on the device
    // and which migration recipes AfterTouch can offer given those
    // transports.
    const detectedEl = document.getElementById("plan-detected");
    if (detectedEl) {
        detectedEl.replaceChildren();
        detectedEl.appendChild(transportChip("SSH", summary.ssh_success));
        detectedEl.appendChild(document.createTextNode("   "));
        detectedEl.appendChild(transportChip("Telnet:17000", summary.telnet_reachable));
    }

    const possibleEl = document.getElementById("plan-possible");
    if (possibleEl) {
        const offers = [];
        if (summary.ssh_success) {
            offers.push("XML migration", "DNS interception (resolv.conf hook)", "CA install");
        }
        if (summary.telnet_reachable) {
            offers.push("Telnet URL flip");
        }
        possibleEl.innerText = offers.length ? offers.join(" · ") : "(no path — see suggested plan below)";
    }

    // Suggested plan
    const plan = computeSuggestedPlan(summary);
    const summaryEl = document.getElementById("plan-suggestion-summary");
    const stepsEl = document.getElementById("plan-suggestion-steps");
    const applyBtn = document.getElementById("plan-apply-btn");
    const statusEl = document.getElementById("plan-apply-status");
    const box = document.getElementById("plan-suggestion");

    if (summaryEl) summaryEl.innerText = plan.summary;

    if (stepsEl) {
        stepsEl.replaceChildren();
        if (plan.steps) {
            for (const s of plan.steps) {
                const li = document.createElement("li");
                li.innerText = s;
                stepsEl.appendChild(li);
            }
        }
        if (plan.note) {
            const li = document.createElement("li");
            li.style.cssText = "list-style: none; margin-left: -1em; color: #555";
            li.innerText = plan.note;
            stepsEl.appendChild(li);
        }
    }

    if (applyBtn) {
        applyBtn.disabled = !plan.available;
        applyBtn.dataset.method = plan.method || "";
    }

    if (statusEl) statusEl.innerText = "";

    // Tint the box neutrally for non-actionable suggestions so the
    // green "ready to apply" framing is reserved for the case where
    // we actually have a plan to apply.
    if (box) {
        if (plan.available) {
            box.style.background = "#f1f8e9";
            box.style.borderColor = "#c8e6c9";
        } else {
            box.style.background = "#f5f5f5";
            box.style.borderColor = "#ddd";
        }
    }
}

// transportChip returns ": ✅ available" or ": ❌ unreachable"
// as a coloured DOM fragment, for the Capabilities row.
function transportChip(name, ok) {
    const wrap = document.createElement("span");
    const label = document.createElement("strong");
    label.textContent = name + ": ";
    wrap.appendChild(label);
    const status = document.createElement("span");
    status.innerText = ok ? "✅" : "❌";
    status.style.color = ok ? "green" : "red";
    wrap.appendChild(status);
    return wrap;
}

// --- Pre-flight panel: rendering helpers ----------------------------

function showPreflightPanel() {
    const panel = document.getElementById("apply-preflight-panel");
    if (!panel) return;
    panel.style.display = "block";
    panel.scrollIntoView({behavior: "smooth", block: "nearest"});
}

function hidePreflightPanel() {
    const panel = document.getElementById("apply-preflight-panel");
    if (panel) panel.style.display = "none";
}

function clearPreflightPanel() {
    const list = document.getElementById("apply-preflight-list");
    if (list) list.replaceChildren();
    const summary = document.getElementById("apply-preflight-summary");
    if (summary) {
        summary.replaceChildren();
        summary.style.color = "";
    }
    const actions = document.getElementById("apply-preflight-actions");
    if (actions) actions.replaceChildren();
}

// addPreflightItem appends a row to the panel's check list and returns
// the 
  • for later status updates. function addPreflightItem(name) { const list = document.getElementById("apply-preflight-list"); if (!list) return null; const li = document.createElement("li"); li.style.padding = "2px 0"; li.dataset.name = name; li.innerText = `🕐 ${name} — pending`; li.style.color = "#666"; list.appendChild(li); return li; } function setPreflightItemStatus(li, status, message) { if (!li) return; let icon, color, suffix; if (status === "running") { icon = "⟳"; color = "#1976d2"; suffix = " — running…"; } else if (status === "ok") { icon = "✅"; color = "green"; suffix = " — passed"; } else if (status === "skip") { icon = "—"; color = "#666"; suffix = message ? ` — ${message}` : " — skipped"; } else if (status === "warn") { icon = "⚠️"; color = "#ed6c02"; suffix = message ? ` — ${message}` : " — warning"; } else { icon = "❌"; color = "red"; suffix = message ? ` — ${message}` : " — failed"; } li.innerText = `${icon} ${li.dataset.name}${suffix}`; li.style.color = color; } // --- Pre-flight panel: individual checks ---------------------------- // Each check returns {status, message?} where status ∈ {"ok","fail","skip"}. // preflightConnectionTestURL picks the URL the pre-flight HTTPS test // should exercise from the device. Default behaviour used to always // test summary.server_https_url (the HTTPS health endpoint), which // was a useful baseline but didn't reflect HTTP-target migrations // at all. Now: // // - For DNS interception (resolv): the device hits https://*.bose.com // after migration, which DNS redirects to our service over HTTPS. // server_https_url is the meaningful test target. // - For URL-flip migrations (xml / telnet): test the user's actual // targetUrl, since that's the URL the migration will write into // the speaker. /health is appended if targetUrl has no path so // we hit a small known endpoint regardless of scheme. // // Falls back to server_https_url when targetUrl can't be parsed, so a // legacy call site without a target still works. function preflightConnectionTestURL(summary, methods, targetUrl) { if (methods.includes("resolv") && summary.server_https_url) { return summary.server_https_url; } if (targetUrl) { try { const u = new URL(targetUrl); return `${u.protocol}//${u.host}/health`; } catch (e) { /* fall through */ } } return summary.server_https_url || null; } async function checkConnectionFromDevice(deviceId, testUrl) { try { // use_explicit_ca=true uploads the CA temporarily so the test // exercises the trust path even before CA install completes — // forward-looking when CA install is part of the plan, and // equivalent to use_explicit_ca=false when CA is already // installed. const q = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=true`; const resp = await fetch(`/api/setup/test-connection/${encodeURIComponent(deviceId)}${q}`, {method: "POST"}); const result = await resp.json(); if (result.ok) return {status: "ok"}; return {status: "fail", message: (result.message || "connection failed").split("\n")[0]}; } catch (e) { return {status: "fail", message: String(e)}; } } async function checkDNSRedirectionFromDevice(deviceId, targetUrl) { try { const q = `?target_url=${encodeURIComponent(targetUrl)}`; const resp = await fetch(`/api/setup/test-dns/${encodeURIComponent(deviceId)}${q}`, {method: "POST"}); const result = await resp.json(); if (result.ok) return {status: "ok"}; return {status: "fail", message: (result.message || "DNS test failed").split("\n")[0]}; } catch (e) { return {status: "fail", message: String(e)}; } } // checkPeerReachability is the post-migration passive observation // check: register interest in the device IP, nudge :8090/swUpdateCheck, // and report whether any inbound from that IP landed on this service // within the timeout. Used in place of the active swUpdateUrl round- // trip on already-migrated speakers, where the swUpdate daemon caches // its URL at boot and the active flip can't reach it without a reboot. // See pkg/service/setup/peer_probe.go for orchestration details. async function checkPeerReachability(deviceId) { try { const resp = await fetch(`/api/setup/peer-probe/${encodeURIComponent(deviceId)}`, {method: "POST"}); const result = await resp.json(); if (result.ok) { const ms = result.result && result.result.elapsed_ms; const path = result.result && result.result.observed_path; let msg = ""; if (ms !== undefined && ms !== null) msg = `${ms}ms`; if (path) msg = msg ? `${msg} (${path})` : path; return {status: "ok", message: msg || undefined}; } if (result.error) return {status: "fail", message: result.error.split("\n")[0]}; if (result.result && result.result.reached === false) { return {status: "warn", message: "no inbound seen within the wait window — usually just timing (the update daemon dials out on its own slow schedule; a reboot after Apply validates it), not a port or config problem. Safe to proceed."}; } return {status: "fail", message: "probe failed"}; } catch (e) { return {status: "fail", message: String(e)}; } } // --- Pre-flight panel: orchestrator --------------------------------- // runApplyPreflight runs the checks visible in the pre-flight panel. // First check (backend summary re-fetch) drives availability of the // later device-side tests via the returned summary. Returns // {results, summary}. async function runApplyPreflight(deviceId, methods, opts, targetUrl) { clearPreflightPanel(); showPreflightPanel(); const results = []; // Step 1: backend summary re-check (authoritative state). const summaryItem = addPreflightItem("Backend summary re-check"); setPreflightItemStatus(summaryItem, "running"); const r = await runPreflightCheck(deviceId, methods, opts, targetUrl); if (!r.ok) { setPreflightItemStatus(summaryItem, "fail", r.issues.join("; ")); results.push({name: "Backend summary re-check", status: "fail", message: r.issues.join("; ")}); return {results, summary: r.summary}; } setPreflightItemStatus(summaryItem, "ok"); results.push({name: "Backend summary re-check", status: "ok"}); const summary = r.summary; // Step 2: reachability from the device. Two evidence sources: // // - SSH (curl from device) verifies inbound TCP from the // speaker to our HTTP/HTTPS port using the speaker's normal // userspace stack. Works pre- or post-migration as long as // SSH is unlocked. // - Passive observer (post-migration only) verifies the // swUpdate daemon is actually dialing this service. Replaces // the deprecated active swUpdateUrl round-trip, which the // daemon ignores because it caches its URL at boot. // // On an unmigrated/partially-migrated telnet-only speaker, no // no-reboot validation of the daemon's outbound is possible — we // surface a skip row explaining "Apply + reboot is required to // validate the fan-out". Per-axis migration state is still visible // in the State card above, so the user can see which parts are // already in place. const connectionTestURL = preflightConnectionTestURL(summary, methods, targetUrl); const ranAnyReachability = (summary.ssh_success && !!connectionTestURL) || summary.telnet_reachable; if (summary.ssh_success && connectionTestURL) { const scheme = connectionTestURL.startsWith("https:") ? "HTTPS" : "HTTP"; const label = `${scheme} connection from device`; const item = addPreflightItem(label); setPreflightItemStatus(item, "running"); const cr = await checkConnectionFromDevice(deviceId, connectionTestURL); setPreflightItemStatus(item, cr.status, cr.message); results.push({name: label, ...cr}); } if (summary.telnet_reachable) { if (summary.is_migrated) { const label = "Reachability check (passive observer)"; const item = addPreflightItem(label); setPreflightItemStatus(item, "running"); const cr = await checkPeerReachability(deviceId); setPreflightItemStatus(item, cr.status, cr.message); results.push({name: label, ...cr}); } else { const label = "Round-trip validation runs after Apply + reboot"; const item = addPreflightItem(label); setPreflightItemStatus(item, "skip", "daemon caches swUpdateUrl at boot; reboot required to validate fan-out"); results.push({name: label, status: "skip", message: "runs after Apply + reboot"}); } } if (!ranAnyReachability) { const item = addPreflightItem("Reachability from device"); setPreflightItemStatus(item, "skip", "neither SSH nor Telnet:17000 is reachable"); results.push({name: "Reachability from device", status: "skip"}); } // Step 3: DNS redirection test (only for resolv plans). if (methods.includes("resolv") && summary.ssh_success) { const item = addPreflightItem("DNS redirection from device"); setPreflightItemStatus(item, "running"); const cr = await checkDNSRedirectionFromDevice(deviceId, targetUrl); setPreflightItemStatus(item, cr.status, cr.message); results.push({name: "DNS redirection from device", ...cr}); } return {results, summary}; } // awaitPreflightDecision renders the summary line and buttons, then // resolves with the user's choice ("proceed" or "cancel"). Auto- // proceeds without buttons when every check passed — the user still // sees the green panel briefly via the caller's animation budget. function awaitPreflightDecision(results) { const failed = results.filter(r => r.status === "fail").length; const skipped = results.filter(r => r.status === "skip").length; const passed = results.filter(r => r.status === "ok").length; const summary = document.getElementById("apply-preflight-summary"); const actions = document.getElementById("apply-preflight-actions"); if (!summary || !actions) return Promise.resolve("proceed"); summary.replaceChildren(); if (failed === 0) { const warned = results.filter(r => r.status === "warn").length; let extras = skipped > 0 ? ` (${skipped} skipped)` : ""; if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`; summary.innerText = `✅ ${passed} of ${results.length} checks passed${extras}`; summary.style.color = warned > 0 ? "#ed6c02" : "green"; actions.replaceChildren(); // Auto-proceed: caller adds a brief delay so the user sees the // green frame before Apply kicks off. return Promise.resolve("proceed"); } summary.innerText = `❌ ${failed} of ${results.length} checks failed`; summary.style.color = "red"; return new Promise(resolve => { actions.replaceChildren(); const proceed = document.createElement("button"); proceed.type = "button"; proceed.innerText = "Proceed Anyway"; proceed.style.cssText = "background: #ff9800; color: white; border: none; padding: 8px 14px"; proceed.onclick = () => resolve("proceed"); const cancel = document.createElement("button"); cancel.type = "button"; cancel.innerText = "Cancel"; cancel.style.cssText = "margin-left: 10px; padding: 8px 14px"; cancel.onclick = () => resolve("cancel"); actions.appendChild(proceed); actions.appendChild(cancel); }); } function preflightSleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // runPreflightCheck does an authoritative round-trip to the backend // just before Apply, catching issues the optimistic client-side // preview can't see: hostname resolution from the device's // perspective, transport reachability re-verified after any prior // step, and a sanity check that the backend's planned config // reflects the per-field URL overrides we're about to send. // // Returns {ok: bool, issues: string[], summary}. The caller decides // whether to proceed (typically with a confirm() dialog listing the // issues so the user can override on a known-false-positive). async function runPreflightCheck(deviceId, methods, opts, targetUrl) { let q = "?target_url=" + encodeURIComponent(targetUrl); for (const k in opts) q += "&" + k + "=" + encodeURIComponent(opts[k]); let summary; try { const resp = await fetch("/api/setup/summary/" + encodeURIComponent(deviceId) + q); if (!resp.ok) { return {ok: false, issues: [`Failed to refresh summary: ${await resp.text()}`]}; } summary = await resp.json(); } catch (e) { return {ok: false, issues: [`Failed to refresh summary: ${e}`]}; } const issues = []; if (summary.resolve_ip_error) { issues.push("Hostname resolution from the device failed: " + summary.resolve_ip_error); } const wantsSSH = methods.some(m => m === "xml" || m === "resolv" || m === "trust-ca"); const wantsTelnet = methods.includes("telnet"); if (wantsSSH && !summary.ssh_success) { issues.push("SSH is no longer reachable — required for: " + methods.filter(m => m === "xml" || m === "resolv" || m === "trust-ca").join(", ")); } if (wantsTelnet && !summary.telnet_reachable) { issues.push("Telnet:17000 is no longer reachable — required for the telnet URL flip."); } // Sanity-check that the backend's planned config reflects the // overrides we're about to write. Only meaningful for URL-flip // methods (xml/telnet). Looser substring match: each of the four // override URLs we sent must appear in the rendered planned XML. if (methods.some(m => m === "xml" || m === "telnet")) { const planned = summary.planned_config || ""; const expected = ["marge_url", "stats_url", "sw_update_url", "bmx_url"] .map(k => opts[k]) .filter(u => !!u); const missing = expected.filter(u => !planned.includes(u)); if (missing.length > 0) { issues.push("Backend's planned config doesn't reflect these overrides: " + missing.join(", ")); } } return {ok: issues.length === 0, issues, summary}; } // previewPreflight runs the same check sequence as the Apply paths // but stops at the results — no migrate / trust-ca / pair-account // call happens. The panel stays open with a Close button so the // user can inspect the outcome ("test first, decide later"). // // Called by preflightSuggestedPlan and preflightCustomPlan, which // share UI plumbing with their Apply counterparts. async function previewPreflight(deviceId, methods, opts, targetUrl) { const {results} = await runApplyPreflight(deviceId, methods, opts, targetUrl); renderPreflightPreviewSummary(results); } // renderPreflightPreviewSummary mirrors awaitPreflightDecision's // rendering shape (summary line + actions row) but with a single // Close button instead of Proceed Anyway / Cancel — there's nothing // to proceed to in preview mode. function renderPreflightPreviewSummary(results) { const failed = results.filter(r => r.status === "fail").length; const passed = results.filter(r => r.status === "ok").length; const skipped = results.filter(r => r.status === "skip").length; const summary = document.getElementById("apply-preflight-summary"); const actions = document.getElementById("apply-preflight-actions"); if (!summary || !actions) return; if (failed === 0) { const warned = results.filter(r => r.status === "warn").length; let extras = skipped > 0 ? ` (${skipped} skipped)` : ""; if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`; summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks${extras}`; summary.style.color = warned > 0 ? "#ed6c02" : "green"; } else { summary.innerText = `❌ Pre-flight: ${failed} of ${results.length} checks failed`; summary.style.color = "red"; } actions.replaceChildren(); const close = document.createElement("button"); close.type = "button"; close.innerText = "Close"; close.style.cssText = "padding: 8px 14px"; close.onclick = () => hidePreflightPanel(); actions.appendChild(close); } // preflightSuggestedPlan runs the Suggested Plan's checks without // applying. Reads the chosen method off plan-apply-btn.dataset.method // (set by renderPlan from computeSuggestedPlan), same source the // real Apply uses, so what's tested matches what would be applied. async function preflightSuggestedPlan() { const applyBtn = document.getElementById("plan-apply-btn"); const method = applyBtn && applyBtn.dataset.method; if (!method) return; const deviceId = document.getElementById("summary-device-id").value; if (!deviceId) return; if (!validatePlanURLs()) return; const targetUrl = document.getElementById("plan-target-url").value; const opts = readPlanURLOptions(); await previewPreflight(deviceId, [method], opts, targetUrl); } // preflightCustomPlan walks the same radio choices applyCustomPlan // reads and queues the same methods array, then runs the checks // against it. Kept structurally close to applyCustomPlan so the two // stay in sync as new axes are added. async function preflightCustomPlan() { const form = document.getElementById("customize-form"); const deviceId = form && form.dataset.deviceId; if (!deviceId) return; if (!validatePlanURLs()) return; const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none"; const dns = (document.querySelector('input[name="customize-dns"]:checked') || {}).value || "none"; const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked; const pair = readPlanPairTarget(); const methods = []; if (flip === "xml" || flip === "telnet") methods.push(flip); if (dns === "resolv") methods.push("resolv"); if (caInstall && dns !== "resolv") methods.push("trust-ca"); if (pair && pair.valid) methods.push("pair-account"); if (methods.length === 0) { // Pre-flight with nothing queued is still useful — it shows // transport reachability. Run the summary check at least. } const targetUrl = document.getElementById("plan-target-url").value; const opts = readPlanURLOptions(); await previewPreflight(deviceId, methods, opts, targetUrl); } // applySuggestedPlan triggers the recipe computeSuggestedPlan picked. // Passes the chosen method directly to migrate() — the legacy // migration-method dropdown is gone. async function applySuggestedPlan() { const btn = document.getElementById("plan-apply-btn"); const status = document.getElementById("plan-apply-status"); const method = btn && btn.dataset.method; if (!method) return; const deviceId = document.getElementById("summary-device-id").value; if (!deviceId) { if (status) status.innerText = "❌ no device selected"; return; } if (status) { status.innerText = "Pre-flight check…"; status.style.color = "#555"; } // Pair-target intent from the Plan card. null = no pairing step; // {valid:false} = invalid input that blocks Apply. const pair = readPlanPairTarget(); if (pair && !pair.valid) { if (status) { status.innerText = "Aborted — invalid account ID"; status.style.color = "#c62828"; } return; } // Visible pre-flight panel — backend summary re-fetch, plus the // applicable device-side checks (HTTPS connection, DNS redirection) // run automatically so the user gets feedback without having to // click the manual Test buttons. const targetUrl = document.getElementById("plan-target-url").value; const opts = readPlanURLOptions(); const {results} = await runApplyPreflight(deviceId, [method], opts, targetUrl); const decision = await awaitPreflightDecision(results); if (decision !== "proceed") { hidePreflightPanel(); if (status) { status.innerText = "Aborted — pre-flight issues unresolved"; status.style.color = "#c62828"; } return; } // Brief glance at the green panel so the success state registers // before it disappears. const failed = results.filter(r => r.status === "fail").length; if (failed === 0) await preflightSleep(700); hidePreflightPanel(); if (status) { status.innerText = "Applying " + method + "…"; status.style.color = "#555"; } // ip is unused by migrate() — the backend resolves the IP from // device id. The empty string keeps the existing call shape. await migrate(deviceId, "", method); // Pairing runs after the URL flip so the user sees migration // succeed before pairing — pair-account is independent of the // migration target so order is purely UX. if (pair && pair.valid) { if (status) { status.innerText = "Pairing account " + pair.accountId + "…"; status.style.color = "#555"; } try { await pairAccount(deviceId, pair.accountId); } catch (e) { if (status) { status.innerText = "❌ Pair failed: " + e.message; status.style.color = "#c62828"; } return; } } if (status) status.innerText = ""; } // looksTransient classifies a probe-error message as likely-flaky-but- // retriable. Conservative substring match — only marks errors that // pattern-match a TCP/I/O timeout, which is the exact failure shape // observed on healthy FW 27.0.6 devices that recover on retry. function looksTransient(msg) { if (!msg) return false; const m = msg.toLowerCase(); return m.includes("timeout") || m.includes("timed out") || m.includes("connection reset"); } // renderMigrationState fills the three-axis state card at the top of // the migration summary: transports, migration-state axes (URL config, // DNS interception, CA/TLS), and preconditions (remote_services, // pairing, backup). Reads only fields the backend already exposes — // is_migrated remains the OR of the per-axis booleans. // // targetUrl is the current Target Domain value, used only to judge // whether CA/TLS is actually relevant to the current plan (see // isHttpsTarget) — the default Suggested Plan never needs it. function renderMigrationState(summary, targetUrl) { // --- Transports --- setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable"); setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable"); const bannerEl = document.getElementById("state-telnet-banner"); if (bannerEl) bannerEl.innerText = summary.telnet_banner ? `(${summary.telnet_banner})` : ""; const errorEl = document.getElementById("state-telnet-error"); if (errorEl) { if (summary.telnet_probe_error && !summary.telnet_reachable) { errorEl.replaceChildren(); const line = document.createElement("div"); line.innerText = "Probe error: " + summary.telnet_probe_error; errorEl.appendChild(line); // The diagnostic shell on FW 27.0.6 occasionally drops the // first connection attempt under load. When the error wraps // an i/o timeout, the next probe almost always succeeds — // so nudge the user toward the ↻ refresh button rather than // letting them assume telnet is permanently unreachable. if (looksTransient(summary.telnet_probe_error)) { const hint = document.createElement("div"); hint.style.cssText = "margin-top: 4px; font-size: 0.85em; color: #5d4037"; hint.innerText = "💡 Telnet probes are occasionally flaky on this firmware. Click the ↻ refresh button next to the device dropdown to retry."; errorEl.appendChild(hint); } errorEl.style.display = "block"; } else { errorEl.style.display = "none"; } } // --- URL Configuration axis --- const urlCell = document.getElementById("state-url"); if (urlCell) { urlCell.replaceChildren(); const verdict = urlConfigVerdict(summary); urlCell.appendChild(stateLine(verdict.icon, verdict.text, verdict.note)); const live = parseTelnetVerifiedConfig(summary.telnet_verified_config || ""); const xml = summary.parsed_current_config || {}; const fields = [ ["margeServerUrl", "marge"], ["statsServerUrl", "stats"], ["swUpdateUrl", "sw_update"], ["bmxRegistryUrl", "bmx"], ]; const detail = document.createElement("div"); detail.style.cssText = "margin-top: 4px; font-family: monospace; font-size: 0.85em; color: #555"; for (const [key] of fields) { const xmlVal = xml[key] || ""; const telVal = live[key] || ""; const row = document.createElement("div"); row.style.cssText = "padding: 1px 0"; const label = document.createElement("strong"); label.style.cssText = "color: #333"; label.textContent = key + ": "; row.appendChild(label); row.appendChild(document.createTextNode(formatURLPair(xmlVal, telVal))); detail.appendChild(row); } urlCell.appendChild(detail); } // --- DNS Interception axis --- const dnsCell = document.getElementById("state-dns"); if (dnsCell) { dnsCell.replaceChildren(); const v = dnsInterceptionVerdict(summary); dnsCell.appendChild(stateLine(v.icon, v.text, v.note)); } // --- CA / TLS axis --- // The cell hosts both the verdict text and two action affordances // (Trust CA Now button + Download CA cert link). We only rewrite // the verdict span so the buttons stay put across re-renders. const caLine = document.getElementById("state-ca-line"); if (caLine) { caLine.replaceChildren(); const v = caVerdict(summary, isHttpsTarget(targetUrl)); caLine.appendChild(stateLine(v.icon, v.text, v.note)); } // --- Preconditions --- // Like CA/TLS above, the cell also hosts the Enable/Disable SSH // buttons as siblings of this line — only rewrite the verdict span so // they stay put across re-renders. const remoteLine = document.getElementById("state-remote-services-line"); if (remoteLine) { remoteLine.replaceChildren(); const v = remoteServicesVerdict(summary); remoteLine.appendChild(stateLine(v.icon, v.text, v.note)); } const pairedCell = document.getElementById("state-paired"); if (pairedCell) { pairedCell.replaceChildren(); if (summary.is_paired) { pairedCell.appendChild(stateLine("✅", "Paired", `(account ${summary.account_id || "?"})`)); } else { pairedCell.appendChild(stateLine("❌", "Not paired", "(/setMargeAccount or telnet envswitch needed)")); } } const backupCell = document.getElementById("state-backup"); if (backupCell) { backupCell.replaceChildren(); if (summary.original_config) { backupCell.appendChild(stateLine("✅", "Found .original", "")); } else { backupCell.appendChild(stateLine("❌", "Not found", "(only relevant for the XML migration method)")); } } } // setStateChip writes a green ✅ / red ❌ chip into the element. function setStateChip(elemId, ok, okText, badText) { const el = document.getElementById(elemId); if (!el) return; if (ok) { el.innerText = "✅ " + okText; el.style.color = "green"; } else { el.innerText = "❌ " + badText; el.style.color = "red"; } } // stateLine returns a DOM fragment " ". function stateLine(icon, text, note) { const wrap = document.createElement("span"); wrap.appendChild(document.createTextNode(icon + " ")); const strong = document.createElement("strong"); strong.textContent = text; wrap.appendChild(strong); if (note) { const muted = document.createElement("span"); muted.style.cssText = "color: #666; margin-left: 6px; font-size: 0.9em"; muted.textContent = note; wrap.appendChild(muted); } return wrap; } // formatURLPair renders the on-disk vs live values for one URL field // in a compact way: agreement → single value; disagreement → both, // labelled. function formatURLPair(xmlVal, telVal) { if (!xmlVal && !telVal) return "—"; if (!xmlVal) return `live: ${telVal}`; if (!telVal) return `xml: ${xmlVal}`; if (xmlVal === telVal) return xmlVal; return `xml: ${xmlVal} • live: ${telVal}`; } // isMigratedToOtherTarget returns true when the speaker's on-device URLs have // been changed away from Bose cloud but don't match the Settings Target Domain — // i.e. the speaker was previously migrated to a different AfterTouch hostname // (or the Settings Target Domain drifted after migration). In this state // xml_migrated and telnet_migrated are both false (they only match the *current* // Settings Target Domain), yet labelling the speaker "Original (Bose cloud)" // would be factually wrong. function isMigratedToOtherTarget(summary) { if (summary.xml_migrated || summary.telnet_migrated) return false; const boseDomains = ["streaming.bose.com", "stats.bose.com", "updates.bose.com", "bmx.bose.com"]; const cfg = summary.parsed_current_config; if (!cfg) return false; const urls = [cfg.margeServerUrl, cfg.statsServerUrl, cfg.swUpdateUrl, cfg.bmxRegistryUrl].filter(Boolean); if (urls.length === 0) return false; return !urls.some(u => boseDomains.some(d => u.includes(d))); } // urlConfigVerdict reports the URL-flip axis in the context of DNS // interception, because "URLs still point at Bose" is only a problem // if nothing else is redirecting them. When the DNS hook (or, less // preferably, /etc/hosts) is intercepting the Bose hostnames, leaving // the on-device URL config untouched is the *expected* migrated state // for that method — flagging it red would be misleading. function urlConfigVerdict(summary) { const xml = !!summary.xml_migrated; const tel = !!summary.telnet_migrated; const dns = !!summary.resolv_migrated || !!summary.hosts_migrated; if (xml && tel) return {icon: "✅", text: "AfterTouch URLs", note: "(XML + telnet runtime in sync)"}; if (xml) return {icon: "✅", text: "AfterTouch URLs", note: "(XML only — telnet runtime may still hold the old URLs)"}; if (tel) return {icon: "✅", text: "AfterTouch URLs", note: "(telnet runtime — reboot to persist into the on-disk XML)"}; // Neither URL-flip mechanism matched the Settings Target Domain. // Before falling back to "Original (Bose cloud)", check whether the // device's URLs already point somewhere that is clearly not Bose cloud // — e.g. the speaker was migrated to "http://spotify:8000" but the // Settings Target Domain is now an IP address (or vice versa). The // speaker is effectively migrated; "Original (Bose cloud)" would be // misleading and alarming. if (isMigratedToOtherTarget(summary)) { const margeUrl = (summary.parsed_current_config || {}).margeServerUrl || "unknown"; if (dns) return {icon: "✅", text: "Migrated (URL mismatch)", note: `— device has ${margeUrl}, also intercepted via DNS`}; return {icon: "⚠️", text: "Migrated (URL mismatch)", note: `— device has ${margeUrl}; the speaker must be able to reach the service there. Update Settings Target Domain to match, or apply the plan to change the device URL`}; } // URLs are genuinely pointing at Bose cloud (or the config is unreadable). if (dns) return {icon: "✅", text: "Original (Bose cloud)", note: "— intercepted via DNS, device reaches AfterTouch"}; return {icon: "❌", text: "Original (Bose cloud)", note: "— not intercepted, device will reach the real Bose cloud"}; } function dnsInterceptionVerdict(summary) { const hosts = !!summary.hosts_migrated; const resolv = !!summary.resolv_migrated; if (!hosts && !resolv) return {icon: "—", text: "None", note: ""}; if (resolv) return {icon: "✅", text: "/etc/resolv.conf hook active", note: hosts ? "(also: /etc/hosts entries)" : ""}; return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"}; } // isHttpsTarget reports whether a target/service URL uses the https // scheme. Used to distinguish "CA/TLS optional" (the default Suggested // Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA // involved) from "CA/TLS required" (Target Domain is https://, or the // Customize form's DNS-interception method is chosen — that one always // targets https://*.bose.com). function isHttpsTarget(url) { return /^https:/i.test((url || "").trim()); } function caVerdict(summary, httpsRelevant) { if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""}; if (httpsRelevant) { return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"}; } return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"}; } function remoteServicesVerdict(summary) { if (!summary.ssh_success) return {icon: "❓", text: "Unknown", note: "(SSH not reachable)"}; if (!summary.remote_services_enabled) return {icon: "❌", text: "SSH not enabled", note: "(insert USB stick with remote_services file and reboot to enable)"}; if (!summary.remote_services_persistent) return {icon: "⚠️", text: "SSH enabled (not persistent)", note: "(will be lost on reboot — use 'Enable SSH' button to persist)"}; return {icon: "✅", text: "SSH enabled (persistent)", note: ""}; } // parseTelnetVerifiedConfig extracts field values from the device's // `getpdo CurrentSystemConfiguration` reply. Mirrors // setup.parseGetpdoConfig (Go); supports both the protobuf-text-like // nested-block format observed on FW 27.0.6 (`key { text: "value" }`) // and the flat key=value format kept as a tolerance path. Banner text, // prompt characters (`->`, `->OK`), and unrelated lines are silently // ignored. function parseTelnetVerifiedConfig(text) { const out = {}; if (!text) return out; const isIdentifier = (s) => !!s && /^[A-Za-z0-9_]+$/.test(s); let currentKey = ""; for (const raw of text.split("\n")) { const line = raw.trim(); if (!line) continue; // Block open: " {". if (line.endsWith("{")) { const head = line.slice(0, -1).trim(); if (isIdentifier(head)) currentKey = head; continue; } // Block close. if (line === "}") { currentKey = ""; continue; } // "text: ..." inside a block is the field value. if (currentKey && line.startsWith("text:")) { let val = line.slice("text:".length).trim(); if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); out[currentKey] = val; continue; } // Flat key=value (tolerance path). const eq = line.indexOf("="); if (eq > 0) { const key = line.slice(0, eq).trim(); if (isIdentifier(key)) { out[key] = line.slice(eq + 1).trim(); } } } return out; } // renderPreflightWarnings shows summary.warnings as a yellow banner // above the migration controls. An empty/missing list hides the banner. function renderPreflightWarnings(summary) { const banner = document.getElementById("preflight-warnings"); const list = document.getElementById("preflight-warnings-list"); if (!banner || !list) return; list.replaceChildren(); const warnings = summary.warnings || []; if (warnings.length === 0) { banner.style.display = "none"; return; } for (const w of warnings) { const li = document.createElement("li"); li.innerText = w; list.appendChild(li); } banner.style.display = "block"; } // renderCustomizeForm sets the per-axis radio availability and pane // visibility based on the summary's transport reachability and the // current radio choices. Disabled options get a small "(why)" hint // next to them. function renderCustomizeForm(summary) { const xmlRadio = document.querySelector('input[name="customize-url-flip"][value="xml"]'); const telnetRadio = document.querySelector('input[name="customize-url-flip"][value="telnet"]'); const dnsRadio = document.querySelector('input[name="customize-dns"][value="resolv"]'); const caCheckbox = document.getElementById("customize-ca-install"); const setHint = (axis, text) => { const el = document.querySelector(`.customize-hint[data-axis="${axis}"]`); if (el) el.innerText = text; }; // XML over SSH requires SSH. if (xmlRadio) { const ok = !!summary.ssh_success; xmlRadio.disabled = !ok; setHint("xml", ok ? "" : "(SSH unreachable)"); if (!ok && xmlRadio.checked) { // Pick the next-best fallback so the form is in a valid // state on first render. if (telnetRadio && summary.telnet_reachable) telnetRadio.checked = true; else document.querySelector('input[name="customize-url-flip"][value="none"]').checked = true; } } // Telnet requires the diagnostic shell on port 17000. if (telnetRadio) { const ok = !!summary.telnet_reachable; telnetRadio.disabled = !ok; setHint("telnet", ok ? "" : "(Telnet:17000 unreachable)"); } // Resolv DNS hook requires SSH (writes to /etc/resolv.conf etc). if (dnsRadio) { const ok = !!summary.ssh_success; dnsRadio.disabled = !ok; setHint("resolv", ok ? "" : "(SSH unreachable)"); if (!ok && dnsRadio.checked) { document.querySelector('input[name="customize-dns"][value="none"]').checked = true; } } // CA install: SSH-only, and skipped silently if already trusted. if (caCheckbox) { const sshOk = !!summary.ssh_success; caCheckbox.disabled = !sshOk; if (!sshOk) caCheckbox.checked = false; if (!sshOk) setHint("ca", "(SSH unreachable)"); else if (summary.ca_cert_trusted) setHint("ca", "(already trusted)"); else setHint("ca", ""); } onCustomizeChange(); } // onCustomizeChange runs whenever any axis radio/checkbox changes. // Validates the combination, updates pane visibility for legacy diff // and resolv panes, and toggles the Apply Custom Plan button. function onCustomizeChange() { const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none"; const dns = (document.querySelector('input[name="customize-dns"]:checked') || {}).value || "none"; const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked; // Visibility of the diff pairs and per-method panes. Each diff // pair is its own .diff-container row so the Current/Planned // columns line up side-by-side per axis instead of mixing into // a single 3+ column layout. const show = (id, on) => { const el = document.getElementById(id); if (el) el.style.display = on ? "" : "none"; }; show("xml-diff-row", flip === "xml"); show("resolv-diff-row", dns === "resolv"); show("telnet-method-pane", flip === "telnet"); show("dns-redirection-test", dns === "resolv"); // Validate the combination and toggle the Apply button. const errors = []; if (flip === "none" && dns === "none" && !caInstall) { errors.push("Pick at least one axis — URL flip, DNS interception, or CA install."); } const errorEl = document.getElementById("customize-validation"); const applyBtn = document.getElementById("customize-apply-btn"); if (errorEl) { if (errors.length === 0) { errorEl.style.display = "none"; errorEl.innerText = ""; } else { errorEl.innerText = errors[0]; errorEl.style.display = "block"; } } if (applyBtn) applyBtn.disabled = errors.length > 0; const preflightCustomBtn = document.getElementById("customize-preflight-btn"); if (preflightCustomBtn) preflightCustomBtn.disabled = errors.length > 0; } // applyCustomPlan runs the chosen sequence of backend operations: // optional URL flip (xml or telnet), optional DNS hook (resolv — // already includes a CA install, so the explicit CA step is skipped // in that case), and an optional standalone CA install. Each step // runs sequentially; the first failure aborts the rest. async function applyCustomPlan() { const form = document.getElementById("customize-form"); const deviceId = form && form.dataset.deviceId; const ip = form && form.dataset.deviceIp; if (!deviceId) { alert("No device selected"); return; } if (!validatePlanURLs()) { // The Plan card already surfaced the per-field errors; just // refuse to run. return; } const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none"; const dns = (document.querySelector('input[name="customize-dns"]:checked') || {}).value || "none"; const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked; const status = document.getElementById("customize-apply-status"); const setStatus = (msg, color) => { if (!status) return; status.innerText = msg; status.style.color = color || "#555"; }; // Pair-target intent from the Plan card. null = no pairing step; // {valid:false} = invalid input that blocks Apply. const pair = readPlanPairTarget(); if (pair && !pair.valid) { setStatus("Aborted — invalid account ID", "#c62828"); return; } const steps = []; const methods = []; if (flip === "xml" || flip === "telnet") { steps.push({label: `URL flip via ${flip}`, run: () => migrate(deviceId, ip, flip)}); methods.push(flip); } if (dns === "resolv") { steps.push({label: "DNS interception (resolv.conf hook + CA install)", run: () => migrate(deviceId, ip, "resolv")}); methods.push("resolv"); } if (caInstall && dns !== "resolv") { steps.push({label: "Install local CA", run: () => trustCA(deviceId, ip)}); methods.push("trust-ca"); } if (pair && pair.valid) { steps.push({label: `Pair account ${pair.accountId}`, run: () => pairAccount(deviceId, pair.accountId)}); methods.push("pair-account"); } if (steps.length === 0) { setStatus("Pick at least one axis above (or change the pairing ID).", "#c62828"); return; } const applyBtn = document.getElementById("customize-apply-btn"); if (applyBtn) applyBtn.disabled = true; // Visible pre-flight panel — same set of checks as the Suggested // path (backend summary + device-side connection / DNS tests), // gated on the methods this multi-step plan actually queues. Each // step's preconditions are validated against this single fresh // summary; we don't re-fetch between steps because each step // touches independent device state. setStatus("Pre-flight check…", "#555"); const targetUrl = document.getElementById("plan-target-url").value; const opts = readPlanURLOptions(); const {results} = await runApplyPreflight(deviceId, methods, opts, targetUrl); const decision = await awaitPreflightDecision(results); if (decision !== "proceed") { hidePreflightPanel(); setStatus("Aborted — pre-flight issues unresolved", "#c62828"); if (applyBtn) applyBtn.disabled = false; return; } const failed = results.filter(r => r.status === "fail").length; if (failed === 0) await preflightSleep(700); hidePreflightPanel(); try { for (const step of steps) { setStatus(`Running: ${step.label}…`, "#555"); await step.run(); } setStatus("✅ Custom plan applied. Reboot to activate.", "green"); if (typeof refreshSummary === "function") refreshSummary(); } catch (e) { setStatus(`❌ Failed: ${e}`, "#c62828"); } finally { if (applyBtn) applyBtn.disabled = false; } } // --------------------------------------------------------------------------- // Health tab // --------------------------------------------------------------------------- async function fetchHealth() { const findingsEl = document.getElementById("health-findings"); const generatedAtEl = document.getElementById("health-generated-at"); if (!findingsEl) return; findingsEl.textContent = "Loading…"; if (generatedAtEl) generatedAtEl.textContent = ""; try { const resp = await fetch("/api/setup/health"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); renderHealthChecks(data, findingsEl, generatedAtEl); } catch (e) { findingsEl.textContent = `Failed to load health checks: ${e.message || e}`; } } async function downloadDiagnostic() { const statusEl = document.getElementById("health-diagnostic-status"); if (statusEl) statusEl.textContent = "Building diagnostic report…"; try { const resp = await fetch("/api/setup/export/diagnostic"); if (!resp.ok) { const text = await resp.text().catch(() => resp.statusText); throw new Error(`HTTP ${resp.status}: ${text}`); } const disposition = resp.headers.get("Content-Disposition") || ""; const match = disposition.match(/filename[^;=\n]*=(?:"([^"]+)"|([^;\n]+))/); const filename = (match && (match[1] || match[2])) || "aftertouch-diagnostic.age"; const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); if (statusEl) { const safe = filename.replace(/&/g, "&").replace(//g, ">"); statusEl.innerHTML = `Downloaded: ${safe}
    ` + `To share it, please prefer email: ` + `aftertouch-support@gesellix.net. ` + `Alternatively, open a GitHub issue ` + `and attach the file renamed to ${safe}.txt ` + `(GitHub blocks .age uploads; adding .txt works around that).`; } } catch (e) { if (statusEl) statusEl.textContent = `Failed to download diagnostic: ${e.message || e}`; } } function renderHealthChecks(data, findingsEl, generatedAtEl) { if (generatedAtEl && data.generatedAt) { generatedAtEl.textContent = `Last run: ${data.generatedAt}`; } const checks = data.checks || []; if (checks.length === 0) { findingsEl.textContent = "No checks are registered."; return; } findingsEl.innerHTML = ""; for (const check of checks) { findingsEl.appendChild(renderHealthCheck(check)); } } function renderHealthCheck(check) { const box = document.createElement("div"); box.className = "summary-box"; const header = document.createElement("h3"); header.style.margin = "0 0 8px 0"; header.appendChild(severityBadge(check.severity)); header.appendChild(document.createTextNode(" " + check.title)); box.appendChild(header); const idLine = document.createElement("div"); idLine.style.fontSize = "0.75em"; idLine.style.color = "#888"; idLine.style.marginBottom = "8px"; idLine.textContent = `id: ${check.id}`; box.appendChild(idLine); const findings = check.findings || []; if (findings.length === 0) { const ok = document.createElement("div"); ok.style.color = "#2e7d32"; ok.textContent = "✓ No issues detected."; box.appendChild(ok); return box; } for (const f of findings) { box.appendChild(renderFinding(check.id, f)); } return box; } function renderFinding(checkId, finding) { const row = document.createElement("div"); row.style.borderTop = "1px solid #e0e0e0"; row.style.padding = "10px 0"; const title = document.createElement("div"); title.appendChild(severityBadge(finding.severity)); title.appendChild(document.createTextNode(" " + (finding.message || ""))); row.appendChild(title); const target = finding.target || {}; if (target.account || target.device || target.name || target.ip) { const t = document.createElement("div"); t.style.fontSize = "0.8em"; t.style.color = "#666"; t.style.marginTop = "4px"; const parts = []; if (target.name) parts.push(target.name); if (target.account) parts.push(`account ${target.account}`); if (target.device) parts.push(`device ${target.device}`); if (target.ip) parts.push(target.ip); t.textContent = parts.join(" · "); row.appendChild(t); } if (finding.details) { const d = document.createElement("div"); d.style.fontSize = "0.85em"; d.style.color = "#444"; d.style.marginTop = "6px"; d.textContent = finding.details; row.appendChild(d); } const fixes = finding.quickFixes || []; if (fixes.length > 0) { const actions = document.createElement("div"); actions.style.marginTop = "8px"; actions.style.display = "flex"; actions.style.gap = "8px"; actions.style.flexWrap = "wrap"; for (const fix of fixes) { const btn = document.createElement("button"); btn.textContent = fix.label || fix.id; btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn); actions.appendChild(btn); } const status = document.createElement("span"); status.className = "health-fix-status"; status.style.fontSize = "0.85em"; status.style.alignSelf = "center"; actions.appendChild(status); row.appendChild(actions); } const manualCommands = finding.manualCommands || []; for (const cmd of manualCommands) { row.appendChild(renderManualCommand(cmd)); } return row; } function renderManualCommand(cmd) { const wrap = document.createElement("div"); wrap.style.marginTop = "8px"; wrap.style.padding = "8px"; wrap.style.background = "#f4f4f4"; wrap.style.borderRadius = "4px"; wrap.style.fontSize = "0.85em"; if (cmd.label) { const label = document.createElement("div"); label.style.color = "#444"; label.style.marginBottom = "4px"; label.textContent = cmd.label; wrap.appendChild(label); } const row = document.createElement("div"); row.style.display = "flex"; row.style.alignItems = "stretch"; row.style.gap = "8px"; const code = document.createElement("code"); code.style.flex = "1"; code.style.padding = "6px 8px"; code.style.background = "#fff"; code.style.border = "1px solid #ddd"; code.style.borderRadius = "3px"; code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; code.style.whiteSpace = "pre-wrap"; code.style.wordBreak = "break-all"; code.textContent = cmd.command; row.appendChild(code); const copyBtn = document.createElement("button"); copyBtn.textContent = "Copy"; copyBtn.style.alignSelf = "flex-start"; copyBtn.onclick = async () => { const ok = await copyTextToClipboard(cmd.command); copyBtn.textContent = ok ? "Copied" : "Copy failed"; if (ok) { setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200); } }; row.appendChild(copyBtn); wrap.appendChild(row); if (cmd.hint) { const hint = document.createElement("div"); hint.style.fontSize = "0.8em"; hint.style.color = "#666"; hint.style.marginTop = "4px"; hint.textContent = cmd.hint; wrap.appendChild(hint); } return wrap; } function severityBadge(severity) { const span = document.createElement("span"); span.style.fontSize = "0.75em"; span.style.padding = "2px 6px"; span.style.borderRadius = "10px"; span.style.fontWeight = "bold"; const palette = { ok: { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" }, info: { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" }, warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" }, error: { bg: "#ffebee", fg: "#c62828", label: "ERROR" }, }; const p = palette[severity] || palette.info; span.style.background = p.bg; span.style.color = p.fg; span.textContent = p.label; return span; } async function runQuickFix(checkId, fixId, target, confirmMsg, button) { if (confirmMsg && !window.confirm(confirmMsg)) return; const status = button.parentElement.querySelector(".health-fix-status"); button.disabled = true; if (status) { status.textContent = "Applying…"; status.style.color = "#555"; } try { const resp = await fetch("/api/setup/health/fix", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ checkId, fixId, target }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`); if (status) { status.textContent = data.message || "Done."; status.style.color = "#2e7d32"; } // Re-fetch health so resolved findings disappear from the list. // Skipped when the server signals refresh:false (persistent // affordances like play_ding that don't change check state). if (data.refresh !== false) setTimeout(fetchHealth, 400); } catch (e) { if (status) { status.textContent = `Failed: ${e.message || e}`; status.style.color = "#c62828"; } button.disabled = false; } } // --------------------------------------------------------------------------- // Logs tab // --------------------------------------------------------------------------- const logsState = { timerId: null, nextSince: 0, entries: [], maxEntries: 5000, // client-side cap; UI lag is the bottleneck droppedTotal: 0, pollIntervalMs: 1500, followTail: true, initialised: false, }; function startLogsPolling() { initLogsTabOnce(); // Reset window each time the tab opens so the user gets a // fresh snapshot rather than picking up stale state. logsState.entries = []; logsState.nextSince = 0; logsState.droppedTotal = 0; renderLogs(); pollLogsOnce(); if (logsState.timerId !== null) clearInterval(logsState.timerId); logsState.timerId = setInterval(pollLogsOnce, logsState.pollIntervalMs); } function stopLogsPolling() { if (logsState.timerId !== null) { clearInterval(logsState.timerId); logsState.timerId = null; } } function initLogsTabOnce() { if (logsState.initialised) return; logsState.initialised = true; const filterEl = document.getElementById("logs-filter"); if (filterEl) { filterEl.addEventListener("input", () => renderLogs()); } const followEl = document.getElementById("logs-follow"); if (followEl) { followEl.addEventListener("change", () => { logsState.followTail = followEl.checked; if (logsState.followTail) scrollLogsToBottom(); }); } const viewEl = document.getElementById("logs-view"); if (viewEl) { // Disengage follow-tail when the user scrolls up; re-engage // when they're back at the bottom. tail -f muscle memory. viewEl.addEventListener("scroll", () => { const distanceFromBottom = viewEl.scrollHeight - viewEl.scrollTop - viewEl.clientHeight; const atBottom = distanceFromBottom < 8; if (atBottom !== logsState.followTail) { logsState.followTail = atBottom; const followEl = document.getElementById("logs-follow"); if (followEl) followEl.checked = atBottom; } }); } } async function pollLogsOnce() { if (typeof document !== "undefined" && document.hidden) return; const statusEl = document.getElementById("logs-status"); try { const url = `/api/setup/logs?since=${encodeURIComponent(logsState.nextSince)}`; const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (Array.isArray(data.entries) && data.entries.length > 0) { logsState.entries.push(...data.entries); // Trim from the front when we exceed the client cap. if (logsState.entries.length > logsState.maxEntries) { logsState.entries.splice(0, logsState.entries.length - logsState.maxEntries); } } if (typeof data.nextSince === "number") { logsState.nextSince = data.nextSince; } if (typeof data.dropped === "number" && data.dropped > 0) { logsState.droppedTotal += data.dropped; } renderLogs(); if (statusEl) { const now = new Date().toLocaleTimeString(); const droppedNote = logsState.droppedTotal > 0 ? ` · ${logsState.droppedTotal} dropped` : ""; statusEl.textContent = `${logsState.entries.length} buffered${droppedNote} · last update ${now}`; } } catch (e) { if (statusEl) statusEl.textContent = `Polling failed: ${e.message || e}`; } } function renderLogs() { const viewEl = document.getElementById("logs-view"); if (!viewEl) return; const filterEl = document.getElementById("logs-filter"); const filter = filterEl ? filterEl.value.trim().toLowerCase() : ""; const lines = []; for (const entry of logsState.entries) { if (filter && entry.message.toLowerCase().indexOf(filter) === -1) continue; const ts = entry.time ? entry.time.replace("T", " ").replace("Z", "") : ""; lines.push(`${ts} ${entry.message}`); } viewEl.textContent = lines.join("\n"); if (logsState.followTail) { scrollLogsToBottom(); } } function scrollLogsToBottom() { const viewEl = document.getElementById("logs-view"); if (viewEl) viewEl.scrollTop = viewEl.scrollHeight; } // --------------------------------------------------------------------------- // Device summary (Devices tab — per-device "Inspect" panel) // --------------------------------------------------------------------------- async function toggleDeviceSummary(deviceId) { const row = document.getElementById(`device-summary-${deviceId}`); const cell = document.getElementById(`device-summary-cell-${deviceId}`); if (!row || !cell) return; if (row.style.display !== "none") { row.style.display = "none"; return; } row.style.display = ""; cell.innerHTML = 'Probing speaker…'; try { const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`); if (!resp.ok) { const txt = await resp.text(); cell.innerHTML = `Summary failed: ${resp.status} ${escapeHTML(txt)}`; return; } const data = await resp.json(); cell.innerHTML = ""; cell.appendChild(renderDeviceSummary(data)); } catch (e) { cell.innerHTML = `Summary failed: ${escapeHTML(e.message || String(e))}`; } } function renderDeviceSummary(data) { const wrap = document.createElement("div"); wrap.style.display = "grid"; wrap.style.gridTemplateColumns = "repeat(auto-fit, minmax(280px, 1fr))"; wrap.style.gap = "12px"; wrap.appendChild(summaryCard("Speaker /info", renderSpeakerInfoBody(data.speaker.info))); wrap.appendChild(summaryCard("Speaker /sources", renderSpeakerSourcesBody(data.speaker.sources, data.service))); wrap.appendChild(summaryCard("Speaker /presets", renderSpeakerPresetsBody(data.speaker.presets, data.service))); wrap.appendChild(summaryCard("Service-side state", renderServiceBody(data.service))); wrap.appendChild(summaryCard("Pairing inference", renderPairingBody(data.pairing, data.service))); const footer = document.createElement("div"); footer.style.gridColumn = "1 / -1"; footer.style.fontSize = "0.75em"; footer.style.color = "#888"; footer.textContent = `Generated at ${data.generated_at}`; wrap.appendChild(footer); return wrap; } function summaryCard(title, bodyNode) { const card = document.createElement("div"); card.style.background = "#fff"; card.style.border = "1px solid #ddd"; card.style.borderRadius = "4px"; card.style.padding = "10px 12px"; const h = document.createElement("div"); h.style.fontWeight = "bold"; h.style.marginBottom = "8px"; h.style.fontSize = "0.9em"; h.textContent = title; card.appendChild(h); if (bodyNode) card.appendChild(bodyNode); return card; } function renderSpeakerInfoBody(info) { const body = document.createElement("div"); body.style.fontSize = "0.85em"; if (!info.reachable) { body.appendChild(unreachableBlock(info)); return body; } body.appendChild(kv("name", info.name)); body.appendChild(kv("type", info.type)); body.appendChild(kv("margeAccountUUID", info.marge_account_uuid || "(empty)")); body.appendChild(kv("margeURL", info.marge_url || "(empty)")); return body; } function renderSpeakerSourcesBody(sources, service) { const body = document.createElement("div"); body.style.fontSize = "0.85em"; if (!sources.reachable) { body.appendChild(unreachableBlock(sources)); return body; } const types = sources.types || []; body.appendChild(kv("count", String(types.length))); body.appendChild(kv("types", types.length ? types.join(", ") : "(none)")); const svcTypes = (service && service.service_source_types) || []; const missingOnSpeaker = svcTypes.filter(t => types.indexOf(t) < 0); const extraOnSpeaker = types.filter(t => svcTypes.indexOf(t) < 0); if (missingOnSpeaker.length > 0) { body.appendChild(kv("missing on speaker", missingOnSpeaker.join(", "), "#a06800")); } if (extraOnSpeaker.length > 0) { body.appendChild(kv("extra on speaker", extraOnSpeaker.join(", "), "#666")); } return body; } function renderSpeakerPresetsBody(presets, service) { const body = document.createElement("div"); body.style.fontSize = "0.85em"; if (!presets.reachable) { body.appendChild(unreachableBlock(presets)); return body; } const ids = presets.ids || []; body.appendChild(kv("count", String(ids.length))); body.appendChild(kv("slots", ids.length ? ids.join(", ") : "(none)")); if (service && typeof service.service_preset_count === "number") { if (service.service_preset_count !== ids.length) { body.appendChild(kv("service count", String(service.service_preset_count), "#a06800")); } } return body; } function renderServiceBody(service) { const body = document.createElement("div"); body.style.fontSize = "0.85em"; body.appendChild(kv("server URL", service.server_url || "(unset)")); const hosts = service.expected_hosts || []; body.appendChild(kv("expected hosts", hosts.length ? hosts.join(", ") : "(none)")); body.appendChild(kv("Sources.xml", service.sources_xml_present ? "present" : "MISSING", service.sources_xml_present ? null : "#c62828")); body.appendChild(kv("Presets.xml", service.presets_xml_present ? `${service.service_preset_count} preset(s)` : "(empty)")); return body; } function renderPairingBody(pairing, service) { const body = document.createElement("div"); body.style.fontSize = "0.85em"; body.appendChild(kv("paired", pairing.paired ? "yes" : "NO", pairing.paired ? null : "#c62828")); body.appendChild(kv("speaker marge host", pairing.speaker_marge_host || "(unknown)")); const matches = pairing.marge_url_matches_service; body.appendChild(kv("matches service?", matches ? "yes" : "NO", matches ? null : "#a06800")); return body; } function kv(label, value, valueColor) { const row = document.createElement("div"); row.style.display = "flex"; row.style.gap = "8px"; row.style.marginBottom = "2px"; row.style.alignItems = "baseline"; const l = document.createElement("span"); l.style.color = "#666"; l.style.minWidth = "120px"; l.style.flexShrink = "0"; l.textContent = label; row.appendChild(l); const v = document.createElement("span"); v.style.wordBreak = "break-all"; if (valueColor) v.style.color = valueColor; v.textContent = value; row.appendChild(v); return row; } function unreachableBlock(probe) { const wrap = document.createElement("div"); const msg = document.createElement("div"); msg.style.color = "#a06800"; msg.style.marginBottom = "6px"; msg.textContent = probe.error ? `Unreachable: ${probe.error}` : "Unreachable from this service host."; wrap.appendChild(msg); if (probe.curl_command) { const hint = document.createElement("div"); hint.style.fontSize = "0.85em"; hint.style.color = "#444"; hint.style.marginBottom = "4px"; hint.textContent = "Run from your LAN:"; wrap.appendChild(hint); const row = document.createElement("div"); row.style.display = "flex"; row.style.gap = "8px"; row.style.alignItems = "stretch"; const code = document.createElement("code"); code.style.flex = "1"; code.style.padding = "4px 6px"; code.style.background = "#f4f4f4"; code.style.border = "1px solid #ddd"; code.style.borderRadius = "3px"; code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; code.style.fontSize = "0.8em"; code.style.whiteSpace = "pre-wrap"; code.style.wordBreak = "break-all"; code.textContent = probe.curl_command; row.appendChild(code); const btn = document.createElement("button"); btn.textContent = "Copy"; btn.onclick = async () => { const ok = await copyTextToClipboard(probe.curl_command); btn.textContent = ok ? "Copied" : "Copy failed"; if (ok) { setTimeout(() => { btn.textContent = "Copy"; }, 1200); } }; row.appendChild(btn); wrap.appendChild(row); } return wrap; } function escapeHTML(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }