// 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;
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("/setup/settings");
const settings = await settingsResponse.json();
const header = document.getElementById("spotify-status-header");
if (!settings.spotify_configured) {
if (header) header.style.display = "none";
return;
}
if (header) header.style.display = "flex";
const response = await fetch("/mgmt/spotify/accounts");
if (!response.ok) return;
const data = await response.json();
const nameEl = document.getElementById("spotify-account-name");
const linkBtn = document.getElementById("link-spotify-btn");
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("/mgmt/spotify/init", {method: "POST"});
if (!response.ok) {
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("/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(`/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;
}
}
async function fetchSettings() {
try {
const response = await fetch("/setup/settings");
const settings = await response.json();
if (settings.server_url) {
document.getElementById("target-domain").value = settings.server_url;
}
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 /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 {
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.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");
}
// 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 (spotifyStatus) {
if (settings.spotify_configured) {
spotifyStatus.innerHTML = '✅ Active';
} else if (settings.spotify_client_id) {
spotifyStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate';
} else {
spotifyStatus.innerHTML = '❌ Not configured';
}
}
// 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 (amazonStatus) {
if (settings.amazon_configured) {
amazonStatus.innerHTML = '✅ Active';
} else if (settings.amazon_client_id) {
amazonStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate';
} else {
amazonStatus.innerHTML = '❌ Not configured';
}
}
fetchLoggingSettings();
fetchSpotifyStatus();
} catch (error) {
console.error("Failed to fetch settings", error);
}
}
async function fetchLoggingSettings() {
try {
const response = await fetch("/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("/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 settings = {
server_url: document.getElementById("target-domain").value,
discovery_interval: document.getElementById("discovery-interval").value,
discovery_enabled: document.getElementById("discovery-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,
};
const status = document.getElementById("settings-status");
status.innerText = "Saving...";
status.style.color = "blue";
try {
const response = await fetch("/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";
}
}
async function fetchDevices() {
try {
const response = await fetch("/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 { 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(`/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(`/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(`/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: "fail", message: "no inbound from device before timeout"};
}
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) {
summary.innerText = `✅ ${passed} of ${results.length} checks passed` +
(skipped > 0 ? ` (${skipped} skipped)` : "");
summary.style.color = "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("/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) {
summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks`
+ (skipped > 0 ? ` (${skipped} skipped)` : "");
summary.style.color = "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.
function renderMigrationState(summary) {
// --- 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);
caLine.appendChild(stateLine(v.icon, v.text, v.note));
}
// --- Preconditions ---
const remoteCell = document.getElementById("state-remote-services-cell");
if (remoteCell) {
remoteCell.replaceChildren();
const v = remoteServicesVerdict(summary);
remoteCell.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}`;
}
// 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 is active. Whether that's OK depends
// on whether DNS interception is doing the redirect.
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)"};
}
function caVerdict(summary) {
if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
return {icon: "❌", text: "Not installed", note: "(HTTPS to local service will fail TLS validation until injected via SSH)"};
}
function remoteServicesVerdict(summary) {
if (!summary.ssh_success) return {icon: "❓", text: "Unknown", note: "(SSH not reachable)"};
if (!summary.remote_services_enabled) return {icon: "❌", text: "Not enabled", note: "(SSH/telnet shells will not survive a reboot until USB-stick unlock is reapplied)"};
if (!summary.remote_services_persistent) return {icon: "⚠️", text: "Enabled but not persistent", note: "(will be lost on reboot)"};
return {icon: "✅", text: "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;
}
}
document.addEventListener("DOMContentLoaded", () => {
fetchDevices();
fetchSettings();
triggerDiscovery();
});