Files
Bose-SoundTouch/pkg/service/handlers/web/index.html
T
Tobias GesellchenandClaude Opus 4.8 b1b3472297 feat(settings): derive the HTTPS URL from the Target Domain, show + override in UI (#355)
The HTTPS URL AfterTouch advertises (and points speakers at for the
DNS-redirect, OAuth, install-ca and cert-trust flows) was a separate,
internally-tracked value: sourced only from --https-server-url /
HTTPS_SERVER_URL / the settings file, defaulting to the machine hostname,
and never shown or editable in the web UI. So it could silently diverge
from the Target Domain (e.g. a different host, or a port-less value that
fell back to 443 while the listener was on 8443 — the root of #355), with
no way to see or fix it in the UI.

Make it derive + show + override:

- DeriveHTTPSURL resolves the effective HTTPS URL: an explicit override
  wins; otherwise it follows the Target Domain (same host, https, on the
  configured HTTPS port); an already-https Target Domain is honoured
  verbatim (its port is not second-guessed); empty falls back to the
  hostname default. So changing the Target Domain updates the HTTPS URL
  automatically for the common single-host case.
- The persisted https_server_url is now the *override* (empty = derive).
  Existing installs carry their old value here, so it is preserved as an
  override — no silent change on upgrade; clearing it opts into derive.
- The server keeps httpsServerURL as the effective value, so all
  consumers (cert SANs, migration, export, health) are unchanged; it is
  recomputed whenever the Target Domain or override changes.
- Settings API returns https_server_url (effective) plus
  https_server_url_override; the Settings page shows the effective URL
  with a derived/override note and an "advanced" override field.

Verified live on a clean data dir: derive from an http Target Domain,
auto-follow when the Target Domain changes, explicit override, an https
Target Domain kept verbatim, and override persistence across restart.
Unit tests cover DeriveHTTPSURL including the already-https cases.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00

1718 lines
82 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="UTF-8"/>
<title>AfterTouch</title>
<meta name="description" content="AfterTouch Admin UI — Configure your SoundTouch replacement, migrate speakers, and manage your local account." />
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml"/>
<link rel="stylesheet" href="/web/css/style.css"/>
</head>
<body>
<header class="topbar">
<a class="brand" href="/?chooser" title="AfterTouch home">
<img src="/web/img/favicon-braille.svg" alt=""/>
<span class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
</span>
</a>
<nav class="bar-links">
<a href="https://gesellix.github.io/Bose-SoundTouch/" target="_blank" rel="noopener" title="Documentation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
Docs
</a>
<a href="/app">Open the Player &rarr;</a>
</nav>
</header>
<p style="margin: 0 0 16px; font-size: 0.9em; color: #888">
Admin &amp; Setup console: migration, settings, accounts, and diagnostics.
</p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">
Overview
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">
1. Settings
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">
2. Devices
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">
3. Data Sync
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">
4. Migration
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">
5. Interactions & Events
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
6. Local Account
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-health')">
7. Health
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-logs')">
8. Logs
</button>
</div>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>
This toolkit helps you keep your Bose SoundTouch speakers
functional even after the Bose Cloud shutdown in May 2026.
It emulates the necessary cloud services locally on your
network.
</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Speaker shell access</strong><br/>
The wizard talks to the speaker over one of two transports.
The <strong>Migration</strong> tab probes both automatically
and uses whichever your device exposes — you don't have to
choose manually.
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>
<strong>SSH</strong> (richest option — required for
the XML migration, the <code>/etc/resolv.conf</code>
DNS hook, and installing the local CA). Enable it by
creating an empty <code>remote_services</code> file
on a USB stick, inserting it into the speaker's
<strong>SERVICE</strong> port, and rebooting. Verify
on the Migration tab — <em>SSH</em> in the state
card's <em>Transports</em> row should show ✅
Reachable. Manual check:
<code
>ssh -oHostKeyAlgorithms=+ssh-rsa
root@&lt;SPEAKER-IP&gt;</code
>
(no password).
</li>
<li>
<strong>Telnet (Port 17000)</strong> — the SSH-less
fallback. Most SoundTouch firmware exposes a
diagnostic shell on TCP/17000 automatically, no
USB-stick setup required. Limited to HTTP migrations
(no CA install possible without SSH). The state card
surfaces this in the same <em>Transports</em> row.
</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the
<strong>Settings</strong> tab. Ensure the "Target
Domain" and "Proxy Domain" use an IP address or domain
name that is
<strong>accessible from your speakers</strong> (usually
the IP of this server on your local network). You can
also edit the Target URL directly from the Migration tab
with a <em>Save as default</em> button.
</li>
<li>
<strong>Discovery:</strong> Go to the
<strong>Devices</strong> tab to find your speakers on
the network. Ensure your speakers are powered on and
connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the
<strong>Data Sync</strong> tab, fetch your current
presets, recents, and sources. This step is critical to
ensure your local service has all your personalized data
before you disconnect from the Bose cloud.
</li>
<li>
<strong>Migration:</strong> In the
<strong>Migration</strong> tab the wizard offers a
one-click <strong>Apply Suggested Plan</strong> that
picks the right recipe for your speaker (XML over SSH
when SSH is available, telnet URL flip otherwise). For
mix-and-match across the three independent axes — URL
flip transport, DNS interception, CA install — expand
<em>Customize this migration</em>. A visible pre-flight
check runs before any backend operation touches the
speaker.
</li>
<li>
<strong>Verification:</strong> After migration and
reboot, your speaker will communicate with this toolkit
instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any
migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-SAFETY/" target="_blank">Professional
Migration & Safety Guide</a>. The toolkit automatically creates backups, but understanding the process
is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/" target="_blank">Cloud
Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/" target="_blank">CLI
Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px">
<strong>Note:</strong> These URLs must be
<strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network
(e.g., <code>http://192.0.2.100:8000</code>) rather than
<code>localhost</code>.
</p>
<div style="margin-bottom: 20px">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div>
<div style="margin-bottom: 20px">
<label>HTTPS URL:</label>
<code id="https-url-effective"></code>
<span id="https-url-effective-note" style="font-size: 0.8em; color: #666"></span>
<div style="margin-top: 6px">
<span class="info-toggle" onclick="toggleInfo('https-url-override-info')">Advanced: override HTTPS URL ⓘ</span>
<div id="https-url-override-info" class="info-details">
<p style="font-size: 0.85em; color: #666; margin: 4px 0">
AfterTouch also serves an HTTPS endpoint (used for the
DNS-based redirect, music-service login, and certificate
trust). By default it follows the Target Domain above
(same host, on the HTTPS port), so you normally don't
set anything here. Provide an override only when a
reverse proxy serves HTTPS on a different host or port.
Leave it empty to go back to deriving it automatically.
Takes effect after saving.
</p>
<input type="text" id="https-url-override" placeholder="https://host:8443 (empty = derive)" style="width: 300px"/>
</div>
</div>
</div>
<div style="margin-bottom: 20px">
<label for="default-landing">Landing page (<code>/</code>):</label>
<select id="default-landing" style="margin-left: 4px">
<option value="chooser">Chooser (pick Player or Admin)</option>
<option value="app">Go straight to the Player</option>
<option value="admin">Go straight to Admin &amp; Setup</option>
</select>
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
What a browser sees at the root URL. The Player and Admin pages
stay reachable at <code>/app</code> and <code>/admin</code> either way.
</div>
</div>
<div style="margin-bottom: 20px">
<strong>TLS extra hosts:</strong>
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')"></span>
<div id="tls-extra-hosts-info" class="info-details">
<strong>When you need this:</strong> rarely. The TLS
certificate already covers AfterTouch's configured
server URL, HTTPS URL, and the host's own name. Add
entries here only when a speaker can't reach
AfterTouch over TLS — typical symptoms include
presets resetting on reboot, the BoseApp showing
the speaker as offline, or
<code>CURLE_SSL_CACERT (60)</code> in the speaker
syslog.<br/>
<strong>How to tell:</strong> open the
<strong>Health tab</strong> and look for
<code>speaker_marge_url</code> warnings. Each
warning names the host a speaker is pointing at;
clicking the <em>Add &lt;host&gt; to TLS hosts</em>
QuickFix fills this list for you. If that check is
clean, this list can stay empty.<br/>
<strong>Manual path:</strong> add one host per line
and save. The TLS certificate is regenerated at
startup from the merged list of
<code>--server-url</code> host,
<code>--https-server-url</code> host, the system
hostname, any <code>--tls-extra-host</code> /
<code>TLS_EXTRA_HOST</code> CLI/env values, and the
hosts persisted here. CLI/env wins over persisted
on overlap.<br/>
<strong>Applying changes requires a service
restart.</strong>
</div>
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
Usually empty. Add a host here only if the
<a href="#" onclick="openTab(null, 'tab-health'); return false;">Health tab</a>
flags a <code>speaker_marge_url</code> warning — or use
the one-click QuickFix on that warning to fill it for you.
</div>
<div style="margin-top: 5px">
<textarea
id="tls-extra-hosts"
placeholder="One host per line, e.g. 192.0.2.10 or aftertouch.lan"
style="width: 360px; height: 84px; font-family: monospace; font-size: 0.9em;"
></textarea>
</div>
<div id="tls-san-effective" style="font-size: 0.8em; color: #666; margin-top: 4px;"></div>
</div>
<div style="margin-bottom: 20px">
<strong>Device Discovery:</strong>
<div style="margin-top: 5px">
<label style="display: block; margin-bottom: 5px">
<input type="checkbox" id="discovery-enabled"/> Enable Periodic Discovery
</label>
<div style="margin-left: 20px">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
</div>
</div>
</div>
<div style="margin-bottom: 20px">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px">
<label style="display: block; margin-bottom: 5px">
<input type="checkbox" id="dns-enabled"/> Enable
DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px">
<label for="dns-upstream">Upstream DNS:</label>
<input
type="text"
id="dns-upstream"
placeholder="Default: system nameservers"
style="width: 200px"
/>
<span
class="info-toggle"
onclick="toggleInfo('dns-upstream-info')"
></span
>
<div id="dns-upstream-info" class="info-details">
Optional: comma-separated list of DNS servers
(e.g., <code>1.1.1.1, 8.8.8.8</code>).<br/>
If empty, AfterTouch defaults to the system
nameservers (e.g. from
<code>/etc/resolv.conf</code>).<br/>
<div
id="dns-current-upstream"
style="margin-top: 5px; font-weight: bold"
></div>
</div>
</div>
<div style="margin-left: 20px">
<label for="dns-bind">DNS Bind Address:</label>
<input
type="text"
id="dns-bind"
placeholder=":53"
style="width: 100px"
/>
<span
style="
font-size: 0.8em;
color: #666;
margin-left: 5px;
"
>(e.g., :53 or 0.0.0.0:53.
<strong>Port 53</strong> is required for actual
migration)</span
>
</div>
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Integrations:</strong>
<div style="margin-top: 8px">
<details class="integration-panel" id="panel-spotify">
<summary class="integration-summary">
<span class="integration-name">Spotify</span>
<span class="integration-badge" id="spotify-badge"></span>
</summary>
<div class="integration-body">
<div style="margin-bottom: 8px">
<label for="spotify-client-id" style="display: inline-block; width: 130px">Client ID:</label>
<input type="text" id="spotify-client-id" placeholder="32-char hex string" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="spotify-client-secret" style="display: inline-block; width: 130px">Client Secret:</label>
<input type="password" id="spotify-client-secret" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="spotify-redirect-uri" style="display: inline-block; width: 130px">Redirect URI:</label>
<input type="text" id="spotify-redirect-uri" placeholder="<your-service-url>/mgmt/spotify/callback" style="width: 280px"/>
</div>
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em">Checking configuration...</div>
</div>
</details>
<details class="integration-panel" id="panel-amazon">
<summary class="integration-summary">
<span class="integration-name">Amazon Music</span>
<span class="integration-badge" id="amazon-badge"></span>
</summary>
<div class="integration-body">
<div style="margin-bottom: 8px">
<label for="amazon-client-id" style="display: inline-block; width: 130px">Client ID:</label>
<input type="text" id="amazon-client-id" placeholder="amzn1.application-oa2-client..." style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="amazon-client-secret" style="display: inline-block; width: 130px">Client Secret:</label>
<input type="password" id="amazon-client-secret" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="amazon-redirect-uri" style="display: inline-block; width: 130px">Redirect URI:</label>
<input type="text" id="amazon-redirect-uri" placeholder="<your-service-url>/mgmt/amazon/callback" style="width: 280px"/>
</div>
<div id="amazon-config-status" style="margin-top: 5px; font-size: 0.9em">Checking configuration...</div>
</div>
</details>
<details class="integration-panel" id="panel-tts">
<summary class="integration-summary">
<span class="integration-name">Google Cloud TTS</span>
<span class="integration-badge" id="tts-badge"></span>
</summary>
<div class="integration-body">
<div style="font-size: 0.85em; color: #555; max-width: 600px; margin-bottom: 8px">
Text-to-speech for speaker announcements. The <em>translate</em> provider needs no
credentials; <em>google-cloud</em> needs an API key. Either way, an app key is required to
play on the speaker (see the <strong>Speaker endpoint</strong> docs).
</div>
<div style="margin-bottom: 8px">
<label for="tts-provider" style="display: inline-block; width: 130px">Provider:</label>
<select id="tts-provider" style="width: 285px">
<option value="translate">Google Translate (no credentials)</option>
<option value="google-cloud">Google Cloud TTS (API key)</option>
</select>
</div>
<div style="margin-bottom: 8px">
<label for="tts-google-api-key" style="display: inline-block; width: 130px">Google API Key:</label>
<input type="password" id="tts-google-api-key" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="tts-app-key" style="display: inline-block; width: 130px">Speaker app_key:</label>
<input type="password" id="tts-app-key" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="tts-language" style="display: inline-block; width: 130px">Default language:</label>
<input type="text" id="tts-language" placeholder="EN (translate) or en-US (google-cloud)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="tts-voice" style="display: inline-block; width: 130px">Default voice:</label>
<input type="text" id="tts-voice" placeholder="e.g. en-US-Neural2-C (google-cloud only)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="tts-volume" style="display: inline-block; width: 130px">Default volume:</label>
<input type="number" id="tts-volume" min="0" max="100" placeholder="0 = keep current" style="width: 100px"/>
</div>
<div id="tts-config-status" style="margin-top: 5px; font-size: 0.9em">Checking configuration...</div>
</div>
</details>
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Logging:</strong>
<div style="margin-top: 5px">
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="logging-redact"
onchange="updateLoggingSettings()"/> Redact
Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="logging-log-body"
onchange="updateLoggingSettings()"/> Log
Bodies</label>
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="logging-record"
onchange="updateLoggingSettings()"/> Record
Interactions<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span></label>
<div style="margin-left: 20px; margin-top: 10px">
<label for="internal-paths">Internal Paths (skip recording for these patterns):</label><br/>
<textarea
id="internal-paths"
rows="2"
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
placeholder="/setup/*&#10;/web/*"
></textarea>
<div
style="
font-size: 0.8em;
color: #666;
margin-top: 2px;
"
>
Requests matching these patterns will be
excluded from recording. Use one pattern per
line.
</div>
</div>
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Root CA Certificate:</strong>
<div style="margin-top: 8px; font-size: 0.9em; color: #555; max-width: 600px">
Import this certificate into your OS or browser trust store to
trust HTTPS connections to this AfterTouch server from other
clients (e.g. curl, Python scripts, browsers).
</div>
<div style="margin-top: 8px">
<a href="/setup/ca.crt" download="soundtouch-ca.crt">
<button type="button">Download CA Certificate</button>
</a>
</div>
</div>
<div style="margin-bottom: 20px">
<button onclick="updateSettings()">Save Settings</button>
<span
id="settings-status"
style="margin-left: 10px; font-size: 0.9em"
></span>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<h2>
Known Devices
<span
id="discovery-indicator"
style="
font-size: 0.5em;
vertical-align: middle;
display: none;
"
>🔍 Scanning...</span
>
</h2>
<div
id="spotify-status-header"
style="
background: #f0f0f0;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 10px;
"
>
Spotify:
<span
id="spotify-account-name"
style="font-weight: bold"
>Not Linked</span
>
<button
id="link-spotify-btn"
onclick="linkSpotify()"
style="
font-size: 0.8em;
padding: 2px 8px;
background: #1db954;
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
"
>
Link Account
</button>
</div>
</div>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px">
<button onclick="triggerDiscovery()">Scan Again</button>
<input
type="text"
id="add-manual-ip"
placeholder="Manual IP (e.g. 192.0.2.100)"
style="margin-left: 20px; padding: 4px"
/>
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>
Before migrating, fetch your presets, recents, and
configured sources from the device to ensure they are
available locally.
</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none">
<h3>Sync Results</h3>
<div
id="sync-log"
style="
font-family: monospace;
background: #f4f4f4;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
"
></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select
id="migration-device-list"
onchange="showSummary(this.value)"
>
<option value="">-- Select a device --</option>
</select>
<button
type="button"
id="migration-refresh-btn"
onclick="refreshSummary()"
title="Reload summary for the selected device"
aria-label="Reload summary"
style="margin-left: 6px; padding: 2px 8px; font-size: 1em; line-height: 1; cursor: pointer"
>&#x21bb;</button>
</div>
<div id="status" class="status"></div>
<div
id="command-output-box"
class="summary-box"
style="display: none; background-color: #f0f0f0"
>
<h3>Command Output</h3>
<div
id="command-output"
style="
font-family: monospace;
white-space: pre-wrap;
font-size: 0.85em;
max-height: 300px;
overflow-y: auto;
padding: 10px;
border: 1px solid #ccc;
background: #fff;
"
></div>
</div>
<div
id="migration-summary"
class="summary-box"
style="display: none"
>
<h3>
Migration Summary for
<span id="summary-device-display"></span>
</h3>
<input type="hidden" id="summary-device-id"/>
<p>Migration Status: <span id="migration-status"></span></p>
<div
id="migration-state-card"
style="margin: 10px 0 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
>
<div style="margin-bottom: 12px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Transports</h4>
<div style="display: flex; gap: 24px; flex-wrap: wrap; padding-left: 4px">
<div>
<strong>SSH:</strong> <span id="state-ssh"></span>
</div>
<div>
<strong>Telnet (Port 17000):</strong> <span id="state-telnet"></span>
<span id="state-telnet-banner" style="font-size: 0.85em; color: #666; margin-left: 4px"></span>
</div>
</div>
<div
id="state-telnet-error"
style="display: none; margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-left: 3px solid #ef6c00; font-size: 0.85em; color: #5d4037"
></div>
</div>
<div style="margin-bottom: 12px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Migration State</h4>
<table style="width: 100%; border-collapse: collapse">
<tbody>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">URL Configuration</td>
<td id="state-url" style="padding: 6px 8px; vertical-align: top"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">DNS Interception</td>
<td id="state-dns" style="padding: 6px 8px; vertical-align: top"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">CA / TLS</td>
<td id="state-ca" style="padding: 6px 8px; vertical-align: top">
<span id="state-ca-line"></span>
<span style="margin-left: 12px; white-space: nowrap">
<button
id="trust-ca-btn"
type="button"
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
>Trust CA Now</button>
<a
href="/setup/ca.crt"
download="soundtouch-ca.crt"
style="margin-left: 6px; font-size: 0.85em"
title="Download CA cert to import into other clients"
>Download CA cert</a>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Preconditions</h4>
<table style="width: 100%; border-collapse: collapse">
<tbody>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; width: 170px; color: #555" title="The remote_services file controls whether SSH is available after reboot">SSH (remote_services)</td>
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; color: #555">Account paired</td>
<td id="state-paired" style="padding: 4px 8px"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; color: #555">XML config backup</td>
<td id="state-backup" style="padding: 4px 8px"></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pre-flight panel: appears when the user clicks Apply,
runs the configured checks live, then auto-proceeds on
success or surfaces failures with override buttons. -->
<div
id="apply-preflight-panel"
style="display: none; margin: 12px 0; padding: 12px; border: 1px solid #2196f3; background: #e3f2fd; border-radius: 4px"
>
<h4 style="margin: 0 0 8px 0">Pre-flight checks</h4>
<ul
id="apply-preflight-list"
style="list-style: none; padding-left: 0; margin: 0; font-family: monospace; font-size: 0.9em"
></ul>
<div id="apply-preflight-summary" style="margin-top: 8px; font-weight: bold"></div>
<div id="apply-preflight-actions" style="margin-top: 10px"></div>
</div>
<div
id="preflight-warnings"
style="display: none; margin: 10px 0; padding: 8px 12px; background-color: #fff8e1; border-left: 4px solid #ffb300; font-size: 0.9em"
>
<strong>Cross-check warnings:</strong>
<ul id="preflight-warnings-list" style="margin: 4px 0 0 1em; padding: 0"></ul>
</div>
<div
id="connection-test"
style="
margin: 15px 0;
padding: 10px;
border: 1px solid #ddd;
background-color: #eefbff;
"
>
<strong>HTTPS Connection Test:</strong><br/>
<span style="font-size: 0.85em; color: #555"
>Verify the device can reach the server over
HTTPS.</span
>
<div style="margin-top: 10px">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px">
<button
id="test-connection-explicit-btn"
style="
background-color: #607d8b;
color: white;
border: none;
padding: 5px 10px;
font-size: 0.9em;
"
>
Test with Explicit CA.crt
</button>
<button
id="test-connection-trusted-btn"
style="
background-color: #607d8b;
color: white;
border: none;
padding: 5px 10px;
font-size: 0.9em;
"
>
Test with Shared Trust Store
</button>
</div>
<div
id="test-result"
style="
margin-top: 10px;
display: none;
padding: 10px;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
font-size: 0.85em;
max-height: 200px;
overflow-y: auto;
"
></div>
</div>
<div
id="dns-redirection-test"
style="
margin: 15px 0;
padding: 10px;
border: 1px solid #ddd;
background-color: #e6ffed;
display: none;
"
>
<strong>Preliminary DNS Test:</strong><br/>
<span style="font-size: 0.85em; color: #555"
>Verify the device can resolve domains via the
AfterTouch DNS server.</span
>
<div style="margin-top: 10px">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px">
<button
id="test-dns-btn"
style="
background-color: #28a745;
color: white;
border: none;
padding: 5px 10px;
font-size: 0.9em;
"
>
Test DNS Redirection
</button>
</div>
<div
id="dns-test-result"
style="
margin-top: 10px;
display: none;
padding: 10px;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
font-size: 0.85em;
max-height: 200px;
overflow-y: auto;
"
></div>
</div>
<div
id="migration-plan-card"
style="margin: 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
>
<h3 style="margin-top: 0">Plan</h3>
<div style="margin-bottom: 14px">
<label for="plan-target-url" style="font-weight: bold">Target service URL:</label>
<div style="margin-top: 4px">
<input
type="text"
id="plan-target-url"
oninput="onPlanTargetURLChange()"
style="width: 320px; font-family: monospace"
placeholder="http://192.168.x.x:8000"
/>
<button
type="button"
id="plan-save-default-btn"
onclick="saveTargetURLAsDefault()"
style="margin-left: 6px"
>Save as default</button>
</div>
<div id="plan-target-saved" style="font-size: 0.85em; color: #666; margin-top: 4px"></div>
</div>
<div style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Capabilities</h4>
<div style="font-size: 0.9em; line-height: 1.7">
<div>This speaker exposes: <span id="plan-detected"></span></div>
<div>AfterTouch can offer: <span id="plan-possible"></span></div>
</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 style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Account pairing</h4>
<p id="plan-pair-current" style="margin: 0 0 6px 0; font-size: 0.85em; color: #555"></p>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
<label for="plan-pair-id">Account ID:</label>
<input
type="text"
id="plan-pair-id"
maxlength="7"
pattern="[0-9]{7}"
placeholder="1234567"
style="font-family: monospace; width: 8em"
oninput="onPlanPairIDChange()"
/>
<button type="button" onclick="generatePlanAccountID()" style="font-size: 0.85em">Generate</button>
<select id="plan-pair-existing" onchange="onPlanPairPick()" style="font-size: 0.85em">
<option value="">— pick from datastore —</option>
</select>
</div>
<div id="plan-pair-status" style="margin-top: 4px; font-size: 0.85em; color: #666"></div>
</div>
<div>
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Suggested plan</h4>
<div
id="plan-suggestion"
style="border: 1px solid #c8e6c9; background: #f1f8e9; padding: 10px 12px; border-radius: 3px"
>
<div id="plan-suggestion-summary" style="font-weight: bold; margin-bottom: 4px"></div>
<ul id="plan-suggestion-steps" style="margin: 4px 0 8px 1.2em; padding: 0; font-size: 0.9em"></ul>
<button
type="button"
id="plan-preflight-btn"
onclick="preflightSuggestedPlan()"
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
style="font-size: 0.95em; margin-right: 6px"
>Pre-flight</button>
<button
type="button"
id="plan-apply-btn"
onclick="applySuggestedPlan()"
style="font-size: 0.95em"
>Apply Suggested Plan</button>
<span id="plan-apply-status" style="margin-left: 10px; font-size: 0.85em"></span>
</div>
</div>
</div>
<details style="margin: 16px 0">
<summary style="cursor: pointer; font-weight: bold">Customize this migration</summary>
<div
id="customize-form"
style="margin: 15px 0; padding: 12px; border: 1px solid #ddd; background-color: #f9f9f9; border-radius: 4px"
>
<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="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-preflight-btn"
onclick="preflightCustomPlan()"
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
style="padding: 8px 14px; font-size: 0.95em; margin-right: 6px"
>Pre-flight</button>
<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
id="telnet-method-pane"
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #f0f7ff;"
>
<h4 style="margin-top: 0">Telnet Migration (Port 17000)</h4>
<p style="margin: 5px 0">
Drives the speaker's diagnostic shell over TCP/17000.
Requires no SSH access. Works on most ST 10/20/300 and
Wave III/IV firmware (27.0.6.x).
</p>
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
Limitation: HTTP-only redirection — telnet has no way to
install a custom CA. If you need end-to-end TLS, use the
XML or DNS method instead.
</p>
</div>
<!-- XML diff pair: Current Config | Planned Config. Shown
when URL flip = xml in the Customize form. -->
<div class="diff-container" id="xml-diff-row" style="display: none">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header"
>Current Config (on Speaker)</span
>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header"
>Planned Config (AfterTouch)</span
>
<pre id="planned-config"></pre>
<div
id="resolve-ip-error"
style="display: none; margin-top: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404;"
>
⚠️ <strong>Hostname resolution warning:</strong>
<span id="resolve-ip-error-msg"></span>
<br/>
The planned IP shown above may be incorrect.
Migration methods that write IPs to the device
(hosts, resolv.conf) will refuse to proceed until
the hostname can be resolved from the device itself.
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
</div>
</div>
</div>
<!-- Resolv diff pair: Current /etc/resolv.conf | Planned hook.
Shown when DNS = resolv in the Customize form. -->
<div class="diff-container" id="resolv-diff-row" style="display: none; margin-top: 12px">
<div id="current-resolv-pane" class="diff-pane">
<span class="config-header"
>Current /etc/resolv.conf</span
>
<pre id="current-resolv-content"></pre>
</div>
<div id="planned-resolv-pane" class="diff-pane">
<span class="config-header"
>Planned /etc/resolv.conf Hook</span
>
<pre id="planned-resolv"></pre>
<div
id="resolv-note"
style="
margin-top: 10px;
font-size: 0.9em;
color: #666;
"
>
<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.
</div>
</div>
</div>
<div style="margin-top: 15px">
<button
id="revert-migrate-btn"
style="
background-color: #ff9800;
color: white;
border: none;
padding: 10px 20px;
display: none;
"
>
Revert to Defaults
</button>
<button
id="reboot-speaker-btn"
style="
background-color: #607d8b;
color: white;
border: none;
padding: 10px 20px;
"
>
Reboot Speaker
</button>
<button
id="ensure-remote-btn"
style="
background-color: #2196f3;
color: white;
border: none;
padding: 10px 20px;
"
>
Enable SSH (Persist remote_services)
</button>
<button
id="remove-remote-btn"
title="Removes the remote_services file — SSH will be disabled after the next reboot"
style="
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
"
>
Disable SSH (Remove remote_services)
</button>
<button
onclick="
document.getElementById(
'migration-summary',
).style.display = 'none'
"
style="padding: 10px 20px"
>
Cancel
</button>
</div>
</details>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>
Analysis of traffic handled by this service (self), proxied
to Bose (upstream), and internal device events (telemetry).
</p>
<div id="interaction-stats-container" class="summary-box">
<div
style="
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 15px;
"
>
<p style="margin: 0">
Total Requests:
<strong id="total-requests">0</strong>
</p>
<button onclick="fetchInteractionStats()">
Refresh Stats
</button>
<div style="margin-left: 10px">
<button onclick="showDeviceEvents()">
View App/Device Events
</button>
</div>
<div style="margin-left: auto; text-align: right">
<button
onclick="cleanupSessions()"
class="btn-danger"
>
Cleanup old sessions
</button>
<div
style="
font-size: 0.75em;
color: #666;
margin-top: 3px;
"
>
Keeps only the 10 most recent sessions
</div>
</div>
</div>
<div style="display: flex; gap: 20px">
<div
style="
flex: 1;
border-right: 1px solid #eee;
padding-right: 20px;
"
>
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2">
<h3>Sessions</h3>
<div
id="stats-by-session-container"
style="
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
padding: 5px;
border-radius: 4px;
"
>
<ul
id="stats-by-session"
class="stats-list"
></ul>
</div>
</div>
</div>
</div>
<div
id="browse-recordings"
class="summary-box"
style="margin-top: 20px"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
"
>
<h3 style="margin: 0">Browse Recordings</h3>
</div>
<div
style="
margin-bottom: 15px;
display: flex;
gap: 15px;
align-items: center;
background: #f9f9f9;
padding: 10px;
border-radius: 4px;
"
>
<div>
<label for="filter-session">Session:</label>
<select
id="filter-session"
onchange="fetchInteractions()"
>
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select
id="filter-category"
onchange="fetchInteractions()"
>
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">
Upstream (Bose)
</option>
</select>
</div>
<div>
<label for="filter-since"
>Since (YYYY-MM-DD HH:mm:ss):</label
>
<input
type="text"
id="filter-since"
placeholder="e.g. 2026-02-15 15:00:00"
size="25"
onchange="fetchInteractions()"
/>
</div>
<button onclick="fetchInteractions()">
Apply Filters
</button>
</div>
<div
id="interactions-list-container"
style="max-height: 400px; overflow-y: auto"
>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr
style="
text-align: left;
border-bottom: 2px solid #eee;
"
>
<th style="padding: 8px">#</th>
<th style="padding: 8px">Time</th>
<th style="padding: 8px">Method</th>
<th style="padding: 8px">Path</th>
<th style="padding: 8px">Status</th>
<th style="padding: 8px">Category</th>
<th style="padding: 8px">Event Details</th>
<th style="padding: 8px">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr>
<td
colspan="8"
style="
padding: 20px;
text-align: center;
color: #666;
"
>
No interactions found.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
id="dns-discoveries"
class="summary-box"
style="margin-top: 20px"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
"
>
<h3 style="margin: 0">DNS Discoveries</h3>
<div style="display: flex; gap: 10px">
<button
onclick="downloadDNSDiscoveries()"
class="btn-info"
>
Download JSON
</button>
<button
onclick="clearDNSDiscoveries()"
class="btn-danger"
>
Clear DNS Logs
</button>
</div>
</div>
<p style="font-size: 0.85em; color: #666">
Hosts discovered via the AfterTouch DNS server. "Self"
means the domain was intercepted and redirected to this
service.
</p>
<div
id="dns-discoveries-list-container"
style="max-height: 400px; overflow-y: auto"
>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr
style="
text-align: left;
border-bottom: 2px solid #eee;
"
>
<th style="padding: 8px">Hostname</th>
<th style="padding: 8px">Last Seen</th>
<th
style="padding: 8px; text-align: center"
>
Queries
</th>
<th
style="padding: 8px; text-align: center"
>
Bose?
</th>
<th style="padding: 8px">Category</th>
<th style="padding: 8px">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr>
<td
colspan="6"
style="
padding: 20px;
text-align: center;
color: #666;
"
>
No DNS discoveries found.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
id="interaction-viewer"
class="summary-box"
style="
margin-top: 20px;
display: none;
background: #2b2b2b;
color: #a9b7c6;
"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
"
>
<h3 style="margin: 0; color: #fff">
Recording Viewer:
<span id="viewer-filename" style="font-weight: normal; font-size: 0.8em"></span>
</h3>
<button onclick="document.getElementById('interaction-viewer').style.display = 'none'"
style="background: #444; color: #fff; border: 1px solid #666;">
Close
</button>
</div>
<pre
id="interaction-content"
style="
white-space: pre-wrap;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
margin: 0;
padding: 10px;
overflow-x: auto;
max-height: 600px;
"
></pre>
</div>
<!-- Device Events Overlay -->
<div
id="device-events-overlay"
class="summary-box"
style="
margin-top: 20px;
display: none;
background: #fdfdfd;
border: 1px solid #ddd;
"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
"
>
<h3 style="margin: 0">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display = 'none'"
style="margin-left: 10px">Close
</button>
</div>
</div>
<div
id="events-list-container"
style="max-height: 400px; overflow-y: auto"
>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr
style="
text-align: left;
border-bottom: 2px solid #eee;
"
>
<th style="padding: 8px">Time</th>
<th style="padding: 8px">Type</th>
<th style="padding: 8px">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr>
<td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view
events.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Tab 6: Local Account -->
<div id="tab-account" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Local Account Details</h2>
<div style="display: flex; gap: 10px; align-items: center;">
<label for="account-selector">Account:</label>
<select id="account-selector" onchange="fetchAccountDetails(this.value)">
<option value="default">Default</option>
</select>
<button onclick="fetchAccountDetails(document.getElementById('account-selector').value)">Refresh</button>
</div>
</div>
<div id="account-info-container" class="summary-box">
<h3>Account Overview</h3>
<div id="account-metadata">Loading...</div>
</div>
<div id="spotify-registration-container" class="summary-box" style="margin-top: 20px;">
<h3>Spotify Integration</h3>
<p style="font-size: 0.9em; color: #555;">
Register a new Spotify source for this local account. This mimics the official SoundTouch app flow:
</p>
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
<li>Exchange OAuth code for a Bose-mediated token.</li>
<li>Register the source in the local Marge cloud profile.</li>
</ol>
<button id="connect-spotify-account-btn" onclick="connectSpotifyToAccount()" style="background: #1db954; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Connect Spotify to this Account
</button>
<div id="spotify-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
</div>
<div id="amazon-registration-container" class="summary-box" style="margin-top: 20px;">
<h3>Amazon Music Integration</h3>
<p style="font-size: 0.9em; color: #555;">
Register a new Amazon Music source for this local account. This mimics the official SoundTouch app flow:
</p>
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
<li>Exchange OAuth code for an Amazon LWA token.</li>
<li>Register the source in the local Marge cloud profile.</li>
</ol>
<button id="connect-amazon-account-btn" onclick="connectAmazonToAccount()" style="background: #ff9900; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Connect Amazon Music to this Account
</button>
<div id="amazon-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
</div>
<div id="account-devices-container">
<h3>Connected Devices</h3>
<div id="account-devices-list">Select an account to view devices.</div>
</div>
</div>
<!-- Tab 7: Health -->
<div id="tab-health" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="margin: 0;">Service Health Checks</h2>
<button onclick="fetchHealth()">Refresh</button>
</div>
<p style="font-size: 0.9em; color: #555;">
Runs a set of checks against the local datastore and flags
findings that may need attention. Quick fixes are offered
for issues the service knows how to remediate.
</p>
<div style="margin: 12px 0 16px 0; padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
<span style="font-size: 0.9em; color: #555;">Share a diagnostic snapshot with the project maintainer.</span>
</div>
<details style="font-size: 0.85em; color: #555; margin-top: 8px;">
<summary style="cursor: pointer;">What does the diagnostic report contain?</summary>
<ul style="margin: 6px 0 0 0; padding-left: 1.4em;">
<li>Health check results and current device state</li>
<li>Device XML files from the datastore (no passwords)</li>
<li>HTTP response samples from speaker endpoints</li>
<li>System files: CA bundle, DNS resolver config</li>
<li>Speaker CA bundle and kernel log (via SSH, if reachable)</li>
<li>Service log tail</li>
<li>Settings file with secrets redacted</li>
</ul>
<p style="margin: 6px 0 0 0;">The archive is encrypted with the project maintainer's public key — only they can open it.</p>
</details>
<div id="health-diagnostic-status" style="font-size: 0.85em; margin-top: 8px;"></div>
</div>
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
<div id="health-findings">Loading…</div>
</div>
<!-- Tab 8: Logs -->
<div id="tab-logs" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="margin: 0;">Service Logs</h2>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="text" id="logs-filter" placeholder="Filter (substring, case-insensitive)" style="padding: 4px 8px; min-width: 260px;"/>
<label style="font-size: 0.9em;">
<input type="checkbox" id="logs-follow" checked/> Follow tail
</label>
</div>
</div>
<p style="font-size: 0.9em; color: #555;">
Live mirror of the service's stderr log. Stderr still
receives every line — this is a read-only view for
convenience.
</p>
<div id="logs-status" style="font-size: 0.8em; color: #888; margin-bottom: 6px;">Idle.</div>
<pre id="logs-view" style="background: #111; color: #ddd; padding: 10px; border-radius: 4px; max-height: 60vh; overflow-y: auto; font-size: 0.8em; line-height: 1.35; margin: 0; white-space: pre-wrap; word-break: break-all;"></pre>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer
style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>