// 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 = "
| Name & Model | IP Address | Device & Account ID | Firmware & Serial | Method | Action |
|---|---|---|---|---|---|
${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} |
| 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}
`).join("");
})() : "None"}
|
Origin: ${getOriginDescription(scmudcData.origin)} (${scmudcData.origin})
Action: ${scmudcData.action}
Summary: ${scmudcData.summary}
`; if (scmudcData.decoded_data) { html += `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 += `${escapeHtml(scmudcData.decoded_data.xml_content)}
`;
}
}
html += ' 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, "'");
}