feat(web): per-field URL editor with validation in the Plan card

Adds a Service URLs section to the Plan card with four free-form URL
inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl), a
"Current on Device" column populated from telnet getpdo (falling back
to the SSH-read XML config), a Soundcork-mode checkbox that flips the
/marge suffix on margeServerUrl, and a Reset-to-defaults button.

Validation runs on every keystroke (oninput) and on each summary
render: each URL must parse via the URL constructor, the scheme must
be http or https, the hostname must be non-empty, and "localhost" or
"127.0.0.1" are explicitly rejected (the speaker can't reach this
machine via that name). Invalid inputs get a red border, an inline
error list surfaces under the table, and the Apply Suggested Plan
button is disabled until everything is valid. migrate() also gates on
validatePlanURLs() and surfaces a clear status message rather than
sending typoed URLs that would silently brick the speaker.

The Plan card's per-field URLs feed both XML and Telnet migrations
via the marge_url / stats_url / sw_update_url / bmx_url options the
backend's applyURLOverrides honors. The legacy XML dropdowns
(self/proxied/original) and the duplicate URL Targets table inside
the Telnet pane stay in the markup for now — the next iteration
removes them once we're confident the Plan card flow covers
everything.

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 10954c6161
commit 3dd3e3eaef
2 changed files with 301 additions and 9 deletions
+87
View File
@@ -787,6 +787,93 @@
</div>
</div>
<div style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Service URLs</h4>
<p style="margin: 0 0 8px 0; font-size: 0.85em; color: #555">
Pre-filled from the target URL above. Edit any field for advanced setups (e.g. soundcork users
append <code>/marge</code> to <code>margeServerUrl</code>). These overrides apply to both XML
and Telnet migrations.
</p>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Field</th>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Current on Device</th>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Target URL</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">margeServerUrl</td>
<td id="plan-current-marge" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-marge-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">statsServerUrl</td>
<td id="plan-current-stats" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-stats-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">swUpdateUrl</td>
<td id="plan-current-sw_update" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-sw_update-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">bmxRegistryUrl</td>
<td id="plan-current-bmx" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-bmx-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 6px; font-size: 0.85em">
<label>
<input
type="checkbox"
id="plan-soundcork-mode"
onchange="toggleSoundcorkMode()"
/>
Soundcork mode (append <code>/marge</code> to <code>margeServerUrl</code>)
</label>
<button
type="button"
onclick="resetPlanURLsToDefaults()"
style="margin-left: 16px; font-size: 0.85em"
>Reset to defaults</button>
</div>
<div
id="plan-url-validation"
style="display: none; margin-top: 8px; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
></div>
</div>
<div>
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Suggested plan</h4>
<div
+214 -9
View File
@@ -1760,6 +1760,7 @@ async function showSummary(deviceId) {
renderMigrationState(summary);
renderPlan(summary);
renderPlanCurrentURLs(summary);
renderTelnetPreflight(summary);
renderPreflightWarnings(summary);
fillTelnetURLInputs(defaultTelnetURLs(targetUrl));
@@ -1776,6 +1777,15 @@ async function showSummary(deviceId) {
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");
migrationStatus.innerText = summary.is_migrated ? "✅ Migrated to AfterTouch" : "❌ Not Migrated";
migrationStatus.style.color = summary.is_migrated ? "green" : "red";
@@ -2105,17 +2115,40 @@ async function migrate(deviceId, ip) {
const targetUrl = document.getElementById("target-domain").value;
const method = document.getElementById("migration-method").value;
const opts = {
marge: document.getElementById("opt-marge").value,
stats: document.getElementById("opt-stats").value,
sw_update: document.getElementById("opt-sw_update").value,
bmx: document.getElementById("opt-bmx").value,
};
if (method === "telnet") {
Object.assign(opts, readTelnetURLOptions());
// 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;
}
const opts = {};
// Legacy XML mode dropdowns (self/proxied/original) — only read if
// the elements still exist. They're being removed in the next
// iteration; until then a literal *_url override from the Plan
// card wins anyway via assignment order below + the backend's
// applyURLOverrides taking precedence over applyProxyOptions.
const optMarge = document.getElementById("opt-marge");
const optStats = document.getElementById("opt-stats");
const optSwUpdate = document.getElementById("opt-sw_update");
const optBmx = document.getElementById("opt-bmx");
if (optMarge) opts.marge = optMarge.value;
if (optStats) opts.stats = optStats.value;
if (optSwUpdate) opts.sw_update = optSwUpdate.value;
if (optBmx) opts.bmx = optBmx.value;
// Per-field URL overrides from the Plan card apply to any method
// (XML or Telnet). Telnet pane's URL Targets table is now
// redundant for the Plan-driven flow but is kept under Customize
// for the next iteration's cleanup.
Object.assign(opts, readPlanURLOptions());
const summaryDiv = document.getElementById("migration-summary");
summaryDiv.style.display = "none";
@@ -2499,6 +2532,160 @@ async function saveTargetURLAsDefault() {
}
}
// 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 reject "localhost" because the speaker
// can't reach this machine via that name.
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: "use the LAN IP/hostname, not localhost — the speaker can't reach this machine via that name"};
}
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.
const applyBtn = document.getElementById("plan-apply-btn");
if (applyBtn) {
const noPlan = !applyBtn.dataset.method;
applyBtn.disabled = noPlan || errors.length > 0;
}
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});
}
// 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
@@ -2545,6 +2732,24 @@ function computeSuggestedPlan(summary) {
};
}
// 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.