From 27dccc779f5435fda4d9aef6f33cb094578d8d48 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 10 May 2026 19:31:25 +0200 Subject: [PATCH] feat(web): per-field telnet URL inputs, preflight status, warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration tab gains: - Telnet (Port 17000) status line in the summary box, mirroring the SSH connection line. Shows ✅/❌, the device's diagnostic shell banner if any, and a probe-error block when a TCP dial succeeded but the shell rejected getpdo. - Cross-check warnings banner that surfaces summary.warnings (the SSH-XML vs telnet-getpdo URL diffs from the parallel preflight) as informational notices above the migration controls. - URL Targets table inside the telnet method pane with four editable inputs (Marge, Stats, Software Update, BMX Registry) pre-filled from the canonical defaultTelnetURLs(target_url) derivation. Each row shows the device's current value alongside, parsed from summary.telnet_verified_config. A "Reset to defaults" button wipes user edits in the table. - Migrate / Reboot buttons now enable when *either* SSH or telnet is reachable, so the SSH-less telnet path can actually be triggered from the UI. The four URL inputs are folded into the migrate query string as the marge_url / stats_url / sw_update_url / bmx_url options the handler now recognises. Empty fields are omitted so the service's telnetURLsFromOptions canonical fallback runs. JS helpers parseTelnetVerifiedConfig and defaultTelnetURLs mirror the Go-side parseGetpdoConfig and defaultTelnetURLs — keep them in sync. I cannot run a browser test from this environment, so this change is verified only by go build, the Go test suite (setup + handlers, race), and node --check on the modified script.js. Worth a manual smoke test of: switching to telnet, observing the inputs pre-fill, editing one field, kicking off a migration, and reading back the warnings banner on a freshly-migrated speaker. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/web/index.html | 89 ++++++++++++++ pkg/service/handlers/web/js/script.js | 163 +++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 2 deletions(-) diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index a958f62..a681624 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -505,6 +505,16 @@

Migration Status:

SSH Connection:

+

Telnet (Port 17000): + +

+

URL Targets

+

+ Pre-filled from the target URL above. Most users leave + these as-is. If you redirect to a fork like + soundcork + that mounts marge under /marge, append the + suffix here — the matching envswitch boseurls set + argument is derived automatically. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldCurrent on DeviceTarget URL
Marge + +
Stats + +
Software Update + +
BMX Registry + +
+
+ +
+ +
migrate(deviceId, ip); - migrateBtn.disabled = !summary.ssh_success; + migrateBtn.disabled = !anyTransport; const revertBtn = document.getElementById("revert-migrate-btn"); revertBtn.onclick = () => revert(deviceId, ip); @@ -1853,7 +1863,7 @@ async function showSummary(deviceId) { const rebootBtn = document.getElementById("reboot-speaker-btn"); rebootBtn.onclick = () => reboot(deviceId, ip); - rebootBtn.disabled = !summary.ssh_success; + rebootBtn.disabled = !anyTransport; rebootBtn.style.border = "none"; // Reset border if it was set during migration const remoteBtn = document.getElementById("ensure-remote-btn"); @@ -2103,6 +2113,10 @@ async function migrate(deviceId, ip) { bmx: document.getElementById("opt-bmx").value, }; + if (method === "telnet") { + Object.assign(opts, readTelnetURLOptions()); + } + const summaryDiv = document.getElementById("migration-summary"); summaryDiv.style.display = "none"; @@ -2369,6 +2383,151 @@ function toggleOriginalConfig() { pane.style.display = pane.style.display === "none" ? "block" : "none"; } +// defaultTelnetURLs returns the canonical four URLs derived from a +// service base URL. Mirrors setup.defaultTelnetURLs (Go) — keep them in +// sync if either side changes. +function defaultTelnetURLs(targetUrl) { + const base = (targetUrl || "").replace(/\/+$/, ""); + return { + marge: base, + stats: base, + sw_update: base + "/updates/soundtouch", + bmx: base + "/bmx/registry/v1/services", + }; +} + +// fillTelnetURLInputs writes the given URL set into the four input +// fields, but only when the field is empty (so a user's edit is never +// clobbered by a refresh). +function fillTelnetURLInputs(urls, {force = false} = {}) { + const fields = [ + ["telnet-marge-url", urls.marge], + ["telnet-stats-url", urls.stats], + ["telnet-sw_update-url", urls.sw_update], + ["telnet-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; + } +} + +// resetTelnetURLsToDefaults wipes any user edits and reapplies the +// canonical defaults. Wired to the "Reset to defaults" button in the +// telnet pane. +function resetTelnetURLsToDefaults() { + const targetUrl = document.getElementById("target-domain").value; + fillTelnetURLInputs(defaultTelnetURLs(targetUrl), {force: true}); +} + +// readTelnetURLOptions returns the four per-field URL overrides as the +// query-parameter map the handler expects (marge_url / stats_url / +// sw_update_url / bmx_url). Empty fields are omitted so the service +// layer's "fall back to canonical default" path is exercised. +function readTelnetURLOptions() { + const out = {}; + const pairs = [ + ["marge_url", "telnet-marge-url"], + ["stats_url", "telnet-stats-url"], + ["sw_update_url", "telnet-sw_update-url"], + ["bmx_url", "telnet-bmx-url"], + ]; + for (const [optKey, elemId] of pairs) { + const el = document.getElementById(elemId); + if (el && el.value) out[optKey] = el.value; + } + return out; +} + +// renderTelnetPreflight surfaces TelnetReachable / TelnetBanner / +// TelnetProbeError on the migration summary, and populates the "Current +// on Device" column from TelnetVerifiedConfig when the device answered. +function renderTelnetPreflight(summary) { + const statusEl = document.getElementById("telnet-status"); + if (statusEl) { + if (summary.telnet_reachable) { + statusEl.innerText = "✅ Reachable"; + statusEl.style.color = "green"; + } else if (summary.telnet_probe_error) { + statusEl.innerText = "❌ Unreachable"; + statusEl.style.color = "red"; + } else { + statusEl.innerText = "❓ Unknown"; + statusEl.style.color = "gray"; + } + } + + const bannerEl = document.getElementById("telnet-banner"); + if (bannerEl) { + bannerEl.innerText = summary.telnet_banner ? `(${summary.telnet_banner})` : ""; + } + + const errorEl = document.getElementById("telnet-probe-error"); + if (errorEl) { + if (summary.telnet_probe_error && !summary.telnet_reachable) { + errorEl.innerText = "Probe error: " + summary.telnet_probe_error; + errorEl.style.display = "block"; + } else { + errorEl.style.display = "none"; + } + } + + const live = parseTelnetVerifiedConfig(summary.telnet_verified_config || ""); + const cells = [ + ["telnet-current-marge", live.margeServerUrl], + ["telnet-current-stats", live.statsServerUrl], + ["telnet-current-sw_update", live.swUpdateUrl], + ["telnet-current-bmx", live.bmxRegistryUrl], + ]; + for (const [id, value] of cells) { + const el = document.getElementById(id); + if (el) el.innerText = value || "—"; + } +} + +// parseTelnetVerifiedConfig extracts key=value pairs from the device's +// `getpdo CurrentSystemConfiguration` reply. Mirrors +// setup.parseGetpdoConfig (Go) — see that function's docstring for the +// tolerance contract. +function parseTelnetVerifiedConfig(text) { + const out = {}; + if (!text) return out; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const i = line.indexOf("="); + if (i <= 0) continue; + const key = line.slice(0, i).trim(); + const val = line.slice(i + 1).trim(); + if (key) out[key] = val; + } + 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"; +} + async function toggleMigrationMethod() { const method = document.getElementById("migration-method").value; const xmlDiffPane = document.getElementById("xml-diff-pane");