feat(web): Customize panel — three-axis form with Apply Custom Plan

Replaces the migration-method dropdown and its toggleMigrationMethod
visibility logic with a unified three-axis form inside the Customize
details:

  - URL flip transport: XML over SSH / Telnet (Port 17000) / Skip
  - DNS interception:   None / /etc/resolv.conf hook
  - Local CA install:   checkbox (SSH-only)

Each radio/checkbox has a transport-availability hint next to it
(e.g. "(SSH unreachable)" or "(already trusted)") so users see *why*
an option is disabled before they pick. renderCustomizeForm runs on
every summary load to recompute these hints and pick a valid initial
selection when the previous default isn't reachable.

applyCustomPlan orchestrates the chosen combination as a sequence of
existing backend calls:

  - URL flip != none → POST /setup/migrate?method={xml,telnet}
  - DNS = resolv     → POST /setup/migrate?method=resolv
                       (already includes the CA install, so an explicit
                       CA step is skipped in that case)
  - CA install only  → POST /setup/trust-ca

Steps run in order; the first failure aborts the rest. After the
sequence completes, refreshSummary repopulates the state card.

migrate() now takes the method as an explicit parameter instead of
reading it from the dropdown; applySuggestedPlan and applyCustomPlan
both pass it directly. The legacy "Confirm Migration" button is
removed (Apply Custom Plan supersedes it). The reboot-method picker
now reads the URL flip radio rather than the dropdown.

The legacy per-method preview/test panes (xml-diff, planned-xml,
planned-resolv, current-resolv, dns-redirection-test) become
visibility-driven by the radio choices via onCustomizeChange instead
of the dropdown's toggleMigrationMethod (now removed). The hosts-
related panes are forced hidden — hosts is the deprecated method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-11 00:37:11 +02:00
co-authored by Claude Opus 4.7
parent 670252b230
commit 12165ba58a
2 changed files with 251 additions and 150 deletions
+66 -44
View File
@@ -897,42 +897,75 @@
<summary style="cursor: pointer; font-weight: bold">Customize this migration</summary>
<div
style="
margin: 15px 0;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
"
id="customize-form"
style="margin: 15px 0; padding: 12px; border: 1px solid #ddd; background-color: #f9f9f9; border-radius: 4px"
>
<label for="migration-method"
><strong>Migration Method:</strong></label
>
<select
id="migration-method"
onchange="toggleMigrationMethod()"
>
<option value="xml">
XML Configuration (Recommended - redirects
specific services)
</option>
<option value="telnet">
Telnet (Port 17000) - no SSH required
</option>
<option value="resolv">
/etc/resolv.conf (DHCP-Aware - Redirect via DNS
Hook)
</option>
</select>
<p style="margin: 0 0 12px 0; font-size: 0.9em; color: #555">
Pick any combination of the three axes — the wizard runs
the matching backend operations in order. Disabled options
require a transport this speaker doesn't expose.
</p>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">URL flip transport</legend>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="xml" checked
onchange="onCustomizeChange()"/>
XML over SSH
<span class="customize-hint" data-axis="xml" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="telnet"
onchange="onCustomizeChange()"/>
Telnet (Port 17000)
<span class="customize-hint" data-axis="telnet" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="none"
onchange="onCustomizeChange()"/>
Skip — leave URLs at the Bose cloud (DNS interception will redirect them instead)
</label>
</fieldset>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">DNS interception</legend>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-dns" value="none" checked
onchange="onCustomizeChange()"/>
None
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-dns" value="resolv"
onchange="onCustomizeChange()"/>
<code>/etc/resolv.conf</code> hook (also installs the local CA — needed when URLs stay at <code>https://*.bose.com</code>)
<span class="customize-hint" data-axis="resolv" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
</fieldset>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">Local CA install</legend>
<label style="display: block; margin: 2px 0">
<input type="checkbox" id="customize-ca-install"
onchange="onCustomizeChange()"/>
Install local root CA on the device via SSH (only needed when targeting <code>https://</code>)
<span class="customize-hint" data-axis="ca" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
</fieldset>
<div
id="dns-port-warning"
style="
margin-top: 5px;
color: #d32f2f;
font-weight: bold;
font-size: 0.9em;
display: none;
"
id="customize-validation"
style="display: none; margin: 8px 0; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
></div>
<div style="margin-top: 12px">
<button
type="button"
id="customize-apply-btn"
onclick="applyCustomPlan()"
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
>Apply Custom Plan</button>
<span id="customize-apply-status" style="margin-left: 10px; font-size: 0.9em"></span>
</div>
</div>
<div
@@ -1098,17 +1131,6 @@
</div>
</div>
<div style="margin-top: 15px">
<button
id="confirm-migrate-btn"
style="
background-color: #4caf50;
color: white;
border: none;
padding: 10px 20px;
"
>
Confirm Migration
</button>
<button
id="revert-migrate-btn"
style="
+185 -106
View File
@@ -1838,7 +1838,7 @@ async function showSummary(deviceId) {
document.getElementById("test-hosts-btn").onclick = () => testHostsRedirection(deviceId);
document.getElementById("test-dns-btn").onclick = () => testDNSRedirection(deviceId);
toggleMigrationMethod();
renderCustomizeForm(summary);
// Migration and reboot are gated on having *some* transport
// reachable. Telnet is enough for the telnet method (no SSH
@@ -1846,9 +1846,13 @@ async function showSummary(deviceId) {
// method whose transport isn't actually available.
const anyTransport = summary.ssh_success || summary.telnet_reachable;
const migrateBtn = document.getElementById("confirm-migrate-btn");
migrateBtn.onclick = () => migrate(deviceId, ip);
migrateBtn.disabled = !anyTransport;
// 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);
@@ -1944,10 +1948,11 @@ async function reboot(deviceId, ip) {
return;
}
// Pick the reboot transport from the migration method dropdown — telnet
// migration likely means SSH isn't available on the device.
const migrationMethod = (document.getElementById("migration-method") || {}).value || "";
const rebootMethod = migrationMethod === "telnet" ? "telnet" : "ssh";
// 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";
@@ -2093,13 +2098,21 @@ async function pairAccount(deviceId) {
}
}
async function migrate(deviceId, ip) {
// 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;
const method = document.getElementById("migration-method").value;
// Refuse to migrate when the Plan card's per-field URLs don't pass
// basic validation — typoed URLs would silently brick the speaker
@@ -2753,11 +2766,8 @@ function transportChip(name, ok) {
}
// applySuggestedPlan triggers the recipe computeSuggestedPlan picked.
// For now it programmatically points the legacy Migration Method
// dropdown at the chosen method and reuses the existing migrate()
// entry point — that keeps the option-plumbing path identical
// (target_url, marge_url, etc.) until the Customize panel is fully
// replaced.
// 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");
@@ -2770,20 +2780,14 @@ async function applySuggestedPlan() {
return;
}
const dropdown = document.getElementById("migration-method");
if (dropdown) dropdown.value = method;
if (status) {
status.innerText = "Applying " + method + "…";
status.style.color = "#555";
}
// Resolve the IP from the visible summary state so migrate() can
// hand it to the backend route. The hidden summary-device-id
// field stores the device id; the IP shows up in the device
// display string, but migrate() ultimately sends just the device
// id and the backend resolves to IP — so an empty ip arg is fine.
await migrate(deviceId, "");
// 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);
if (status) status.innerText = "";
}
@@ -3073,94 +3077,169 @@ function renderPreflightWarnings(summary) {
banner.style.display = "block";
}
async function toggleMigrationMethod() {
const method = document.getElementById("migration-method").value;
const xmlDiffPane = document.getElementById("xml-diff-pane");
const plannedXmlPane = document.getElementById("planned-xml-pane");
const plannedHostsPane = document.getElementById("planned-hosts-pane");
const plannedResolvPane = document.getElementById("planned-resolv-pane");
const currentResolvPane = document.getElementById("current-resolv-pane");
const hostsTestPane = document.getElementById("hosts-redirection-test");
const dnsTestPane = document.getElementById("dns-redirection-test");
const telnetPane = document.getElementById("telnet-method-pane");
// 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 dnsWarning = document.getElementById("dns-port-warning");
const setHint = (axis, text) => {
const el = document.querySelector(`.customize-hint[data-axis="${axis}"]`);
if (el) el.innerText = text;
};
if (telnetPane) telnetPane.style.display = method === "telnet" ? "block" : "none";
if (method === "telnet") {
xmlDiffPane.style.display = "none";
plannedXmlPane.style.display = "none";
plannedHostsPane.style.display = "none";
plannedResolvPane.style.display = "none";
currentResolvPane.style.display = "none";
hostsTestPane.style.display = "none";
dnsTestPane.style.display = "none";
if (dnsWarning) dnsWarning.style.display = "none";
} else if (method === "hosts") {
xmlDiffPane.style.display = "none";
plannedXmlPane.style.display = "none";
plannedHostsPane.style.display = "block";
plannedResolvPane.style.display = "none";
currentResolvPane.style.display = "none";
hostsTestPane.style.display = "block";
dnsTestPane.style.display = "none";
if (dnsWarning) dnsWarning.style.display = "none";
} else if (method === "resolv") {
xmlDiffPane.style.display = "none";
plannedXmlPane.style.display = "none";
plannedHostsPane.style.display = "none";
plannedResolvPane.style.display = "block";
currentResolvPane.style.display = "none";
hostsTestPane.style.display = "none";
dnsTestPane.style.display = "block";
const resolvNote = document.getElementById("resolv-note");
if (resolvNote) {
resolvNote.innerHTML = "<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.";
// 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;
}
}
// Check DNS settings
try {
const response = await fetch("/setup/settings");
const settings = await response.json();
const dnsBind = settings.dns_bind_addr || "";
const isPort53 = dnsBind.endsWith(":53") || dnsBind === "53";
const isEnabled = settings.dns_enabled;
const isRunning = settings.dns_running;
const actualBind = settings.dns_actual_bind;
// Telnet requires the diagnostic shell on port 17000.
if (telnetRadio) {
const ok = !!summary.telnet_reachable;
telnetRadio.disabled = !ok;
setHint("telnet", ok ? "" : "(Telnet:17000 unreachable)");
}
if (dnsWarning) {
if (!isEnabled) {
dnsWarning.innerText = "⚠️ DNS Discovery is DISABLED in Settings. Migration will fail.";
dnsWarning.style.display = "block";
} else if (!isPort53) {
dnsWarning.innerText = `⚠️ DNS Discovery is bound to ${dnsBind}, but port 53 is required for migration.`;
dnsWarning.style.display = "block";
} else if (!isRunning) {
dnsWarning.innerText = `⚠️ DNS Discovery server is NOT RUNNING on ${dnsBind} (check for port conflicts/permissions). Migration will fail.`;
dnsWarning.style.display = "block";
} else {
dnsWarning.style.display = "none";
}
}
} catch (e) {
console.error("Failed to check DNS settings", e);
// 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;
}
} else {
// XML branch (the default fallthrough). The DNS-port warning is
// owned by the resolv branch, but the previous code path forgot
// to reset it here — switching from resolv back to xml left a
// stale "DNS Discovery is DISABLED" warning attached to the XML
// method, where it's irrelevant.
if (dnsWarning) dnsWarning.style.display = "none";
xmlDiffPane.style.display = "block";
plannedXmlPane.style.display = "block";
plannedHostsPane.style.display = "none";
plannedResolvPane.style.display = "none";
currentResolvPane.style.display = "none";
hostsTestPane.style.display = "none";
dnsTestPane.style.display = "none";
}
// 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 legacy preview/test panes inside Customize.
const show = (id, on) => {
const el = document.getElementById(id);
if (el) el.style.display = on ? "block" : "none";
};
show("xml-diff-pane", flip === "xml");
show("planned-xml-pane", flip === "xml");
show("telnet-method-pane", flip === "telnet");
show("planned-resolv-pane", dns === "resolv");
show("planned-hosts-pane", false);
show("current-resolv-pane", dns === "resolv");
show("hosts-redirection-test", false);
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;
}
// 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";
};
const steps = [];
if (flip === "xml" || flip === "telnet") {
steps.push({label: `URL flip via ${flip}`, run: () => migrate(deviceId, ip, flip)});
}
if (dns === "resolv") {
steps.push({label: "DNS interception (resolv.conf hook + CA install)", run: () => migrate(deviceId, ip, "resolv")});
}
if (caInstall && dns !== "resolv") {
// resolv already trusts the CA; only do an explicit trust-ca
// when the user picked it standalone.
steps.push({label: "Install local CA", run: () => trustCA(deviceId, ip)});
}
if (steps.length === 0) {
setStatus("Pick at least one axis above.", "#c62828");
return;
}
const applyBtn = document.getElementById("customize-apply-btn");
if (applyBtn) applyBtn.disabled = true;
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;
}
}