[fix](trx-frontend-http): rebuild the background decode panel around one list
The panel listed the same bookmarks twice: a status card at the top naming
the selected ones and what each was doing, and a checklist below naming all
of them with the checkboxes. Neither list said which row in the other it
meant, so choosing what to decode and reading what was being decoded were two
passes over the same names. The checklist itself sat squeezed against the
right-hand edge, because the row it lives in sets `align-items: flex-end` and
an inline `flex-direction: column` turned that into "push everything right".
It is one list now. A row carries its checkbox, its name, its frequency,
mode and decoder, and its own state, so picking and watching happen in the
same place. What the rig can hear moves up beside the switch, where it
explains why a selected bookmark reads out of span, and the selection adds
itself up under the list. The states lose the ✓/△/✗ they carried next to a
dot that already said as much, and say what they mean: "Out of span",
"Scheduler has it", "Nobody listening", each with the reason on hover. The
empty list now says which of the two reasons it is empty for, and what to do.
Save was live from the moment the panel opened, with nothing to save; it now
offers itself only when something has changed.
Two races behind it, both of which left the panel useless rather than ugly:
* The rig was whatever the panel was handed at load. Loading before the rig
list arrived handed it null, and the next telling only came when the
operator switched rigs — so the panel stayed empty and silent. Every rig
list refresh now passes the rig on, and both panels ignore one they have.
* The settings panels are wired once the session is up, but their modules
import asynchronously and the wiring skipped whichever had not arrived.
A panel that missed it kept dead buttons for the rest of the session: no
filter, no Select All, no Save. Wiring runs again when the modules land.
Closes #52
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #58.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The background decode panel: one list that both picks the bookmarks and says
|
||||
// what each one is doing. It used to be two lists of the same bookmarks — one
|
||||
// to choose from, one to read state off — and it filled neither until the
|
||||
// operator switched rigs, because it took whatever rig it was handed at load
|
||||
// and that was nothing yet.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document */
|
||||
|
||||
const BOOKMARKS = [
|
||||
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
|
||||
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
|
||||
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
|
||||
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
|
||||
// Nothing that runs in the background can decode CW, so it must not be offered.
|
||||
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
|
||||
];
|
||||
|
||||
const backgroundDecode = {
|
||||
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
|
||||
status: {
|
||||
active_rig: true,
|
||||
center_hz: 14_100_000,
|
||||
sample_rate: 2_400_000,
|
||||
entries: [
|
||||
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
|
||||
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
|
||||
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const readRows = () => page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
|
||||
return rows.map((row) => ({
|
||||
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
|
||||
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
|
||||
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
|
||||
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
|
||||
}));
|
||||
});
|
||||
|
||||
const openPanel = async () => {
|
||||
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||
await page.waitForTimeout(1200);
|
||||
};
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 950 });
|
||||
await openPanel();
|
||||
|
||||
// Nothing was switched, clicked or reloaded: the panel asked the host which
|
||||
// rig it is on and filled itself.
|
||||
const rows = await readRows();
|
||||
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
|
||||
"the list does not hold the bookmarks a background channel can decode");
|
||||
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
|
||||
"the saved selection is not reflected in the list");
|
||||
|
||||
// Each row carries its own state, so there is no second list to consult.
|
||||
assert.deepEqual(rows.map((row) => row.state),
|
||||
["active", "out_of_span", "unselected", "handled_by_scheduler"],
|
||||
"the rows do not carry the state of the bookmark they name");
|
||||
assert.match(rows[0].stateText, /Decoding/);
|
||||
assert.match(rows[1].stateText, /Out of span/);
|
||||
|
||||
// What the rig can hear, and what the selection adds up to.
|
||||
const context = await page.evaluate(() => ({
|
||||
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
|
||||
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
|
||||
}));
|
||||
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
|
||||
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
|
||||
|
||||
// Save has nothing to do until something changes, and says so again once it
|
||||
// has been done.
|
||||
const saveState = () => page.evaluate(() => {
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
|
||||
});
|
||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
|
||||
|
||||
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
|
||||
await page.waitForTimeout(300);
|
||||
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
|
||||
|
||||
await page.locator("#background-decode-save-btn").click();
|
||||
await page.waitForTimeout(1000);
|
||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
|
||||
|
||||
// The selection reached the server and comes back on the next load.
|
||||
await openPanel();
|
||||
const saved = await readRows();
|
||||
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
|
||||
"the newly selected bookmark did not survive a reload");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
// With no bookmarks at all the panel says what to do about it rather than
|
||||
// showing an empty box.
|
||||
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
|
||||
const empty = await startBrowser(chromium);
|
||||
try {
|
||||
await empty.page.setViewportSize({ width: 1400, height: 950 });
|
||||
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||
await empty.page.waitForTimeout(2000);
|
||||
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||
await empty.page.waitForTimeout(1000);
|
||||
const text = await empty.page.evaluate(() =>
|
||||
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
|
||||
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
|
||||
const summary = await empty.page.evaluate(() =>
|
||||
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
|
||||
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
|
||||
} finally {
|
||||
await empty.browser.close();
|
||||
await emptyFixture.close();
|
||||
}
|
||||
|
||||
console.log("background decode panel tests passed");
|
||||
@@ -34,7 +34,14 @@ const DECODER_REGISTRY = [
|
||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
||||
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
|
||||
].map((decoder) => ({
|
||||
...decoder,
|
||||
// Which decoders a background channel can run, as the server's own registry
|
||||
// has it: the background decode panel offers a bookmark only if one of these
|
||||
// can decode it, so marking them all false left that panel with nothing.
|
||||
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
|
||||
bookmark_selectable: true,
|
||||
}));
|
||||
|
||||
const CONTENT_TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
@@ -132,6 +139,7 @@ export async function startWebFixture({
|
||||
mode = "FM",
|
||||
history = {},
|
||||
bookmarks = [],
|
||||
backgroundDecode = null,
|
||||
bandplan = {},
|
||||
bandplanEnabled = false,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
@@ -271,6 +279,34 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
// Background decode: the panel reads its config, its status, and the
|
||||
// bookmark list, and writes the config back.
|
||||
if (url.pathname.startsWith("/background-decode/")) {
|
||||
const isStatus = url.pathname.endsWith("/status");
|
||||
if (isStatus) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
const body = await new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.on("data", (chunk) => { raw += chunk; });
|
||||
request.on("end", () => resolve(raw));
|
||||
});
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (backgroundDecode) backgroundDecode.config = parsed;
|
||||
} catch { /* leave the config as it was */ }
|
||||
}
|
||||
if (request.method === "DELETE" && backgroundDecode) {
|
||||
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(backgroundDecode?.config
|
||||
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||
const remote = url.searchParams.get("remote");
|
||||
if (remote) {
|
||||
|
||||
Reference in New Issue
Block a user