mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
The migration pre-flight "Reachability check (passive observer)" reported a timed-out no-inbound as a hard failure, so the panel showed "N of M checks failed" and forced a Proceed Anyway. That outcome is usually just timing: the speaker's swUpdate daemon dials out on its own slow schedule and a reboot after Apply validates the fan-out. One reporter wrongly suspected custom service ports were to blame (#471, Leeto001). Introduce a proper non-blocking `warn` status (amber, no fail count) and downgrade the no-inbound case to it, with a message that explains the timing and states it is not a port or config problem and is safe to proceed. The pre-flight summaries now surface a warning count alongside the passed/skipped counts and keep auto-proceeding; genuine probe errors still fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4847 lines
200 KiB
JavaScript
4847 lines
200 KiB
JavaScript
// 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 <code>:443</code> on <code>" + lanHost + "</code>.";
|
||
} else if (outcome.elapsed >= FAST_ERROR_MS) {
|
||
color = "#2e7d32";
|
||
msg = "✅ Your browser reached <code>:" + lanHost + ":443</code> — the failure that follows is the expected " +
|
||
"untrusted-CA error, not a missing listener.";
|
||
} else {
|
||
color = "#c62828";
|
||
msg = "❌ Your browser sees no listener on <code>" + lanHost + ":443</code> " +
|
||
"(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 += " <em>(Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)</em>";
|
||
color = "#c62828";
|
||
} else if (!serverLanOK && browserSees443) {
|
||
msg += " <em>(Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)</em>";
|
||
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");
|
||
|
||
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) 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("/api/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("/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 resolved = document.getElementById("target-domain-resolved");
|
||
if (resolved) {
|
||
if (settings.server_url_resolved_ip) {
|
||
resolved.style.color = "#2e7d32";
|
||
resolved.innerHTML = "✅ DNS will hand out <code>" + settings.server_url_resolved_ip +
|
||
"</code> 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 <code>:443</code> — speakers can connect.";
|
||
} else if (settings.https_443_not_applicable) {
|
||
port443.style.color = "#1565c0";
|
||
port443.innerHTML = "ℹ️ <code>:443</code> 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 = "✅ <code>:443</code> reachable on <code>localhost</code> and <code>" +
|
||
(lanHost || "LAN address") + "</code> (forwarded to <code>:" + listenerPort + "</code>).";
|
||
} 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 <code>:443</code> but AfterTouch listens on <code>:" +
|
||
listenerPort + "</code>. " + details.join(" · ") +
|
||
". Set up iptables / setcap / reverse proxy — see " +
|
||
"<a href=\"https://github.com/gesellix/Bose-SoundTouch/blob/main/docs/guides/HTTPS-SETUP.md\" target=\"_blank\">HTTPS-SETUP.md</a>.";
|
||
}
|
||
|
||
// 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.default_landing) {
|
||
document.getElementById("default-landing").value = settings.default_landing;
|
||
}
|
||
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 = '<span style="color: green;">✅ Active</span>';
|
||
setIntegrationBadge("spotify-badge", "active");
|
||
} else if (settings.spotify_client_id) {
|
||
if (spotifyStatus) spotifyStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
|
||
setIntegrationBadge("spotify-badge", "saved");
|
||
} else {
|
||
if (spotifyStatus) spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
|
||
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 = '<span style="color: green;">✅ Active</span>';
|
||
setIntegrationBadge("amazon-badge", "active");
|
||
} else if (settings.amazon_client_id) {
|
||
if (amazonStatus) amazonStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
|
||
setIntegrationBadge("amazon-badge", "saved");
|
||
} else {
|
||
if (amazonStatus) amazonStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
|
||
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 = '<span style="color: green;">✅ Active (' + (settings.tts_provider || "translate") + ")</span>";
|
||
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 = '<span style="color: #666;">❌ Not active — set ' + need + "</span>";
|
||
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 settings = {
|
||
server_url: document.getElementById("target-domain").value,
|
||
default_landing: document.getElementById("default-landing").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,
|
||
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 = "<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>";
|
||
|
||
// Clear and repopulate selectors
|
||
const currentSyncVal = syncSelector.value;
|
||
const currentMigrationVal = migrationSelector.value;
|
||
const eventSelector = document.getElementById("event-device-selector");
|
||
const currentEventVal = eventSelector ? eventSelector.value : "";
|
||
|
||
syncSelector.innerHTML = '<option value="">-- Select a device --</option>';
|
||
migrationSelector.innerHTML = '<option value="">-- Select a device --</option>';
|
||
if (eventSelector) eventSelector.innerHTML = '<option value="">-- Select a device --</option>';
|
||
|
||
devices.forEach((d) => {
|
||
const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";
|
||
html += `
|
||
<tr id="device-row-${d.device_id}">
|
||
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
|
||
<td class="col-ip">${d.ip_address}</td>
|
||
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || "default"}</div></td>
|
||
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
|
||
<td class="col-method">${methodLabel}</td>
|
||
<td>
|
||
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
|
||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
|
||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||
</td>
|
||
</tr>
|
||
<tr id="device-summary-${d.device_id}" style="display: none;">
|
||
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
|
||
</tr>
|
||
`;
|
||
|
||
const optSync = document.createElement("option");
|
||
optSync.value = d.device_id;
|
||
optSync.textContent = `${d.name} (${d.ip_address})`;
|
||
syncSelector.appendChild(optSync);
|
||
|
||
const optMigrate = document.createElement("option");
|
||
optMigrate.value = d.device_id;
|
||
optMigrate.textContent = `${d.name} (${d.ip_address})`;
|
||
migrationSelector.appendChild(optMigrate);
|
||
|
||
if (eventSelector) {
|
||
const optEvent = document.createElement("option");
|
||
optEvent.value = d.device_id;
|
||
optEvent.textContent = `${d.name} (${d.ip_address})`;
|
||
eventSelector.appendChild(optEvent);
|
||
}
|
||
});
|
||
html += "</table>";
|
||
container.innerHTML = html;
|
||
|
||
if (currentSyncVal) syncSelector.value = currentSyncVal;
|
||
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
|
||
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
|
||
|
||
// Refresh each device's live /info, but cap how many run at
|
||
// once. A browser only opens ~6 connections per origin over
|
||
// HTTP/1.1; an offline speaker holds its /info request until the
|
||
// timeout, so probing every device at once can starve the pool
|
||
// and make the whole page (including navigating away) wait for
|
||
// those requests to clear. Capping below the limit keeps sockets
|
||
// free for navigation and other requests.
|
||
mapLimit(devices, LIVE_INFO_CONCURRENCY, (d) => updateDeviceInfo(d.device_id, d.ip_address));
|
||
fetchSpotifyStatus();
|
||
}
|
||
|
||
return devices.length;
|
||
} catch (error) {
|
||
document.getElementById("device-list").textContent = "Error loading devices: " + error;
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function prepareSync(deviceId) {
|
||
document.getElementById("sync-device-list").value = deviceId;
|
||
openTab(null, "tab-sync");
|
||
}
|
||
|
||
function prepareMigration(deviceId) {
|
||
document.getElementById("migration-device-list").value = deviceId;
|
||
openTab(null, "tab-migration");
|
||
showSummary(deviceId);
|
||
}
|
||
|
||
function openTab(evt, tabId) {
|
||
const tabcontents = document.getElementsByClassName("tab-content");
|
||
for (let i = 0; i < tabcontents.length; i++) {
|
||
tabcontents[i].className = tabcontents[i].className.replace(" active", "");
|
||
}
|
||
|
||
const tablinks = document.getElementsByClassName("tab-btn");
|
||
for (let i = 0; i < tablinks.length; i++) {
|
||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||
}
|
||
|
||
const content = document.getElementById(tabId);
|
||
if (content) {
|
||
content.className += " active";
|
||
}
|
||
|
||
if (tabId === "tab-interactions") {
|
||
fetchInteractionStats();
|
||
fetchInteractions();
|
||
fetchDNSDiscoveries();
|
||
}
|
||
|
||
if (tabId === "tab-account") {
|
||
fetchAccountList();
|
||
}
|
||
|
||
if (tabId === "tab-health") {
|
||
fetchHealth();
|
||
}
|
||
|
||
if (tabId === "tab-logs") {
|
||
startLogsPolling();
|
||
} else {
|
||
stopLogsPolling();
|
||
}
|
||
|
||
if (evt) {
|
||
evt.currentTarget.className += " active";
|
||
let hash = tabId;
|
||
if (tabId === "tab-migration") {
|
||
const dev = document.getElementById("migration-device-list").value;
|
||
if (dev) hash += "?" + dev;
|
||
}
|
||
history.pushState(null, '', '#' + hash);
|
||
} else {
|
||
// Find the button that corresponds to the tabId and activate it
|
||
for (let i = 0; i < tablinks.length; i++) {
|
||
const onclick = tablinks[i].getAttribute("onclick");
|
||
if (onclick && onclick.includes(tabId)) {
|
||
tablinks[i].className += " active";
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
window.addEventListener('popstate', function() {
|
||
const hash = window.location.hash.slice(1);
|
||
const [tabId, extra] = hash.split('?');
|
||
if (!tabId || !document.getElementById(tabId)) {
|
||
openTab(null, "tab-overview");
|
||
return;
|
||
}
|
||
openTab(null, tabId);
|
||
if (tabId === "tab-migration") {
|
||
selectMigrationDevice(extra);
|
||
}
|
||
});
|
||
|
||
function selectMigrationDevice(deviceId) {
|
||
const sel = document.getElementById("migration-device-list");
|
||
if (sel) {
|
||
for (let i = 0; i < sel.options.length; i++) {
|
||
if (sel.options[i].value === deviceId) {
|
||
sel.value = deviceId;
|
||
showSummary(deviceId);
|
||
return;
|
||
}
|
||
}
|
||
sel.value = "";
|
||
document.getElementById("migration-summary").style.display = "none";
|
||
history.replaceState(null, '', '#tab-migration');
|
||
}
|
||
}
|
||
|
||
function getDeviceDisplayName(deviceId) {
|
||
if (!deviceId) return "Unknown Device";
|
||
|
||
// 1. Try migration selector
|
||
const migrationSelector = document.getElementById("migration-device-list");
|
||
if (migrationSelector) {
|
||
for (let opt of migrationSelector.options) {
|
||
if (opt.value === deviceId && opt.textContent !== "-- Select a device --") {
|
||
return opt.textContent;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Try sync selector
|
||
const syncSelector = document.getElementById("sync-device-list");
|
||
if (syncSelector) {
|
||
for (let opt of syncSelector.options) {
|
||
if (opt.value === deviceId && opt.textContent !== "-- Select a device --") {
|
||
return opt.textContent;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Try table lookup
|
||
const rows = document.querySelectorAll("#device-list tr");
|
||
for (const row of rows) {
|
||
const idCell = row.querySelector(".col-deviceid");
|
||
if (idCell && idCell.innerText === deviceId) {
|
||
const name = row.querySelector(".col-name").innerText;
|
||
const ip = row.querySelector(".col-ip").innerText;
|
||
return `${name} (${ip})`;
|
||
}
|
||
}
|
||
|
||
return deviceId;
|
||
}
|
||
|
||
async function startSync() {
|
||
const deviceId = document.getElementById("sync-device-list").value;
|
||
if (!deviceId) {
|
||
alert("Please select a device first");
|
||
return;
|
||
}
|
||
|
||
const status = document.getElementById("sync-status");
|
||
const results = document.getElementById("sync-results");
|
||
const log = document.getElementById("sync-log");
|
||
|
||
status.style.display = "block";
|
||
status.style.backgroundColor = "#eef";
|
||
const display = getDeviceDisplayName(deviceId);
|
||
status.textContent = "Syncing data from " + display + "...";
|
||
results.style.display = "none";
|
||
log.innerHTML = "";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
if (response.ok) {
|
||
status.style.backgroundColor = "#dfd";
|
||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||
results.style.display = "block";
|
||
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||
} else {
|
||
const err = await response.text();
|
||
throw new Error(err);
|
||
}
|
||
} catch (error) {
|
||
status.style.backgroundColor = "#fdd";
|
||
status.textContent = "❌ Sync failed for " + display + ": " + error.message;
|
||
}
|
||
}
|
||
|
||
async function fetchVersion() {
|
||
try {
|
||
const response = await fetch("/api/setup/version");
|
||
const data = await response.json();
|
||
const info = document.getElementById("version-info");
|
||
if (info && data.version) {
|
||
let versionStr = data.version;
|
||
if (data.release_url) {
|
||
versionStr = `<a href="${data.release_url}" target="_blank" style="color: inherit; text-decoration: underline;">${data.version}</a>`;
|
||
}
|
||
let commitStr = data.commit;
|
||
if (data.commit_url) {
|
||
const shortCommit = data.commit.substring(0, 7);
|
||
commitStr = `<a href="${data.commit_url}" target="_blank" style="color: inherit; text-decoration: underline;">${shortCommit}</a>`;
|
||
}
|
||
info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) • ${data.date}`;
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch version info", error);
|
||
}
|
||
}
|
||
|
||
async function fetchAccountList() {
|
||
try {
|
||
const response = await fetch("/api/mgmt/accounts");
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
const selector = document.getElementById("account-selector");
|
||
if (selector) {
|
||
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
|
||
if (data.accounts.length > 0) {
|
||
fetchAccountDetails(selector.value);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch account list", error);
|
||
}
|
||
}
|
||
|
||
async function fetchAccountDetails(accountId) {
|
||
if (!accountId) return;
|
||
const metadataEl = document.getElementById("account-metadata");
|
||
const devicesEl = document.getElementById("account-devices-list");
|
||
const regStatus = document.getElementById("spotify-reg-status");
|
||
const amazonRegStatus = document.getElementById("amazon-reg-status");
|
||
|
||
if (regStatus) regStatus.innerText = "";
|
||
if (amazonRegStatus) amazonRegStatus.innerText = "";
|
||
if (metadataEl) metadataEl.innerHTML = "Loading...";
|
||
if (devicesEl) devicesEl.innerHTML = "Loading devices...";
|
||
|
||
try {
|
||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
|
||
if (!response.ok) {
|
||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
|
||
// Render Metadata
|
||
if (metadataEl) {
|
||
const warningNotice = data.account.is_placeholder ?
|
||
`<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
|
||
<strong>Notice:</strong> Account data (account.json) was not found in the expected location for this account ID.
|
||
</div>` : "";
|
||
|
||
metadataEl.innerHTML = `
|
||
${warningNotice}
|
||
<table style="width: 100%; font-size: 0.9em;">
|
||
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
|
||
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
|
||
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
|
||
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
|
||
<option value="de" ${data.account.preferred_language === "de" ? "selected" : ""}>de</option>
|
||
</select>
|
||
<span id="language-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
|
||
</td></tr>
|
||
<tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">
|
||
${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]) => `
|
||
<div style="margin-bottom: 8px;">
|
||
<strong>${pName}</strong>
|
||
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
|
||
${settings.map(s => {
|
||
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
|
||
return `
|
||
<li style="margin-bottom: 4px;">
|
||
Music Streaming Quality:
|
||
<select class="provider-setting-select"
|
||
data-account-id="${data.account.account_id}"
|
||
data-provider-id="${s.provider_id}"
|
||
data-key="${s.key_name}"
|
||
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
|
||
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
|
||
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
|
||
<option value="3" ${s.value === "3" ? "selected" : ""}>Best Quality - up to 320 kbit/s</option>
|
||
</select>
|
||
<span class="setting-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
|
||
</li>
|
||
`;
|
||
}
|
||
return `<li>${s.key_name}: ${s.value}</li>`;
|
||
}).join("")}
|
||
</ul>
|
||
</div>
|
||
`).join("");
|
||
})() : "None"}
|
||
</td></tr>
|
||
</table>
|
||
`;
|
||
|
||
const languageSelect = document.getElementById("account-language-select");
|
||
if (languageSelect) {
|
||
languageSelect.addEventListener("change", async (e) => {
|
||
const statusEl = document.getElementById("language-update-status");
|
||
const newLang = e.target.value;
|
||
if (statusEl) {
|
||
statusEl.innerText = "Saving...";
|
||
statusEl.style.display = "inline";
|
||
statusEl.style.color = "#666";
|
||
}
|
||
try {
|
||
const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ language: newLang }),
|
||
});
|
||
if (response.ok) {
|
||
if (statusEl) {
|
||
statusEl.innerText = "Saved!";
|
||
statusEl.style.color = "#28a745";
|
||
setTimeout(() => {
|
||
statusEl.style.display = "none";
|
||
}, 2000);
|
||
}
|
||
} else {
|
||
throw new Error(await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to update language", error);
|
||
if (statusEl) {
|
||
statusEl.innerText = "Error!";
|
||
statusEl.style.color = "#dc3545";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const providerSettingSelects = document.querySelectorAll(".provider-setting-select");
|
||
providerSettingSelects.forEach(select => {
|
||
select.addEventListener("change", async (e) => {
|
||
const statusEl = e.target.parentElement.querySelector(".setting-update-status");
|
||
const accID = e.target.dataset.accountId;
|
||
const provID = e.target.dataset.providerId;
|
||
const key = e.target.dataset.key;
|
||
const newValue = e.target.value;
|
||
|
||
if (statusEl) {
|
||
statusEl.innerText = "Saving...";
|
||
statusEl.style.display = "inline";
|
||
statusEl.style.color = "#666";
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
provider_id: provID,
|
||
key: key,
|
||
value: newValue
|
||
}),
|
||
});
|
||
if (response.ok) {
|
||
if (statusEl) {
|
||
statusEl.innerText = "Saved!";
|
||
statusEl.style.color = "#28a745";
|
||
setTimeout(() => {
|
||
statusEl.style.display = "none";
|
||
}, 2000);
|
||
}
|
||
} else {
|
||
throw new Error(await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to update provider setting", error);
|
||
if (statusEl) {
|
||
statusEl.innerText = "Error!";
|
||
statusEl.style.color = "#dc3545";
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Render Devices
|
||
if (devicesEl) {
|
||
if (!data.devices || data.devices.length === 0) {
|
||
devicesEl.innerHTML = "No devices found for this account.";
|
||
return;
|
||
}
|
||
|
||
devicesEl.innerHTML = data.devices.map(device => `
|
||
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
|
||
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
|
||
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
|
||
<div style="font-size: 0.8em; color: #666">
|
||
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">▾</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
|
||
<div>
|
||
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
|
||
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
|
||
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
|
||
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
|
||
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
|
||
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
|
||
</div>
|
||
|
||
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
|
||
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
|
||
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<h5 style="margin: 10px 0 5px 0">Presets (1-6)</h5>
|
||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px">
|
||
${Array.from({length: 6}, (_, i) => {
|
||
const p = device.presets ? device.presets.find(pr => pr.button_number == i + 1) : null;
|
||
let itemName = "Empty";
|
||
let sourceLabel = "";
|
||
|
||
if (p) {
|
||
itemName = p.name || (p.source ? (p.source.source_label || p.source.name || p.source.type) : "Unknown");
|
||
if (p.source) {
|
||
const s = p.source;
|
||
const name = s.provider_label || s.display_name || s.source_name || s.name || s.type;
|
||
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
|
||
const finalName = name || s.type || "Unknown Source";
|
||
if (finalName) {
|
||
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
return `
|
||
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
|
||
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
|
||
<h5 style="margin: 15px 0 5px 0">Recent Items</h5>
|
||
<div style="max-height: 150px; overflow-y: auto; font-size: 0.8em; border: 1px solid #eee; padding: 5px; border-radius: 4px">
|
||
<ul style="padding-left: 15px; margin: 0">
|
||
${device.recents ? device.recents.slice(0, 10).map(r => {
|
||
const name = r.name || (r.source ? (r.source.source_label || r.source.name || r.source.type) : "Unknown");
|
||
let sourceLabel = "";
|
||
if (r.source) {
|
||
const s = r.source;
|
||
const sName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
|
||
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
|
||
const finalSName = sName || s.type || "Unknown Source";
|
||
if (finalSName) {
|
||
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
|
||
}
|
||
}
|
||
const dateRaw = r.last_played_at || r.created_on;
|
||
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
|
||
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
|
||
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
|
||
}).join("") : "<li>No recents</li>"}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 15px; border-top: 1px dashed #ddd; padding-top: 10px">
|
||
<h5 style="margin: 0 0 5px 0">Configured Sources</h5>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 5px">
|
||
${device.sources ? device.sources.filter(s => (s.source_label || s.source_name || s.name || s.type)).map(s => {
|
||
const sourceName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
|
||
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
|
||
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
|
||
return `
|
||
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
|
||
${sourceName}${usernameSuffix}${accountSuffix}
|
||
</span>
|
||
`;
|
||
}).join("") : "<small style='color:#999'>None</small>"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
} catch (error) {
|
||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||
console.error("Failed to fetch account details", error);
|
||
}
|
||
}
|
||
|
||
async function connectSpotifyToAccount() {
|
||
const selector = document.getElementById("account-selector");
|
||
const accountId = selector ? selector.value : "default";
|
||
const statusEl = document.getElementById("spotify-reg-status");
|
||
|
||
if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization...";
|
||
|
||
try {
|
||
const response = await fetch(`/api/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, {
|
||
method: "POST"
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.text();
|
||
throw new Error(err || response.statusText);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const redirectUrl = data.redirectUrl;
|
||
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `Spotify authorization window opened. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
|
||
}
|
||
|
||
// Open Spotify auth in a new window
|
||
window.open(redirectUrl, "SpotifyAuth", "width=600,height=800");
|
||
|
||
// Simple poll to see when we might be done (refresh every 5s for 5 mins)
|
||
let pollCount = 0;
|
||
const interval = setInterval(async () => {
|
||
pollCount++;
|
||
if (pollCount > 60) {
|
||
clearInterval(interval);
|
||
return;
|
||
}
|
||
// Refresh account details to see if source appeared
|
||
await fetchAccountDetails(accountId);
|
||
}, 5000);
|
||
|
||
} catch (error) {
|
||
if (statusEl) statusEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||
console.error("Spotify link failed", error);
|
||
}
|
||
}
|
||
|
||
async function connectAmazonToAccount() {
|
||
const selector = document.getElementById("account-selector");
|
||
const accountId = selector ? selector.value : "default";
|
||
const statusEl = document.getElementById("amazon-reg-status");
|
||
|
||
if (statusEl) statusEl.innerHTML = "Initializing Amazon Music authorization...";
|
||
|
||
try {
|
||
const response = await fetch(`/api/mgmt/amazon/init?account=${encodeURIComponent(accountId)}`, {
|
||
method: "POST"
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.text();
|
||
throw new Error(err || response.statusText);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const redirectUrl = data.redirectUrl;
|
||
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `Amazon Music authorization window opened. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
|
||
}
|
||
|
||
window.open(redirectUrl, "AmazonAuth", "width=600,height=800");
|
||
|
||
let pollCount = 0;
|
||
const interval = setInterval(async () => {
|
||
pollCount++;
|
||
if (pollCount > 60) {
|
||
clearInterval(interval);
|
||
return;
|
||
}
|
||
await fetchAccountDetails(accountId);
|
||
}, 5000);
|
||
|
||
} catch (error) {
|
||
if (statusEl) statusEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||
console.error("Amazon link failed", error);
|
||
}
|
||
}
|
||
|
||
async function fetchInteractionStats() {
|
||
console.log("Fetching interaction stats...");
|
||
try {
|
||
const response = await fetch("/api/setup/interaction-stats");
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const stats = await response.json();
|
||
console.log("Fetched interaction stats:", stats);
|
||
|
||
document.getElementById("total-requests").innerText = stats.total_requests || stats.TotalRequests || 0;
|
||
|
||
const statsContainer = document.getElementById("interaction-stats-container",);
|
||
if (statsContainer) {
|
||
statsContainer.style.display = "block";
|
||
}
|
||
|
||
const serviceList = document.getElementById("stats-by-service");
|
||
serviceList.innerHTML = "";
|
||
const byService = stats.by_service || stats.ByService;
|
||
if (byService) {
|
||
Object.entries(byService).forEach(([service, count]) => {
|
||
const li = document.createElement("li");
|
||
li.innerHTML = `<strong>${service || "unknown"}:</strong> ${count || 0} requests`;
|
||
serviceList.appendChild(li);
|
||
});
|
||
}
|
||
|
||
const sessionList = document.getElementById("stats-by-session");
|
||
const sessionFilter = document.getElementById("filter-session");
|
||
const currentFilter = sessionFilter.value;
|
||
|
||
sessionList.innerHTML = "";
|
||
sessionFilter.innerHTML = '<option value="">All Sessions</option>';
|
||
|
||
const bySession = stats.by_session || stats.BySession;
|
||
if (bySession) {
|
||
// Sort by session ID (timestamp) descending
|
||
const sortedSessions = Object.entries(bySession).sort((a, b) => {
|
||
const sessionA = a[0] || "";
|
||
const sessionB = b[0] || "";
|
||
return sessionB.localeCompare(sessionA);
|
||
});
|
||
|
||
sortedSessions.forEach(([session, count]) => {
|
||
// Session format is like 20260215-160705-99213
|
||
// Try to make it more readable: 2026-02-15 16:07:05 (PID 99213)
|
||
let sessionDisplay = session || "unknown";
|
||
if (session && session.includes("-")) {
|
||
const parts = session.split("-");
|
||
if (parts.length >= 2) {
|
||
const date = parts[0]; // 20260215
|
||
const time = parts[1]; // 160705
|
||
if (date.length === 8 && time.length === 6) {
|
||
sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`;
|
||
if (parts.length >= 3) {
|
||
sessionDisplay += ` (PID ${parts[2]})`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const li = document.createElement("li");
|
||
li.innerHTML = `
|
||
<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
|
||
<div style="display: flex; gap: 5px;">
|
||
<button onclick="downloadSession('${session || ""}')" class="btn-info" style="font-size: 0.8em; padding: 2px 5px;">Download</button>
|
||
<button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
|
||
<button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
|
||
</div>
|
||
`;
|
||
sessionList.appendChild(li);
|
||
|
||
const opt = document.createElement("option");
|
||
opt.value = session || "";
|
||
opt.innerText = sessionDisplay;
|
||
sessionFilter.appendChild(opt);
|
||
});
|
||
|
||
sessionFilter.value = currentFilter;
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch interaction stats", error);
|
||
}
|
||
}
|
||
|
||
function downloadSession(sessionId) {
|
||
if (!sessionId) return;
|
||
window.location.href = `/api/setup/interactions/sessions/${sessionId}/download`;
|
||
}
|
||
|
||
async function filterBySession(sessionId) {
|
||
document.getElementById("filter-session").value = sessionId;
|
||
fetchInteractions();
|
||
const browseContainer = document.getElementById("browse-recordings");
|
||
if (browseContainer) {
|
||
browseContainer.scrollIntoView({behavior: "smooth"});
|
||
}
|
||
}
|
||
|
||
async function deleteSession(sessionId) {
|
||
if (!sessionId) return;
|
||
if (!confirm(`Are you sure you want to delete session ${sessionId}?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/setup/interactions/sessions/${sessionId}`, {
|
||
method: "DELETE",
|
||
});
|
||
if (response.ok) {
|
||
// If the deleted session was selected in the filter, clear the filter
|
||
const sessionFilter = document.getElementById("filter-session");
|
||
if (sessionFilter.value === sessionId) {
|
||
sessionFilter.value = "";
|
||
fetchInteractions();
|
||
}
|
||
fetchInteractionStats();
|
||
} else {
|
||
const err = await response.text();
|
||
alert("Failed to delete session: " + err);
|
||
}
|
||
} catch (error) {
|
||
alert("Error deleting session: " + error.message);
|
||
}
|
||
}
|
||
|
||
async function cleanupSessions() {
|
||
if (!confirm("Are you sure you want to cleanup old sessions? Only the 10 most recent ones will be kept.",)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/interactions/sessions?keep=10", {
|
||
method: "DELETE",
|
||
});
|
||
if (response.ok) {
|
||
// Refresh everything
|
||
document.getElementById("filter-session").value = "";
|
||
fetchInteractionStats();
|
||
fetchInteractions();
|
||
} else {
|
||
const err = await response.text();
|
||
alert("Failed to cleanup sessions: " + err);
|
||
}
|
||
} catch (error) {
|
||
alert("Error cleaning up sessions: " + error.message);
|
||
}
|
||
}
|
||
|
||
async function fetchInteractions() {
|
||
console.log("Fetching interactions...");
|
||
const session = document.getElementById("filter-session").value;
|
||
const category = document.getElementById("filter-category").value;
|
||
const since = document.getElementById("filter-since").value;
|
||
|
||
let url = "/api/setup/interactions";
|
||
const params = [];
|
||
if (session) params.push(`session=${encodeURIComponent(session)}`);
|
||
if (category) params.push(`category=${encodeURIComponent(category)}`);
|
||
if (since) params.push(`since=${encodeURIComponent(since)}`);
|
||
if (params.length > 0) url += "?" + params.join("&");
|
||
|
||
try {
|
||
const response = await fetch(url);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const interactions = await response.json();
|
||
console.log("Fetched interactions:", interactions);
|
||
const list = document.getElementById("interactions-list");
|
||
if (!list) {
|
||
console.error("Could not find interactions-list element");
|
||
return;
|
||
}
|
||
|
||
// Show the parent summary box if it was hidden
|
||
const browseContainer = list.closest(".summary-box");
|
||
if (browseContainer) {
|
||
browseContainer.style.display = "block";
|
||
}
|
||
|
||
list.innerHTML = "";
|
||
|
||
if (!interactions || interactions.length === 0) {
|
||
list.innerHTML = '<tr><td colspan="8" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
// Default sort: Session desc, then Counter asc
|
||
// If a specific session is selected, sort primarily by counter asc
|
||
interactions.sort((a, b) => {
|
||
const sessionA = a.session || a.Session || "";
|
||
const sessionB = b.session || b.Session || "";
|
||
if (sessionA !== sessionB) {
|
||
return sessionB.localeCompare(sessionA);
|
||
}
|
||
const counterA = a.counter || a.Counter || 0;
|
||
const counterB = b.counter || b.Counter || 0;
|
||
return counterA - counterB;
|
||
});
|
||
|
||
interactions.forEach((i) => {
|
||
const tr = document.createElement("tr");
|
||
tr.style.borderBottom = "1px solid #eee";
|
||
|
||
const counter = i.counter || i.Counter || 0;
|
||
const timestamp = i.timestamp || i.Timestamp || "";
|
||
const method = i.method || i.Method || "";
|
||
const path = i.path || i.Path || "";
|
||
const status = i.status || i.Status || "";
|
||
const category = i.category || i.Category || "";
|
||
const session = i.session || i.Session || "";
|
||
const file = i.file || i.File || "";
|
||
const scmudcData = i.scmudc_data || i.SCMUDCData || null;
|
||
|
||
let statusClass = "";
|
||
if (status >= 200 && status < 300) statusClass = "status-success"; else if (status >= 400) statusClass = "status-error";
|
||
|
||
// Create SCMUDC event details
|
||
let eventDetails = "";
|
||
if (scmudcData) {
|
||
const originIcon = getOriginIcon(scmudcData.origin);
|
||
const actionIcon = getActionIcon(scmudcData.action);
|
||
const truncatedCommand = scmudcData.command && scmudcData.command.length > 30 ? scmudcData.command.substring(0, 27) + "..." : scmudcData.command || "";
|
||
|
||
eventDetails = `<span style="font-size: 0.9em;">
|
||
${originIcon} ${actionIcon} ${truncatedCommand}
|
||
${scmudcData.decoded_data ? '<span style="cursor: pointer;" onclick="showSCMUDCDetails(\'' + file + "')\">(...)</span>" : ""}
|
||
</span>`;
|
||
}
|
||
|
||
tr.innerHTML = `
|
||
<td style="padding: 8px; color: #888;">${counter}</td>
|
||
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
|
||
<td style="padding: 8px; font-family: monospace;">${method}</td>
|
||
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
|
||
<td style="padding: 8px;"><span class="badge ${statusClass}">${status || "???"}</span></td>
|
||
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
|
||
<td style="padding: 8px;">${eventDetails}</td>
|
||
<td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
|
||
`;
|
||
list.appendChild(tr);
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to fetch interactions", error);
|
||
}
|
||
}
|
||
|
||
// Helper functions for SCMUDC event display
|
||
function getOriginIcon(origin) {
|
||
const icons = {
|
||
gabbo: "📱", // SoundTouch App
|
||
console: "🎛️", // Device Hardware
|
||
device: "🔄", // Internal System
|
||
};
|
||
return icons[origin] || "❓";
|
||
}
|
||
|
||
function getActionIcon(action) {
|
||
const icons = {
|
||
"play-pressed": "▶️",
|
||
"pause-pressed": "⏸️",
|
||
"power-pressed": "⚡",
|
||
"preset-pressed": "⭐",
|
||
"play-item": "🎵",
|
||
"skip-forward-pressed": "⏭️",
|
||
"stop-pressed": "⏹️",
|
||
};
|
||
return icons[action] || "🔘";
|
||
}
|
||
|
||
function showSCMUDCDetails(file) {
|
||
// Find the interaction data for this file
|
||
fetch("/api/setup/interactions")
|
||
.then((response) => response.json())
|
||
.then((interactions) => {
|
||
const interaction = interactions.find((i) => (i.file || i.File) === file);
|
||
if (interaction && interaction.scmudc_data) {
|
||
displaySCMUDCPopover(interaction.scmudc_data);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to fetch SCMUDC details:", error);
|
||
});
|
||
}
|
||
|
||
function displaySCMUDCPopover(scmudcData) {
|
||
const modal = document.createElement("div");
|
||
modal.style.cssText = `
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.5); z-index: 1000;
|
||
display: flex; align-items: center; justify-content: center;
|
||
`;
|
||
|
||
const content = document.createElement("div");
|
||
content.style.cssText = `
|
||
background: white; padding: 20px; border-radius: 8px;
|
||
max-width: 600px; max-height: 80%; overflow-y: auto;
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||
`;
|
||
|
||
let html = `
|
||
<h3>SCMUDC Event Details</h3>
|
||
<p><strong>Origin:</strong> ${getOriginDescription(scmudcData.origin)} (${scmudcData.origin})</p>
|
||
<p><strong>Action:</strong> ${scmudcData.action}</p>
|
||
<p><strong>Summary:</strong> ${scmudcData.summary}</p>
|
||
`;
|
||
|
||
if (scmudcData.decoded_data) {
|
||
html += `
|
||
<h4>Decoded Content:</h4>
|
||
<p><strong>Source:</strong> ${scmudcData.decoded_data.content_type}</p>
|
||
<p><strong>Item:</strong> ${scmudcData.decoded_data.item_name}</p>
|
||
`;
|
||
|
||
if (scmudcData.decoded_data.source_account) {
|
||
html += `<p><strong>Account:</strong> ${scmudcData.decoded_data.source_account}</p>`;
|
||
}
|
||
|
||
if (scmudcData.decoded_data.artwork_url) {
|
||
html += `<p><strong>Artwork:</strong> <a href="${scmudcData.decoded_data.artwork_url}" target="_blank">View</a></p>`;
|
||
}
|
||
|
||
if (scmudcData.decoded_data.is_presetable) {
|
||
html += `<p><strong>Presetable:</strong> Yes</p>`;
|
||
}
|
||
|
||
if (scmudcData.decoded_data.xml_content) {
|
||
html += `
|
||
<h4>Full XML Content:</h4>
|
||
<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 0.8em;">${escapeHtml(scmudcData.decoded_data.xml_content)}</pre>
|
||
`;
|
||
}
|
||
}
|
||
|
||
html += '<br><button onclick="this.closest(\'div\').remove()" style="padding: 8px 16px;">Close</button>';
|
||
content.innerHTML = html;
|
||
modal.appendChild(content);
|
||
document.body.appendChild(modal);
|
||
|
||
// Close on background click
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === modal) modal.remove();
|
||
});
|
||
}
|
||
|
||
function getOriginDescription(origin) {
|
||
const descriptions = {
|
||
gabbo: "SoundTouch App", console: "Device Hardware", device: "Internal System",
|
||
};
|
||
return descriptions[origin] || origin;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement("div");
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function viewInteraction(file) {
|
||
try {
|
||
const response = await fetch(`/api/setup/interaction-content?file=${encodeURIComponent(file)}`,);
|
||
const content = await response.text();
|
||
|
||
document.getElementById("viewer-filename").innerText = file;
|
||
document.getElementById("interaction-content").innerText = content;
|
||
document.getElementById("interaction-viewer").style.display = "block";
|
||
document
|
||
.getElementById("interaction-viewer")
|
||
.scrollIntoView({behavior: "smooth"});
|
||
} catch (error) {
|
||
alert("Failed to load interaction content: " + error);
|
||
}
|
||
}
|
||
|
||
async function fetchDNSDiscoveries() {
|
||
console.log("Fetching DNS discoveries...");
|
||
try {
|
||
const response = await fetch("/api/setup/dns-discoveries");
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const discoveries = await response.json();
|
||
console.log("Fetched DNS discoveries:", discoveries);
|
||
const list = document.getElementById("dns-discoveries-list");
|
||
if (!list) {
|
||
console.error("Could not find dns-discoveries-list element");
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = "";
|
||
|
||
if (!discoveries || discoveries.length === 0) {
|
||
list.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS queries discovered yet.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
discoveries.forEach((d) => {
|
||
const tr = document.createElement("tr");
|
||
tr.style.borderBottom = "1px solid #eee";
|
||
|
||
const hostname = d.hostname || "";
|
||
const lastSeen = d.last_seen || "";
|
||
const count = d.query_count || 0;
|
||
const isBose = d.is_bose_service ? "✅" : "❌";
|
||
const category = d.is_intercepted ? "self" : "upstream";
|
||
const remoteAddr = d.remote_addr || "unknown";
|
||
|
||
tr.innerHTML = `
|
||
<td style="padding: 8px; font-weight: bold;">${hostname}</td>
|
||
<td style="padding: 8px; font-size: 0.85em;">${lastSeen}</td>
|
||
<td style="padding: 8px; text-align: center;">${count}</td>
|
||
<td style="padding: 8px; text-align: center;">${isBose}</td>
|
||
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
|
||
<td style="padding: 8px; font-size: 0.8em; color: #666;">${remoteAddr}</td>
|
||
`;
|
||
list.appendChild(tr);
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to fetch DNS discoveries", error);
|
||
}
|
||
}
|
||
|
||
async function clearDNSDiscoveries() {
|
||
if (!confirm("Are you sure you want to clear all DNS discovery logs?")) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/dns-discoveries", {
|
||
method: "DELETE",
|
||
});
|
||
if (response.ok) {
|
||
fetchDNSDiscoveries();
|
||
} else {
|
||
const err = await response.text();
|
||
alert("Failed to clear DNS discoveries: " + err);
|
||
}
|
||
} catch (error) {
|
||
alert("Error clearing DNS discoveries: " + error.message);
|
||
}
|
||
}
|
||
|
||
function downloadDNSDiscoveries() {
|
||
window.location.href = "/api/setup/dns-discoveries/download";
|
||
}
|
||
|
||
async function showDeviceEvents() {
|
||
const overlay = document.getElementById("device-events-overlay");
|
||
overlay.style.display = "block";
|
||
overlay.scrollIntoView({behavior: "smooth"});
|
||
|
||
// Ensure device selector is populated (handled by fetchDevices)
|
||
// but if it's still empty, we can try to trigger a fetch
|
||
const selector = document.getElementById("event-device-selector");
|
||
if (selector.options.length <= 1) {
|
||
fetchDevices();
|
||
}
|
||
}
|
||
|
||
async function fetchDeviceEvents(deviceId) {
|
||
if (!deviceId) return;
|
||
|
||
const list = document.getElementById("events-list");
|
||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/setup/devices/${deviceId}/events`);
|
||
const data = await response.json();
|
||
const events = data.events;
|
||
|
||
list.innerHTML = "";
|
||
if (!events || events.length === 0) {
|
||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">No events found for this device.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
// Sort events by time descending
|
||
events.sort((a, b) => (b.time || "").localeCompare(a.time || ""));
|
||
|
||
events.forEach((e) => {
|
||
const tr = document.createElement("tr");
|
||
tr.style.borderBottom = "1px solid #eee";
|
||
|
||
const time = e.time || "";
|
||
const type = e.type || "";
|
||
const data = JSON.stringify(e.data || {});
|
||
|
||
tr.innerHTML = `
|
||
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${time}</td>
|
||
<td style="padding: 8px;"><span class="badge category-self">${type}</span></td>
|
||
<td style="padding: 8px; font-size: 0.85em; font-family: monospace; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title='${data}'>${data}</td>
|
||
`;
|
||
list.appendChild(tr);
|
||
});
|
||
} catch (error) {
|
||
list.innerHTML = `<tr><td colspan="3" style="padding: 20px; text-align: center; color: #f44336;">Error loading events: ${error.message}</td></tr>`;
|
||
}
|
||
}
|
||
function formatBody(body, contentType) {
|
||
if (!body) return "";
|
||
contentType = (contentType || "").toLowerCase();
|
||
if (contentType.includes("json")) {
|
||
return formatJSON(body);
|
||
}
|
||
if (contentType.includes("xml")) {
|
||
return formatXML(body);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function formatJSON(json) {
|
||
if (!json) return "";
|
||
try {
|
||
const obj = typeof json === "string" ? JSON.parse(json) : json;
|
||
return JSON.stringify(obj, null, 2);
|
||
} catch (e) {
|
||
return json;
|
||
}
|
||
}
|
||
|
||
function formatXML(xml) {
|
||
if (!xml) return "";
|
||
try {
|
||
let formatted = "";
|
||
let reg = /(>)(<)(\/*)/g;
|
||
xml = xml.replace(reg, "$1\r\n$2$3");
|
||
let pad = 0;
|
||
xml.split("\r\n").forEach(function (node) {
|
||
let indent = 0;
|
||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||
indent = 0;
|
||
} else if (node.match(/^<\/\w/)) {
|
||
if (pad !== 0) pad -= 1;
|
||
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||
indent = 1;
|
||
} else {
|
||
indent = 0;
|
||
}
|
||
|
||
let padding = "";
|
||
for (let i = 0; i < pad; i++) padding += " ";
|
||
formatted += padding + node + "\r\n";
|
||
pad += indent;
|
||
});
|
||
return formatted.trim();
|
||
} catch (e) {
|
||
return xml;
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
const cfg = await fetchSettings();
|
||
fetchVersion();
|
||
const deviceCount = await fetchDevices();
|
||
// Only sweep automatically on a cold start (no devices known yet). When
|
||
// devices are already in the store, rely on the cached list plus the
|
||
// periodic sweep and the explicit Discover button. Re-probing offline
|
||
// speakers on every admin page load was slow and surprising.
|
||
if (deviceCount === 0 && cfg?.discovery_enabled !== false) {
|
||
triggerDiscovery();
|
||
}
|
||
|
||
const hash = window.location.hash.slice(1);
|
||
const [tabId, extra] = hash.split('?');
|
||
if (tabId && document.getElementById(tabId)) {
|
||
openTab(null, tabId);
|
||
if (tabId === "tab-migration") {
|
||
selectMigrationDevice(extra);
|
||
}
|
||
}
|
||
|
||
const syncBtn = document.getElementById("sync-now-btn");
|
||
if (syncBtn) syncBtn.onclick = startSync;
|
||
});
|
||
|
||
async function addManualDevice() {
|
||
const ip = document.getElementById("add-manual-ip").value.trim();
|
||
if (!ip) {
|
||
alert("Please enter an IP address");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/devices", {
|
||
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
document.getElementById("add-manual-ip").value = "";
|
||
fetchDevices();
|
||
} else {
|
||
const err = await response.text();
|
||
alert("Failed to add device: " + err);
|
||
}
|
||
} catch (error) {
|
||
alert("Error adding device: " + error.message);
|
||
}
|
||
}
|
||
|
||
async function removeDevice(deviceId, name) {
|
||
if (!confirm(`Are you sure you want to remove device "${name}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/setup/devices/${deviceId}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (response.ok) {
|
||
fetchDevices();
|
||
} else {
|
||
const err = await response.text();
|
||
alert("Failed to remove device: " + err);
|
||
}
|
||
} catch (error) {
|
||
alert("Error removing device: " + error.message);
|
||
}
|
||
}
|
||
|
||
async function triggerDiscovery() {
|
||
const indicator = document.getElementById("discovery-indicator");
|
||
if (indicator) indicator.style.display = "inline";
|
||
try {
|
||
const response = await fetch("/api/setup/discover", {method: "POST"});
|
||
if (!response.ok) {
|
||
const err = await response.text();
|
||
alert("Failed to start discovery: " + err);
|
||
if (indicator) indicator.style.display = "none";
|
||
return;
|
||
}
|
||
pollDiscoveryStatus();
|
||
} catch (error) {
|
||
console.error("Failed to trigger discovery", error);
|
||
if (indicator) indicator.style.display = "none";
|
||
}
|
||
}
|
||
|
||
async function pollDiscoveryStatus() {
|
||
const indicator = document.getElementById("discovery-indicator");
|
||
try {
|
||
const response = await fetch("/api/setup/discovery-status");
|
||
const data = await response.json();
|
||
if (data.discovering) {
|
||
setTimeout(pollDiscoveryStatus, 2000);
|
||
} else {
|
||
if (indicator) indicator.style.display = "none";
|
||
fetchDevices();
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to check discovery status", error);
|
||
if (indicator) indicator.style.display = "none";
|
||
}
|
||
}
|
||
|
||
async function updateDeviceInfo(deviceId, ip) {
|
||
try {
|
||
const response = await fetch("/api/setup/info/" + encodeURIComponent(deviceId));
|
||
if (!response.ok) return;
|
||
const info = await response.json();
|
||
|
||
const rowId = "device-row-" + deviceId;
|
||
const row = document.getElementById(rowId);
|
||
if (row) {
|
||
const nameEl = row.querySelector(".col-name");
|
||
if (nameEl && info.name) nameEl.innerText = info.name;
|
||
|
||
const modelEl = row.querySelector(".col-model");
|
||
if (modelEl && info.type) modelEl.innerText = info.type;
|
||
|
||
const serialEl = row.querySelector(".col-serial");
|
||
if (serialEl && info.serialNumber) serialEl.innerText = info.serialNumber;
|
||
|
||
const firmwareEl = row.querySelector(".col-firmware");
|
||
if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion;
|
||
|
||
const deviceIdEl = row.querySelector(".col-deviceid");
|
||
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
|
||
|
||
const accountIdEl = row.querySelector(".col-accountid");
|
||
if (accountIdEl) {
|
||
if (info.margeAccountUUID) {
|
||
accountIdEl.innerText = info.margeAccountUUID;
|
||
accountIdEl.style.color = "#666";
|
||
} else {
|
||
// Empty <margeAccountUUID/> in /info → speaker is
|
||
// either factory-reset or never paired. The
|
||
// Migration tab's wizard already detects this
|
||
// state and prompts for re-pairing; this badge
|
||
// surfaces the affordance from the devices list
|
||
// so users don't have to know to open the
|
||
// Migration tab cold. See issue #234.
|
||
accountIdEl.replaceChildren();
|
||
const badge = document.createElement("a");
|
||
badge.href = "#";
|
||
badge.onclick = (e) => {
|
||
e.preventDefault();
|
||
prepareMigration(deviceId);
|
||
};
|
||
badge.innerText = "⚠ Not paired — re-pair";
|
||
badge.style.color = "#c62828";
|
||
badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired).";
|
||
accountIdEl.appendChild(badge);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to fetch live info for " + ip, error);
|
||
}
|
||
}
|
||
|
||
async function showSummary(deviceId) {
|
||
if (!deviceId) {
|
||
document.getElementById("migration-summary").style.display = "none";
|
||
return;
|
||
}
|
||
const newHash = '#tab-migration?' + deviceId;
|
||
if (window.location.hash !== newHash) {
|
||
history.pushState(null, '', newHash);
|
||
}
|
||
const targetUrl = document.getElementById("target-domain").value;
|
||
|
||
// Per-field URL overrides (Plan card). The summary endpoint uses
|
||
// these to render the planned-config diff so the user sees the
|
||
// exact XML that the migration will write.
|
||
const opts = readPlanURLOptions();
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
|
||
const display = getDeviceDisplayName(deviceId);
|
||
statusDiv.textContent = "Fetching summary for " + display + "...";
|
||
|
||
const outputBox = document.getElementById("command-output-box");
|
||
if (outputBox) outputBox.style.display = "none";
|
||
|
||
let query = "?target_url=" + encodeURIComponent(targetUrl);
|
||
for (let k in opts) {
|
||
query += "&" + k + "=" + encodeURIComponent(opts[k]);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/summary/" + encodeURIComponent(deviceId) + query,);
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(errorText);
|
||
}
|
||
const summary = await response.json();
|
||
|
||
statusDiv.style.display = "none";
|
||
|
||
const ip = summary.ip_address || deviceId;
|
||
const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
|
||
document.getElementById("summary-device-display").innerText = finalDisplay;
|
||
|
||
// Detect a device switch BEFORE we clobber the hidden id input.
|
||
// When the user moves between speakers in the dropdown, any
|
||
// per-device form state (plan-card URL edits, the soundcork
|
||
// checkbox, the "saved" hint) belongs to the previous device
|
||
// and would otherwise leak into the new device's preview.
|
||
const prevDeviceId = document.getElementById("summary-device-id").value;
|
||
const deviceChanged = prevDeviceId && prevDeviceId !== deviceId;
|
||
|
||
// Keep deviceId hidden for subsequent calls
|
||
document.getElementById("summary-device-id").value = deviceId;
|
||
|
||
if (deviceChanged) {
|
||
resetPlanCardForDeviceSwitch();
|
||
}
|
||
|
||
// Update table row if it exists
|
||
const rowId = "device-row-" + deviceId;
|
||
const row = document.getElementById(rowId);
|
||
if (row) {
|
||
const nameEl = row.querySelector(".col-name");
|
||
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
|
||
|
||
const modelEl = row.querySelector(".col-model");
|
||
if (modelEl && summary.device_model) modelEl.innerText = summary.device_model;
|
||
|
||
const serialEl = row.querySelector(".col-serial");
|
||
if (serialEl && summary.device_serial) serialEl.innerText = summary.device_serial;
|
||
|
||
const firmwareEl = row.querySelector(".col-firmware");
|
||
if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version;
|
||
|
||
const deviceIdEl = row.querySelector(".col-deviceid");
|
||
if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id;
|
||
|
||
const accountIdEl = row.querySelector(".col-accountid");
|
||
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
|
||
}
|
||
|
||
renderMigrationState(summary);
|
||
renderPlan(summary);
|
||
renderPlanCurrentURLs(summary);
|
||
renderPlanPairing(summary, deviceId);
|
||
renderPreflightWarnings(summary);
|
||
|
||
// Mirror the global target URL into the Plan card's input.
|
||
// Reset the "saved" feedback to the persisted value so a
|
||
// subsequent edit can flag it as unsaved without confusing
|
||
// the user.
|
||
const planInput = document.getElementById("plan-target-url");
|
||
if (planInput) planInput.value = targetUrl || "";
|
||
const planSaved = document.getElementById("plan-target-saved");
|
||
if (planSaved) {
|
||
planSaved.dataset.savedValue = targetUrl || "";
|
||
planSaved.innerText = "";
|
||
}
|
||
|
||
// Pre-fill the Plan card per-field URL inputs with the canonical
|
||
// defaults from target URL. fillPlanURLInputs preserves any
|
||
// existing user edits across summary refreshes — the user has
|
||
// to click "Reset to defaults" to clobber them, which matches
|
||
// the Telnet pane's existing semantics.
|
||
const soundcork = document.getElementById("plan-soundcork-mode") &&
|
||
document.getElementById("plan-soundcork-mode").checked;
|
||
fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}));
|
||
|
||
const migrationStatus = document.getElementById("migration-status");
|
||
const otherTarget = isMigratedToOtherTarget(summary);
|
||
migrationStatus.innerText = summary.is_migrated ? "✅ Migrated to AfterTouch"
|
||
: otherTarget ? "⚠️ Migrated (URL mismatch)"
|
||
: "❌ Not Migrated";
|
||
migrationStatus.style.color = summary.is_migrated ? "green" : otherTarget ? "orange" : "red";
|
||
migrationStatus.style.fontWeight = "bold";
|
||
|
||
// Trust CA Now button now lives inside the state card's CA / TLS
|
||
// cell. Show only when SSH is reachable AND the CA isn't already
|
||
// trusted on the device.
|
||
const trustBtn = document.getElementById("trust-ca-btn");
|
||
if (trustBtn) {
|
||
const canTrust = summary.ssh_success && !summary.ca_cert_trusted;
|
||
trustBtn.style.display = canTrust ? "inline-block" : "none";
|
||
trustBtn.onclick = () => trustCA(deviceId, ip);
|
||
}
|
||
|
||
// The HTTPS Connection Test runs `curl` on the device via SSH —
|
||
// upload-temp-CA + run-curl — so the panel is irrelevant when
|
||
// SSH isn't reachable. The implicit telnet-poke + observation
|
||
// alternative is on the roadmap but not implemented yet.
|
||
const connectionTestPane = document.getElementById("connection-test");
|
||
if (connectionTestPane) {
|
||
connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
|
||
}
|
||
|
||
const currentConfigElem = document.getElementById("current-config");
|
||
currentConfigElem.innerText = summary.current_config;
|
||
currentConfigElem.style.color = summary.ssh_success ? "black" : "red";
|
||
|
||
document.getElementById("planned-config").innerText = summary.planned_config;
|
||
document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";
|
||
|
||
const resolveErrEl = document.getElementById("resolve-ip-error");
|
||
if (summary.resolve_ip_error) {
|
||
document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
|
||
resolveErrEl.style.display = "block";
|
||
} else {
|
||
resolveErrEl.style.display = "none";
|
||
}
|
||
|
||
const currentResolvElem = document.getElementById("current-resolv-content");
|
||
if (currentResolvElem) {
|
||
currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
|
||
}
|
||
|
||
const testUrlElem = document.getElementById("test-url");
|
||
testUrlElem.innerText = summary.server_https_url || "N/A";
|
||
const testResultDiv = document.getElementById("test-result");
|
||
testResultDiv.style.display = "none";
|
||
testResultDiv.innerText = "";
|
||
|
||
document.getElementById("test-connection-explicit-btn").onclick = () => testConnection(deviceId, true);
|
||
document.getElementById("test-connection-trusted-btn").onclick = () => testConnection(deviceId, false);
|
||
document.getElementById("test-dns-btn").onclick = () => testDNSRedirection(deviceId);
|
||
|
||
renderCustomizeForm(summary);
|
||
|
||
// Migration and reboot are gated on having *some* transport
|
||
// reachable. Telnet is enough for the telnet method (no SSH
|
||
// required); the server returns a clear error if the user picks a
|
||
// method whose transport isn't actually available.
|
||
const anyTransport = summary.ssh_success || summary.telnet_reachable;
|
||
|
||
// Stash device id/ip on the customize form so applyCustomPlan
|
||
// can find them without globals.
|
||
const customizeForm = document.getElementById("customize-form");
|
||
if (customizeForm) {
|
||
customizeForm.dataset.deviceId = deviceId;
|
||
customizeForm.dataset.deviceIp = ip || "";
|
||
}
|
||
|
||
const revertBtn = document.getElementById("revert-migrate-btn");
|
||
revertBtn.onclick = () => revert(deviceId, ip);
|
||
revertBtn.disabled = !summary.ssh_success;
|
||
revertBtn.style.display = summary.original_config ? "inline-block" : "none";
|
||
|
||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||
rebootBtn.onclick = () => reboot(deviceId, ip);
|
||
rebootBtn.disabled = !anyTransport;
|
||
rebootBtn.style.border = "none"; // Reset border if it was set during migration
|
||
|
||
const remoteBtn = document.getElementById("ensure-remote-btn");
|
||
remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip);
|
||
remoteBtn.disabled = !summary.ssh_success;
|
||
|
||
const removeRemoteBtn = document.getElementById("remove-remote-btn");
|
||
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip);
|
||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||
|
||
document.getElementById("migration-summary").style.display = "block";
|
||
document.getElementById("migration-summary").scrollIntoView();
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error fetching summary for " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
function refreshSummary() {
|
||
// Prefer the device id of the most recently shown summary; fall back
|
||
// to the dropdown so the refresh button works even before the user
|
||
// has loaded a summary once.
|
||
const deviceId =
|
||
document.getElementById("summary-device-id").value ||
|
||
document.getElementById("migration-device-list").value;
|
||
if (deviceId) {
|
||
showSummary(deviceId);
|
||
}
|
||
}
|
||
|
||
function showCommandOutput(result) {
|
||
const outputBox = document.getElementById("command-output-box");
|
||
const outputText = document.getElementById("command-output");
|
||
if (outputBox && outputText && result.output) {
|
||
outputBox.style.display = "block";
|
||
outputText.innerText = result.output;
|
||
} else if (outputBox) {
|
||
outputBox.style.display = "none";
|
||
}
|
||
}
|
||
|
||
async function revert(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const display = getDeviceDisplayName(deviceId);
|
||
if (!confirm("Are you sure you want to revert " + display + " to Bose cloud defaults?",)) {
|
||
return;
|
||
}
|
||
|
||
const summaryDiv = document.getElementById("migration-summary");
|
||
summaryDiv.style.display = "none";
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
statusDiv.textContent = "Reverting " + display + " to defaults...";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully started revert for " + display + ".";
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Revert failed for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error reverting " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function reboot(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const display = getDeviceDisplayName(deviceId);
|
||
if (!confirm("Are you sure you want to reboot the speaker at " + display + "?")) {
|
||
return;
|
||
}
|
||
|
||
// Pick the reboot transport from the Customize form's URL flip
|
||
// selection — telnet there strongly implies the user is going
|
||
// SSH-less, so reboot via telnet too. Fall back to ssh otherwise.
|
||
const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "";
|
||
const rebootMethod = flip === "telnet" ? "telnet" : "ssh";
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
// textContent avoids reinterpreting the (user-controlled) device name as HTML.
|
||
statusDiv.textContent = "Rebooting " + display + " via " + rebootMethod + "...";
|
||
|
||
try {
|
||
const url = "/api/setup/reboot/" + encodeURIComponent(deviceId)
|
||
+ "?method=" + encodeURIComponent(rebootMethod);
|
||
const response = await fetch(url, {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully started reboot for " + display + ".";
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Reboot failed for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error rebooting " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
// --- Plan card: account pairing ----------------------------------------
|
||
|
||
// renderPlanPairing populates the Account pairing section of the Plan
|
||
// card from the live summary (margeAccountUUID is in summary.account_id
|
||
// after populateDeviceInfo) and loads known IDs from the datastore.
|
||
async function renderPlanPairing(summary, deviceId) {
|
||
const currentP = document.getElementById("plan-pair-current");
|
||
const input = document.getElementById("plan-pair-id");
|
||
const status = document.getElementById("plan-pair-status");
|
||
|
||
if (currentP) {
|
||
if (summary.is_paired && summary.account_id) {
|
||
currentP.replaceChildren(
|
||
document.createTextNode("Current: ✅ Paired (account "),
|
||
Object.assign(document.createElement("strong"), {textContent: summary.account_id}),
|
||
document.createTextNode(") — leave as-is to keep, or change the ID to re-pair"),
|
||
);
|
||
} else {
|
||
currentP.replaceChildren(
|
||
document.createTextNode("Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply"),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (input) {
|
||
input.value = summary.account_id || "";
|
||
input.dataset.currentId = summary.account_id || "";
|
||
input.style.borderColor = "";
|
||
}
|
||
if (status) {
|
||
status.innerText = "";
|
||
status.style.color = "#666";
|
||
}
|
||
|
||
await loadPlanAccountSuggestions(deviceId);
|
||
|
||
// Run the change handler once so the status hint reflects the
|
||
// pre-filled value (matches current → "no pairing needed").
|
||
onPlanPairIDChange();
|
||
}
|
||
|
||
// loadPlanAccountSuggestions populates the datastore-pick dropdown
|
||
// from /api/setup/account-id-suggestions. Quietly degrades on failure —
|
||
// the input + Generate button still work standalone.
|
||
async function loadPlanAccountSuggestions(deviceId) {
|
||
const select = document.getElementById("plan-pair-existing");
|
||
if (!select) return;
|
||
|
||
select.replaceChildren();
|
||
const placeholder = document.createElement("option");
|
||
placeholder.value = "";
|
||
placeholder.innerText = "— pick from datastore —";
|
||
select.appendChild(placeholder);
|
||
|
||
try {
|
||
const resp = await fetch("/api/setup/account-id-suggestions/" + encodeURIComponent(deviceId));
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
for (const id of data.known || []) {
|
||
const opt = document.createElement("option");
|
||
opt.value = String(id);
|
||
opt.textContent = String(id);
|
||
select.appendChild(opt);
|
||
}
|
||
} catch (e) { /* best-effort */ }
|
||
}
|
||
|
||
// onPlanPairPick mirrors the dropdown choice into the input and
|
||
// triggers validation.
|
||
function onPlanPairPick() {
|
||
const select = document.getElementById("plan-pair-existing");
|
||
const input = document.getElementById("plan-pair-id");
|
||
if (select && select.value && input) {
|
||
input.value = select.value;
|
||
onPlanPairIDChange();
|
||
}
|
||
}
|
||
|
||
// generatePlanAccountID picks a random 7-digit ID, avoiding values
|
||
// already shown in the dropdown so we don't accidentally collide with
|
||
// known datastore entries on this machine.
|
||
function generatePlanAccountID() {
|
||
const input = document.getElementById("plan-pair-id");
|
||
if (!input) return;
|
||
const select = document.getElementById("plan-pair-existing");
|
||
const known = new Set();
|
||
if (select) {
|
||
for (const o of select.options) if (o.value) known.add(o.value);
|
||
}
|
||
for (let i = 0; i < 32; i++) {
|
||
const n = Math.floor(Math.random() * 9_000_000) + 1_000_000;
|
||
const s = String(n);
|
||
if (!known.has(s)) {
|
||
input.value = s;
|
||
onPlanPairIDChange();
|
||
return;
|
||
}
|
||
}
|
||
input.value = "";
|
||
onPlanPairIDChange();
|
||
}
|
||
|
||
// onPlanPairIDChange validates the input and surfaces the implicit
|
||
// intent: empty/matches-current → no pairing step queued; differs
|
||
// → pairing step queued at Apply time.
|
||
function onPlanPairIDChange() {
|
||
const input = document.getElementById("plan-pair-id");
|
||
const status = document.getElementById("plan-pair-status");
|
||
if (!input || !status) return;
|
||
|
||
const v = (input.value || "").trim();
|
||
const currentId = input.dataset.currentId || "";
|
||
|
||
if (!v) {
|
||
if (currentId) {
|
||
status.innerText = "→ no pairing step (current ID retained)";
|
||
status.style.color = "#666";
|
||
} else {
|
||
status.innerText = "→ no pairing step (device stays unpaired — pair via the official Bose app later if needed)";
|
||
status.style.color = "#bf6900";
|
||
}
|
||
input.style.borderColor = "";
|
||
return;
|
||
}
|
||
|
||
if (!/^\d{7}$/.test(v)) {
|
||
status.innerText = "❌ Account ID must be exactly 7 digits";
|
||
status.style.color = "#c62828";
|
||
input.style.borderColor = "#c62828";
|
||
return;
|
||
}
|
||
|
||
if (v === currentId) {
|
||
status.innerText = "→ matches current ID — no pairing step needed";
|
||
status.style.color = "#666";
|
||
input.style.borderColor = "";
|
||
return;
|
||
}
|
||
|
||
if (currentId) {
|
||
status.innerText = `→ will re-pair from ${currentId} to ${v}`;
|
||
} else {
|
||
status.innerText = `→ will pair with ${v}`;
|
||
}
|
||
status.style.color = "#1976d2";
|
||
input.style.borderColor = "";
|
||
}
|
||
|
||
// readPlanPairTarget returns null when no pairing step should run, or
|
||
// the {accountId, valid} for Apply orchestration. Empty input or input
|
||
// matching current = null (no step). Invalid input also returns null
|
||
// but with valid=false so callers can refuse to continue.
|
||
function readPlanPairTarget() {
|
||
const input = document.getElementById("plan-pair-id");
|
||
if (!input) return null;
|
||
const v = (input.value || "").trim();
|
||
if (!v) return null;
|
||
if (!/^\d{7}$/.test(v)) return {accountId: v, valid: false};
|
||
const currentId = input.dataset.currentId || "";
|
||
if (v === currentId) return null;
|
||
return {accountId: v, valid: true};
|
||
}
|
||
|
||
// pairAccount POSTs to /api/setup/pair-account and throws on failure so
|
||
// the Apply orchestrator's first-failure-aborts logic kicks in.
|
||
async function pairAccount(deviceId, accountId) {
|
||
const url = `/api/setup/pair-account/${encodeURIComponent(deviceId)}?account_id=${encodeURIComponent(accountId)}`;
|
||
const resp = await fetch(url, {method: "POST"});
|
||
const result = await resp.json();
|
||
if (!resp.ok || !result.ok) {
|
||
throw new Error(result.error || result.message || `pair-account returned ${resp.status}`);
|
||
}
|
||
}
|
||
|
||
// migrate runs a single migrate call against the backend. method is
|
||
// passed explicitly by the caller (suggested-plan or custom-plan
|
||
// orchestrator); the legacy migration-method dropdown is gone.
|
||
async function migrate(deviceId, ip, method) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
|
||
if (!method) {
|
||
alert("Migration method is required");
|
||
return;
|
||
}
|
||
|
||
const targetUrl = document.getElementById("target-domain").value;
|
||
|
||
// Refuse to migrate when the Plan card's per-field URLs don't pass
|
||
// basic validation — typoed URLs would silently brick the speaker
|
||
// until reverted, and the validator highlights exactly which field
|
||
// is broken.
|
||
if (!validatePlanURLs()) {
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Cannot migrate: one or more service URLs are invalid (see the validation errors above).";
|
||
return;
|
||
}
|
||
|
||
// Per-field URL overrides from the Plan card. The backend's
|
||
// applyURLOverrides honors these for both XML and Telnet
|
||
// migrations. Empty inputs are omitted so the canonical-default
|
||
// fallback runs server-side.
|
||
const opts = readPlanURLOptions();
|
||
|
||
const summaryDiv = document.getElementById("migration-summary");
|
||
summaryDiv.style.display = "none";
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
const display = getDeviceDisplayName(deviceId);
|
||
statusDiv.textContent = "Migrating " + display + " using " + method + "...";
|
||
|
||
let query = "?method=" + encodeURIComponent(method) + "&target_url=" + encodeURIComponent(targetUrl);
|
||
for (let k in opts) {
|
||
query += "&" + k + "=" + encodeURIComponent(opts[k]);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
// replaceChildren keeps the user-controlled `display` outside any
|
||
// HTML-parsing context, while preserving the intentional <strong>.
|
||
statusDiv.replaceChildren(
|
||
document.createTextNode("Successfully started migration for " + display + ". "),
|
||
Object.assign(document.createElement("strong"), {
|
||
textContent: "Please reboot the device to activate the changes.",
|
||
}),
|
||
);
|
||
|
||
// Make reboot button available and prominent
|
||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||
rebootBtn.style.display = "inline-block";
|
||
rebootBtn.disabled = false;
|
||
rebootBtn.style.border = "2px solid #000";
|
||
|
||
// The Reboot button now lives inside the "Customize this
|
||
// migration" <details>; expand it so the post-migration
|
||
// reboot affordance is reachable from the Plan flow too.
|
||
const customize = rebootBtn.closest("details");
|
||
if (customize) customize.open = true;
|
||
|
||
// Re-show summary but with prominence on reboot
|
||
summaryDiv.style.display = "block";
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Migration failed for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error migrating " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function trustCA(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
const display = getDeviceDisplayName(deviceId);
|
||
statusDiv.textContent = "Injecting Root CA into shared trust store on " + display + "...";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully injected Root CA on " + display + ".";
|
||
showSummary(deviceId); // Refresh to update status
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Failed to trust CA on " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error trusting CA on " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function ensureRemoteServices(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const summaryDiv = document.getElementById("migration-summary");
|
||
summaryDiv.style.display = "none";
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
const display = getDeviceDisplayName(deviceId);
|
||
statusDiv.textContent = "Ensuring remote services for " + display + "...";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully ensured remote services for " + display + ".";
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Failed to ensure remote services for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error ensuring remote services for " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function removeRemoteServices(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const display = getDeviceDisplayName(deviceId);
|
||
if (!confirm("Remove the remote_services file from " + display + "? SSH will be disabled after the next reboot.")) {
|
||
return;
|
||
}
|
||
const summaryDiv = document.getElementById("migration-summary");
|
||
summaryDiv.style.display = "none";
|
||
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
statusDiv.textContent = "Removing remote services for " + display + "...";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully removed remote services from " + display + ".";
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Failed to remove remote services for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error removing remote services for " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function backupConfig(deviceId, ip) {
|
||
if (!deviceId) {
|
||
alert("Please select a device.");
|
||
return;
|
||
}
|
||
const statusDiv = document.getElementById("status");
|
||
statusDiv.style.display = "block";
|
||
statusDiv.style.backgroundColor = "#ffffcc";
|
||
const display = getDeviceDisplayName(deviceId);
|
||
statusDiv.textContent = "Creating backup for " + display + "...";
|
||
|
||
try {
|
||
const response = await fetch("/api/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||
const result = await response.json();
|
||
showCommandOutput(result);
|
||
if (result.ok) {
|
||
statusDiv.style.backgroundColor = "#ccffcc";
|
||
statusDiv.textContent = "Successfully created backup for " + display + ".";
|
||
showSummary(deviceId); // Refresh
|
||
} else {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Backup failed for " + display + ": " + (result.message || "Unknown error");
|
||
}
|
||
} catch (error) {
|
||
statusDiv.style.backgroundColor = "#ffcccc";
|
||
statusDiv.textContent = "Error creating backup for " + display + ": " + error;
|
||
}
|
||
}
|
||
|
||
async function testConnection(deviceId, useExplicitCA) {
|
||
const testUrl = document.getElementById("test-url").innerText;
|
||
const testResultDiv = document.getElementById("test-result");
|
||
const display = getDeviceDisplayName(deviceId);
|
||
|
||
testResultDiv.style.display = "block";
|
||
testResultDiv.style.backgroundColor = "#f0f0f0";
|
||
testResultDiv.style.color = "black";
|
||
testResultDiv.innerText = "Running connection test from " + display + "...\n(This may take a few seconds)";
|
||
|
||
try {
|
||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||
const response = await fetch(`/api/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||
const result = await response.json();
|
||
|
||
if (result.ok) {
|
||
testResultDiv.style.backgroundColor = "#ccffcc";
|
||
testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output;
|
||
} else {
|
||
testResultDiv.style.backgroundColor = "#ffcccc";
|
||
testResultDiv.innerText = "❌ Connection failed: " + result.message + "\n\nOutput:\n" + result.output;
|
||
}
|
||
} catch (error) {
|
||
testResultDiv.style.backgroundColor = "#ffcccc";
|
||
testResultDiv.innerText = "❌ Error triggering test: " + error;
|
||
}
|
||
}
|
||
|
||
async function testDNSRedirection(deviceId) {
|
||
const targetUrl = document.getElementById("target-domain").value;
|
||
const testResultDiv = document.getElementById("dns-test-result");
|
||
const display = getDeviceDisplayName(deviceId);
|
||
|
||
testResultDiv.style.display = "block";
|
||
testResultDiv.style.backgroundColor = "#f0f0f0";
|
||
testResultDiv.style.color = "black";
|
||
testResultDiv.innerText = "Running DNS redirection test from " + display + "...\n(This may take a few seconds)";
|
||
|
||
try {
|
||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||
const response = await fetch(`/api/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
|
||
const result = await response.json();
|
||
|
||
if (result.ok) {
|
||
testResultDiv.style.backgroundColor = "#ccffcc";
|
||
testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output;
|
||
} else {
|
||
testResultDiv.style.backgroundColor = "#ffcccc";
|
||
testResultDiv.innerText = "❌ Test failed: " + result.message + "\n\nOutput:\n" + result.output;
|
||
}
|
||
} catch (error) {
|
||
testResultDiv.style.backgroundColor = "#ffcccc";
|
||
testResultDiv.innerText = "❌ Error triggering test: " + error;
|
||
}
|
||
}
|
||
|
||
// onPlanTargetURLChange mirrors the migration tab's plan-target-url
|
||
// input back into the canonical #target-domain input on the Settings
|
||
// tab. Keeps the rest of the migration flow (which still reads
|
||
// #target-domain) working transparently.
|
||
function onPlanTargetURLChange() {
|
||
const v = document.getElementById("plan-target-url").value;
|
||
const canonical = document.getElementById("target-domain");
|
||
if (canonical) canonical.value = v;
|
||
// Hide stale "saved" feedback now that the value diverged from the
|
||
// last persisted state.
|
||
const saved = document.getElementById("plan-target-saved");
|
||
if (saved && saved.dataset.savedValue && saved.dataset.savedValue !== v) {
|
||
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
|
||
saved.style.color = "#bf6900";
|
||
}
|
||
}
|
||
|
||
// saveTargetURLAsDefault posts the current plan-target-url value to
|
||
// /api/setup/settings as the global default. Other settings on that
|
||
// endpoint are read first and re-posted unchanged ("***" secrets and
|
||
// other fields are preserved verbatim — the backend treats "***" as
|
||
// "unchanged").
|
||
async function saveTargetURLAsDefault() {
|
||
const newURL = document.getElementById("plan-target-url").value.trim();
|
||
if (!newURL) {
|
||
alert("Target URL is empty");
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById("plan-save-default-btn");
|
||
const saved = document.getElementById("plan-target-saved");
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
const cur = await (await fetch("/api/setup/settings")).json();
|
||
cur.server_url = newURL;
|
||
|
||
const resp = await fetch("/api/setup/settings", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(cur),
|
||
});
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
|
||
if (saved) {
|
||
saved.innerText = "✅ Saved. Restart the service to apply changes that need a fresh certificate set.";
|
||
saved.style.color = "green";
|
||
saved.dataset.savedValue = newURL;
|
||
}
|
||
} catch (e) {
|
||
if (saved) {
|
||
saved.innerText = "❌ Failed to save: " + e.message;
|
||
saved.style.color = "red";
|
||
}
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// defaultServiceURLs returns the canonical four URLs derived from a
|
||
// service base, with an optional Soundcork-mode adjustment that
|
||
// appends /marge to margeServerUrl. Mirrors setup.defaultTelnetURLs
|
||
// (Go) for the soundtouch-service case; the soundcorkMode option is
|
||
// the UI-side equivalent of the documented soundcork recipe — see
|
||
// docs/analysis/TELNET-MIGRATION-METHOD.md §2.1.
|
||
function defaultServiceURLs(targetUrl, options = {}) {
|
||
const base = (targetUrl || "").replace(/\/+$/, "");
|
||
return {
|
||
marge: options.soundcorkMode ? base + "/marge" : base,
|
||
stats: base,
|
||
sw_update: base + "/updates/soundtouch",
|
||
bmx: base + "/bmx/registry/v1/services",
|
||
};
|
||
}
|
||
|
||
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
|
||
// force=true overwrites existing values (used by Reset and the
|
||
// Soundcork toggle); force=false only fills empties (used on summary
|
||
// render so manual edits survive a refresh).
|
||
function fillPlanURLInputs(urls, {force = false} = {}) {
|
||
const fields = [
|
||
["plan-marge-url", urls.marge],
|
||
["plan-stats-url", urls.stats],
|
||
["plan-sw_update-url", urls.sw_update],
|
||
["plan-bmx-url", urls.bmx],
|
||
];
|
||
for (const [id, value] of fields) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
if (force || !el.value) el.value = value;
|
||
}
|
||
validatePlanURLs();
|
||
}
|
||
|
||
// readPlanURLOptions returns the four inputs as the option keys the
|
||
// backend expects (marge_url / stats_url / sw_update_url / bmx_url).
|
||
// Empty inputs are omitted so the service-side canonical-default
|
||
// fallback runs.
|
||
function readPlanURLOptions() {
|
||
const out = {};
|
||
const pairs = [
|
||
["marge_url", "plan-marge-url"],
|
||
["stats_url", "plan-stats-url"],
|
||
["sw_update_url", "plan-sw_update-url"],
|
||
["bmx_url", "plan-bmx-url"],
|
||
];
|
||
for (const [optKey, elemId] of pairs) {
|
||
const el = document.getElementById(elemId);
|
||
if (el && el.value) out[optKey] = el.value.trim();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// validateURL classifies a string as an OK service URL.
|
||
// Empty value is valid (means "use the canonical default"). Otherwise
|
||
// the URL must parse, the scheme must be http or https, the hostname
|
||
// must be non-empty, and we flag loopback hostnames because they only
|
||
// reach AfterTouch in the on-device-install case (AfterTouch running
|
||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||
// host" deployment, the speaker can't reach loopback on a different
|
||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||
function validateURL(value) {
|
||
const v = (value || "").trim();
|
||
if (!v) return {ok: true, error: ""};
|
||
|
||
let u;
|
||
try {
|
||
u = new URL(v);
|
||
} catch (e) {
|
||
return {ok: false, error: "not a valid URL"};
|
||
}
|
||
|
||
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||
return {ok: false, error: "scheme must be http or https"};
|
||
}
|
||
|
||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||
|
||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||
}
|
||
|
||
return {ok: true, error: ""};
|
||
}
|
||
|
||
// validatePlanURLs validates each of the four Plan-card inputs. Returns
|
||
// true when all are valid. Surfaces inline errors in the validation
|
||
// box, colours invalid input borders red, and disables the Apply
|
||
// Suggested Plan button when anything is invalid.
|
||
function validatePlanURLs() {
|
||
const fields = [
|
||
["margeServerUrl", "plan-marge-url"],
|
||
["statsServerUrl", "plan-stats-url"],
|
||
["swUpdateUrl", "plan-sw_update-url"],
|
||
["bmxRegistryUrl", "plan-bmx-url"],
|
||
];
|
||
|
||
const errors = [];
|
||
|
||
for (const [name, elemId] of fields) {
|
||
const el = document.getElementById(elemId);
|
||
if (!el) continue;
|
||
const v = validateURL(el.value);
|
||
el.style.borderColor = v.ok ? "" : "#c62828";
|
||
if (!v.ok) errors.push(`${name}: ${v.error}`);
|
||
}
|
||
|
||
const errorBox = document.getElementById("plan-url-validation");
|
||
if (errorBox) {
|
||
if (errors.length === 0) {
|
||
errorBox.style.display = "none";
|
||
errorBox.replaceChildren();
|
||
} else {
|
||
errorBox.replaceChildren();
|
||
const ul = document.createElement("ul");
|
||
ul.style.cssText = "margin: 0; padding-left: 1.2em";
|
||
for (const e of errors) {
|
||
const li = document.createElement("li");
|
||
li.innerText = e;
|
||
ul.appendChild(li);
|
||
}
|
||
errorBox.appendChild(ul);
|
||
errorBox.style.display = "block";
|
||
}
|
||
}
|
||
|
||
// Apply Suggested Plan is gated on URL validity (in addition to its
|
||
// existing data-method check). The dataset.method field is set by
|
||
// renderPlan based on what computeSuggestedPlan returned. The
|
||
// Pre-flight sibling shares the same gate so the two buttons
|
||
// enable/disable in lockstep.
|
||
const applyBtn = document.getElementById("plan-apply-btn");
|
||
const preflightBtn = document.getElementById("plan-preflight-btn");
|
||
const noPlan = !applyBtn || !applyBtn.dataset.method;
|
||
if (applyBtn) applyBtn.disabled = noPlan || errors.length > 0;
|
||
if (preflightBtn) preflightBtn.disabled = noPlan || errors.length > 0;
|
||
|
||
// Refresh the client-side XML preview so the Customize panel's
|
||
// Planned Config pane tracks every keystroke without a backend
|
||
// round-trip.
|
||
renderPlannedXMLPreview();
|
||
|
||
return errors.length === 0;
|
||
}
|
||
|
||
// resetPlanURLsToDefaults wipes manual edits and reapplies the
|
||
// canonical defaults derived from the current target URL, honoring
|
||
// the Soundcork-mode checkbox.
|
||
function resetPlanURLsToDefaults() {
|
||
const targetUrl = document.getElementById("plan-target-url").value;
|
||
const soundcork = document.getElementById("plan-soundcork-mode") &&
|
||
document.getElementById("plan-soundcork-mode").checked;
|
||
fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}), {force: true});
|
||
}
|
||
|
||
// resetPlanCardForDeviceSwitch clears every per-device form state on
|
||
// the Plan card so the previously-edited values don't leak into the
|
||
// new device's preview. Called from showSummary the moment the user
|
||
// picks a different speaker in the dropdown.
|
||
//
|
||
// Doesn't refill defaults itself — that happens further down in
|
||
// showSummary via fillPlanURLInputs(defaults), which writes only into
|
||
// empty inputs.
|
||
function resetPlanCardForDeviceSwitch() {
|
||
const inputs = ["plan-marge-url", "plan-stats-url", "plan-sw_update-url", "plan-bmx-url"];
|
||
for (const id of inputs) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.value = "";
|
||
el.style.borderColor = "";
|
||
}
|
||
}
|
||
|
||
const soundcork = document.getElementById("plan-soundcork-mode");
|
||
if (soundcork) soundcork.checked = false;
|
||
|
||
const saved = document.getElementById("plan-target-saved");
|
||
if (saved) {
|
||
saved.innerText = "";
|
||
delete saved.dataset.savedValue;
|
||
}
|
||
|
||
const validationBox = document.getElementById("plan-url-validation");
|
||
if (validationBox) {
|
||
validationBox.style.display = "none";
|
||
validationBox.replaceChildren();
|
||
}
|
||
|
||
const applyStatus = document.getElementById("plan-apply-status");
|
||
if (applyStatus) applyStatus.innerText = "";
|
||
|
||
const customizeStatus = document.getElementById("customize-apply-status");
|
||
if (customizeStatus) customizeStatus.innerText = "";
|
||
|
||
// Pairing input — clear so the renderPlanPairing call later in
|
||
// showSummary populates it from the new device's account_id
|
||
// rather than the previous device's pre-typed value.
|
||
const pairInput = document.getElementById("plan-pair-id");
|
||
if (pairInput) {
|
||
pairInput.value = "";
|
||
pairInput.style.borderColor = "";
|
||
delete pairInput.dataset.currentId;
|
||
}
|
||
const pairStatus = document.getElementById("plan-pair-status");
|
||
if (pairStatus) pairStatus.innerText = "";
|
||
}
|
||
|
||
// toggleSoundcorkMode reapplies defaults so the /marge suffix appears
|
||
// or disappears on margeServerUrl. Manual edits are intentionally
|
||
// reset — the checkbox is a deliberate "give me the canonical
|
||
// soundcork shape" affordance, not a soft hint.
|
||
function toggleSoundcorkMode() {
|
||
resetPlanURLsToDefaults();
|
||
}
|
||
|
||
// computeSuggestedPlan picks the most conservative migration recipe
|
||
// for the device based on which transports are reachable. The chosen
|
||
// default is XML over SSH with HTTP — fewest moving parts, no DNS or
|
||
// CA install required. Telnet is the fallback when SSH is unavailable.
|
||
function computeSuggestedPlan(summary) {
|
||
if (summary.is_migrated) {
|
||
return {
|
||
available: false,
|
||
summary: "✅ Already migrated to AfterTouch",
|
||
note: "Use Customize below to make further changes, or click Reboot if needed.",
|
||
};
|
||
}
|
||
|
||
if (summary.ssh_success) {
|
||
return {
|
||
available: true,
|
||
method: "xml",
|
||
summary: "XML migration over SSH (HTTP)",
|
||
steps: [
|
||
"Backup existing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml",
|
||
"Write new XML pointing at AfterTouch (HTTP, no CA install)",
|
||
"Reboot the device to load the new configuration",
|
||
],
|
||
};
|
||
}
|
||
|
||
if (summary.telnet_reachable) {
|
||
return {
|
||
available: true,
|
||
method: "telnet",
|
||
summary: "Telnet (Port 17000) URL flip (HTTP)",
|
||
steps: [
|
||
"Connect to the diagnostic shell on TCP/17000",
|
||
"Write the four URLs via `sys configuration` + `envswitch boseurls set`",
|
||
"Reboot the device via telnet to persist the change",
|
||
],
|
||
};
|
||
}
|
||
|
||
return {
|
||
available: false,
|
||
summary: "❌ No supported transport reachable",
|
||
note: "Neither SSH nor Telnet:17000 answers. Use the official Bose app to pair before EOS, or unlock remote_services via USB stick to enable SSH.",
|
||
};
|
||
}
|
||
|
||
// renderPlannedXMLPreview rewrites the #planned-config <pre> 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 = [
|
||
'<?xml version="1.0" encoding="utf-8"?>',
|
||
'<SoundTouchSdkPrivateCfg>',
|
||
` <margeServerUrl>${escapeForXMLText(marge)}</margeServerUrl>`,
|
||
` <statsServerUrl>${escapeForXMLText(stats)}</statsServerUrl>`,
|
||
` <swUpdateUrl>${escapeForXMLText(swUpdate)}</swUpdateUrl>`,
|
||
' <usePandoraProductionServer>true</usePandoraProductionServer>',
|
||
' <isZeroconfEnabled>true</isZeroconfEnabled>',
|
||
' <saveMargeCustomerReport>false</saveMargeCustomerReport>',
|
||
` <bmxRegistryUrl>${escapeForXMLText(bmx)}</bmxRegistryUrl>`,
|
||
'</SoundTouchSdkPrivateCfg>',
|
||
].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, "<")
|
||
.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 "<name>: ✅ available" or "<name>: ❌ 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 <li> 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.
|
||
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 "<icon> <bold text> <muted note>".
|
||
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)"};
|
||
}
|
||
|
||
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: "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: "<key> {".
|
||
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, "<").replace(/>/g, ">");
|
||
statusEl.innerHTML =
|
||
`Downloaded: <strong>${safe}</strong><br>` +
|
||
`To share it, please <strong>prefer email</strong>: ` +
|
||
`<a href="mailto:aftertouch-support@gesellix.net?subject=Diagnostic%20report&body=Please%20attach%20${encodeURIComponent(safe)}%20to%20this%20email.">aftertouch-support@gesellix.net</a>. ` +
|
||
`Alternatively, open a <a href="https://github.com/gesellix/Bose-SoundTouch/issues" target="_blank" rel="noopener">GitHub issue</a> ` +
|
||
`and attach the file renamed to <code>${safe}.txt</code> ` +
|
||
`(GitHub blocks <code>.age</code> uploads; adding <code>.txt</code> 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) {
|
||
const t = document.createElement("div");
|
||
t.style.fontSize = "0.8em";
|
||
t.style.color = "#666";
|
||
t.style.marginTop = "4px";
|
||
const parts = [];
|
||
if (target.account) parts.push(`account ${target.account}`);
|
||
if (target.device) parts.push(`device ${target.device}`);
|
||
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 = '<em style="color:#666;">Probing speaker…</em>';
|
||
|
||
try {
|
||
const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
|
||
if (!resp.ok) {
|
||
const txt = await resp.text();
|
||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
cell.innerHTML = "";
|
||
cell.appendChild(renderDeviceSummary(data));
|
||
} catch (e) {
|
||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
|
||
}
|
||
}
|
||
|
||
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, """)
|
||
.replace(/'/g, "'");
|
||
}
|