Files

819 lines
36 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="UTF-8"/>
<title>AfterTouch (SoundTouch Toolkit)</title>
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml"/>
<link rel="stylesheet" href="/web/css/style.css"/>
<script src="/web/js/diff.min.js"></script>
</head>
<body>
<h1><img src="/web/img/favicon-braille.svg" alt="AfterTouch Logo" class="logo"/>AfterTouch</h1>
<p class="subtitle">
Bose SoundTouch Toolkit
</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-parity')">
6. Parity & Mirroring
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
7. Local Account
</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>🔌 Prerequisite: Enable SSH</strong><br/>
Migration requires SSH access. To enable it:
<ol class="list-compact">
<li>
Create an empty file named
<code>remote_services</code> on a USB stick.
</li>
<li>
Insert it into the speaker's
<strong>SERVICE</strong> port and reboot the
speaker.
</li>
</ol>
<strong>Verify connection:</strong>
<ul class="list-compact list-indented">
<li>
Use the <strong>Migration</strong> tab to select
your device and verify that
<em>SSH Connection</em> shows ✅ Success.
</li>
<li>
Or manually:
<code
>ssh -oHostKeyAlgorithms=+ssh-rsa
root@&lt;SPEAKER-IP&gt;</code
>
(no password).
</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).
</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, redirect your speaker to
this local service. We recommend the
<strong>XML Configuration</strong> method as it is
surgical and easily reversible.
</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/guides/MIGRATION-SAFETY.html" 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/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud
Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI
Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<div class="info-box safety-box mb-lg">
<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.168.1.100:8000</code>) rather than
<code>localhost</code>.
</div>
<div class="form-group">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000"/>
<span class="help-text">Standard services URL</span>
</div>
<div class="form-inline form-group">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" class="w-100"/>
<label><input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery</label>
</div>
<div class="form-group">
<strong>DNS Discovery:</strong>
<div class="mt-sm">
<label class="mb-sm visible">
<input type="checkbox" id="dns-enabled"/> Enable
DNS Discovery Server
</label>
<div class="ml-lg mb-sm">
<div class="form-inline">
<label for="dns-upstream">Upstream DNS:</label>
<input
type="text"
id="dns-upstream"
placeholder="Default: system nameservers"
class="w-200"
/>
<span
class="info-toggle"
onclick="toggleInfo('dns-upstream-info')"
></span
>
</div>
<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"
class="mt-sm fw-bold"
></div>
</div>
</div>
<div class="ml-lg">
<div class="form-inline">
<label for="dns-bind">DNS Bind Address:</label>
<input
type="text"
id="dns-bind"
placeholder=":53"
class="w-100"
/>
<span class="help-text">
(e.g., :53 or 0.0.0.0:53.
<strong>Port 53</strong> is required for actual
migration)
</span>
</div>
</div>
</div>
</div>
<div class="form-group">
<strong>Endpoint Mirroring:</strong>
<div class="mt-sm">
<label class="checkbox-label">
<input type="checkbox" id="mirror-enabled"/> Enable
Background Mirroring to Bose Cloud
</label>
<div class="ml-lg mb-sm">
<label class="checkbox-label">
<input type="checkbox" id="preferred-source-upstream"/>
Prefer Upstream Response for Mirrored Endpoints
</label>
<label for="mirror-endpoints">Mirror Endpoints (one per line, supports * wildcards):</label>
<textarea
id="mirror-endpoints"
rows="4"
placeholder="/streaming/account/*/device/*/recent&#10;/accounts/*/devices/*/presets/*"
></textarea>
<label for="skip-mirror-endpoints" class="mt-sm visible">Skip Mirror Endpoints (local only, one per line):</label>
<textarea
id="skip-mirror-endpoints"
rows="2"
placeholder="/oauth/device/*/music/musicprovider/*/token/cs3&#10;/oauth/device/*/music/musicprovider/*/token"
></textarea>
<div class="info-box safety-box mt-sm font-size-sm p-sm">
<strong>Note:</strong> Mirroring sends matching
requests (including full headers) to the
official Bose servers for parity comparison. If
<em>Redact Sensitive Data</em> is enabled in
Proxy Settings, credentials will be masked in
<strong>logs and recordings</strong>, but full
headers are always sent to Bose to ensure
service compatibility.
</div>
</div>
</div>
</div>
<div class="form-group">
<strong>Spotify Integration:</strong>
<div id="spotify-config-status" class="help-text mt-sm">
Checking configuration...
</div>
</div>
<div class="form-group">
<strong>Proxy Logging:</strong>
<div class="mt-sm">
<label class="checkbox-label"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"/> Redact Sensitive Headers</label>
<label class="checkbox-label"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"/> Log Bodies</label>
<label class="checkbox-label"><input type="checkbox" id="proxy-record" onchange="updateProxySettings()"/> Record Interactions <span class="help-text ml-sm visible">(View in <strong>5. Interactions</strong> tab)</span></label>
<div class="ml-lg mt-sm">
<label for="internal-paths">Internal Paths (skip recording for these patterns):</label>
<textarea
id="internal-paths"
rows="2"
placeholder="/setup/*&#10;/web/*"
></textarea>
<span class="help-text">
Requests matching these patterns will be
excluded from recording. Use one pattern per
line.
</span>
</div>
</div>
</div>
<div class="form-group mt-lg">
<button class="btn-primary" onclick="updateSettings()">Save Settings</button>
<span id="settings-status" class="status"></span>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<div class="flex-between">
<h2>
Known Devices
<span id="discovery-indicator" class="help-text hidden ml-lg">🔍 Scanning...</span>
</h2>
<div id="spotify-status-header" class="spotify-badge hidden">
Spotify:
<span id="spotify-account-name" class="fw-bold">Not Linked</span>
<button id="link-spotify-btn" class="badge bg-spotify" onclick="linkSpotify()">
Link Account
</button>
</div>
</div>
<div id="device-list">Loading devices...</div>
<div class="form-inline mt-lg">
<button class="btn-primary" onclick="triggerDiscovery()">Scan Again</button>
<div class="form-inline">
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" class="w-250"/>
<button onclick="addManualDevice()">Add Device</button>
</div>
</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 class="btn-primary" id="sync-now-btn" onclick="startSync()">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" class="mt-lg hidden">
<h3>Sync Results</h3>
<div id="sync-log" class="console-box"></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>
</div>
<div id="status" class="status"></div>
<div id="migration-fetch-status" class="status"></div>
<div id="command-output-box" class="summary-box hidden">
<h3>Command Output</h3>
<div id="command-output" class="console-box"></div>
</div>
<div
id="migration-summary"
class="summary-box hidden"
>
<div id="migration-summary-status" class="status"></div>
<h3>
Migration Summary for
<span id="summary-device-display"></span>
</h3>
<p>Migration Status: <span id="migration-status"></span></p>
<input type="hidden" id="summary-device-id"/>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" class="hidden">
Backup: ✅ Found .original config at
<code
>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code
>
<button onclick="toggleOriginalConfig()">
Show Original Config
</button>
</p>
<p id="no-original-config-status" class="hidden">
Backup: ❌ Not found
<button id="backup-config-btn">
Backup Config Now
</button>
</p>
<p>
Remote Services Enabled:
<span id="remote-services-status"></span>
<span
id="remote-services-found"
class="col-remote-addr"
></span>
</p>
<p>
AfterTouch Local Root CA Trusted:
<span id="ca-trust-status"></span>
<button id="trust-ca-btn" class="btn-secondary hidden ml-lg font-size-sm p-sm">
Trust CA Now
</button>
</p>
<div id="connection-test" class="test-connection-box">
<strong>HTTPS Connection Test:</strong>
<span class="help-text-sm">Verify the device can reach the server over HTTPS.</span>
<div class="mt-md">
URL: <code id="test-url"></code>
</div>
<div class="mt-md">
<button id="test-connection-explicit-btn" class="btn-secondary p-sm font-size-sm">
Test with Explicit CA.crt
</button>
<button id="test-connection-trusted-btn" class="btn-secondary p-sm font-size-sm">
Test with Shared Trust Store
</button>
</div>
<div id="test-result" class="console-box mt-md hidden max-h-200"></div>
</div>
<div id="hosts-redirection-test" class="test-connection-box test-connection-box-warning hidden">
<strong>Preliminary /etc/hosts Test:</strong>
<span class="help-text-sm">Verify the device's /etc/hosts mechanism before full migration.</span>
<div class="mt-md">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div class="mt-md">
<button id="test-hosts-btn" class="btn-warning p-sm font-size-sm">
Test Hosts Redirection
</button>
</div>
<div id="hosts-test-result" class="console-box mt-md hidden max-h-200"></div>
</div>
<div id="dns-redirection-test" class="test-connection-box test-connection-box-success hidden">
<strong>Preliminary DNS Test:</strong>
<span class="help-text-sm">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div class="mt-md">
Domain: <code>aftertouch.test</code>
</div>
<div class="mt-md">
<button id="test-dns-btn" class="btn-success p-sm font-size-sm">
Test DNS Redirection
</button>
</div>
<div id="dns-test-result" class="console-box mt-md hidden max-h-200"></div>
</div>
<div class="summary-box mb-md">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()" class="p-sm">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Redirect via DNS Hook)</option>
</select>
<div id="dns-port-warning" class="mt-sm status-error hidden"></div>
</div>
<div id="current-resolv-pane" class="mb-md hidden">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" class="mb-md hidden">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" class="mb-md hidden">
<h4>Service Implementations</h4>
<table>
<tr>
<th>Service</th>
<th>Original URL</th>
<th>Implementation</th>
</tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<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>
<div id="planned-hosts-pane" class="diff-pane hidden">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div class="help-text-sm mt-md">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane hidden">
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" class="help-text-sm mt-md">
<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 class="mt-md flex-row gap-sm flex-wrap">
<button id="confirm-migrate-btn" class="btn-success">Confirm Migration</button>
<button id="revert-migrate-btn" class="btn-warning hidden">Revert to Defaults</button>
<button id="reboot-speaker-btn" class="btn-secondary">Reboot Speaker</button>
<button id="ensure-remote-btn" class="btn-primary">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" class="btn-danger">Remove Persistent Remote Services</button>
<button onclick="hideStatus('migration-summary')">Cancel</button>
</div>
</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 class="flex-between mb-md">
<div class="flex-row">
<p class="m-0">
Total Requests:
<strong id="total-requests">0</strong>
</p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div class="text-right">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<span class="help-text-sm">Keeps only the 10 most recent sessions</span>
</div>
</div>
<div class="flex-row items-start">
<div class="flex-grow border-right pr-md">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div class="flex-2 ml-lg">
<h3>Sessions</h3>
<div id="stats-by-session-container" class="scroll-y max-h-200 border-p">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box mt-md">
<div class="flex-between mb-md">
<h3 class="m-0">Browse Recordings</h3>
</div>
<div class="filter-bar">
<div class="form-inline">
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()" class="w-auto">
<option value="">All Sessions</option>
</select>
</div>
<div class="form-inline">
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()" class="w-auto">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
<option value="mirror">Mirror (Bose)</option>
</select>
</div>
<div class="form-inline">
<label for="filter-since">Since:</label>
<input type="text" id="filter-since" placeholder="YYYY-MM-DD HH:mm:ss" class="w-200" onchange="fetchInteractions()" />
</div>
<button onclick="fetchInteractions()" class="btn-primary">Apply Filters</button>
</div>
<div id="interactions-list-container" class="scroll-y max-h-400">
<table>
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th>Category</th>
<th>Event Details</th>
<th>Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr>
<td colspan="8" class="p-lg text-center color-muted">
No interactions found.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="dns-discoveries" class="summary-box mt-md">
<div class="flex-between mb-md">
<h3 class="m-0">DNS Discoveries</h3>
<div class="flex-row">
<button onclick="downloadDNSDiscoveries()" class="btn-primary">Download JSON</button>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
</div>
<p class="help-text-sm">
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" class="scroll-y mt-md max-h-400">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Last Seen</th>
<th class="text-center">Queries</th>
<th class="text-center">Bose?</th>
<th>Category</th>
<th>Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr>
<td colspan="6" class="p-lg text-center color-muted">
No DNS discoveries found.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="viewer-box hidden">
<div class="flex-between mb-md">
<h3 class="m-0">
Recording Viewer:
<span id="viewer-filename" class="font-size-sm fw-normal"></span>
</h3>
<button onclick="hideStatus('interaction-viewer')">Close</button>
</div>
<pre id="interaction-content"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box mt-md hidden bg-light">
<div class="flex-between mb-md">
<h3 class="m-0">App & Device Events</h3>
<div class="flex-row">
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)" class="w-auto">
<option value="">-- Select Device --</option>
</select>
<button onclick="hideStatus('device-events-overlay')">Close</button>
</div>
</div>
<div id="events-list-container" class="scroll-y max-h-400">
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr>
<td colspan="3" class="p-lg text-center color-muted">
Select a device to view events.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Tab 6: Parity & Mirroring -->
<div id="tab-parity" class="tab-content">
<h2>Parity Analysis</h2>
<p>
Detection of discrepancies between AfterTouch local
responses and official Bose Cloud responses for mirrored
endpoints.
</p>
<div class="summary-box">
<div class="flex-between mb-md">
<h3 class="m-0">Parity Mismatches</h3>
<div class="flex-row">
<button onclick="fetchParityMismatches()">Refresh Mismatches</button>
<button onclick="clearParityMismatches()" class="btn-danger">Clear All Records</button>
</div>
</div>
<div id="parity-list-container" class="scroll-y max-h-500">
<table>
<thead>
<tr>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody id="parity-mismatches-list">
<tr>
<td colspan="5" class="p-lg text-center color-muted">
Loading mismatches...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="parity-diff-view" class="summary-box mt-md hidden">
<div class="flex-between mb-md">
<h3 class="m-0">
Mismatch Detail:
<span id="diff-path-display"></span>
</h3>
<button onclick="hideStatus('parity-diff-view')">Close Detail</button>
</div>
<div class="status-error mb-md visible">
<strong>Detection Reasons:</strong>
<ul id="diff-reasons-list" class="list-compact list-indented"></ul>
</div>
<div id="diff-size-warning" class="status-warning mb-md hidden">
⚠️ <strong>Large Payload:</strong> Rich diff highlighting is disabled to prevent a browser freeze. Showing raw comparison instead.
<button id="force-rich-diff-btn" class="btn-secondary ml-sm font-size-sm">Show Rich Diff Anyway</button>
</div>
<div class="diff-container-columns mt-md">
<div class="diff-pane">
<span class="config-header config-header-local">Local Response (AfterTouch)</span>
<div id="diff-local-meta" class="help-text-sm mb-md"></div>
<pre id="diff-local-body"></pre>
</div>
<div class="diff-pane">
<span class="config-header config-header-upstream">Upstream Response (Bose)</span>
<div id="diff-upstream-meta" class="help-text-sm mb-md"></div>
<pre id="diff-upstream-body"></pre>
</div>
</div>
</div>
</div>
<!-- Tab 7: Local Account -->
<div id="tab-account" class="tab-content">
<div class="flex-between mb-md">
<h2>Local Account Details</h2>
<div class="flex-row">
<label for="account-selector">Account:</label>
<select id="account-selector" onchange="fetchAccountDetails(this.value)" class="w-auto">
<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="account-devices-container" class="mt-md">
<h3>Connected Devices</h3>
<div id="account-devices-list" class="help-text">Select an account to view devices.</div>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer>
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>