-
Loading decode history…
-
Preparing recent decodes for the UI
+
+
+ Loading decode history…
+ Preparing recent decodes for the UI
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index e8e6f55d..d8a4b834 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -2209,6 +2209,74 @@ small { color: var(--text-muted); }
color: var(--text-muted);
}
+/* Decode history loads in the corner, not over the page. It was a full-screen
+ scrim: the operator could not read the waterfall, the decode panels or
+ anything else while a large history replayed, and the replay is exactly when
+ there is something to watch. */
+.history-progress {
+ position: fixed;
+ left: var(--space-4);
+ bottom: var(--space-4);
+ z-index: 120;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ width: min(20rem, calc(100vw - 2rem));
+ padding: 0.6rem 0.75rem 0.7rem;
+ border: 1px solid color-mix(in srgb, var(--border-light) 70%, transparent);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--card-bg) 94%, transparent);
+ box-shadow: 0 10px 24px color-mix(in srgb, var(--bg) 35%, transparent);
+ pointer-events: none;
+ transition: opacity var(--dur-base) var(--ease-standard),
+ visibility var(--dur-base) var(--ease-standard);
+}
+.history-progress.is-hidden {
+ opacity: 0;
+ visibility: hidden;
+}
+.history-progress-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+}
+.history-progress-title {
+ font-size: var(--fs-xs);
+ font-weight: 700;
+ color: var(--text-heading);
+}
+.history-progress-sub {
+ font-size: var(--fs-xs);
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+.history-progress-track {
+ height: 0.28rem;
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--border-light) 45%, transparent);
+ overflow: hidden;
+}
+.history-progress-bar {
+ display: block;
+ width: 0%;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--accent-green);
+ transition: width var(--dur-base) var(--ease-out);
+}
+/* Indeterminate while the payload is still on the wire. */
+.history-progress[data-phase="fetching"] .history-progress-bar {
+ width: 35%;
+ animation: trx-history-sweep 1.1s var(--ease-standard) infinite;
+}
+@keyframes trx-history-sweep {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(285%); }
+}
+@media (max-width: 760px) {
+ .history-progress { bottom: calc(5.9rem + env(safe-area-inset-bottom)); }
+}
.decode-history-overlay {
position: fixed;
inset: 0;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
index 306438c3..45843d2e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
@@ -712,6 +712,7 @@ const loadingSub = requiredElement("loading-sub");
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
+const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
@@ -880,10 +881,29 @@ function syncTopBarAccess() {
}
let overviewDrawPending = false;
-function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
+// Progress, in the corner. This used to dim the whole page behind a scrim,
+// which hid the waterfall and the decode panels for as long as the replay ran
+// — and a replay only happens when there is a backlog worth watching arrive.
+// `fraction` null leaves the bar indeterminate, for the part where the payload
+// is still on the wire and there is nothing to count yet.
+function setDecodeHistoryOverlayVisible(
+ visible: boolean,
+ title = "",
+ sub = "",
+ fraction: number | null = null,
+) {
if (!decodeHistoryOverlayEl) return;
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
+ if (decodeHistoryProgressBarEl) {
+ if (fraction == null) {
+ decodeHistoryOverlayEl.dataset.phase = "fetching";
+ decodeHistoryProgressBarEl.style.width = "";
+ } else {
+ delete decodeHistoryOverlayEl.dataset.phase;
+ decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
+ }
+ }
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
}
@@ -6494,36 +6514,44 @@ function connectDecode() {
let historySettled = false;
let historyWorkerDone = false;
let historyFallbackStarted = false;
+ let historyRetried = false;
let historyBatchDrainScheduled = false;
let historyTotal = 0;
let historyProcessed = 0;
const historyGroupQueue: DecodeHistoryGroup[] = [];
const liveBuffer: DecodeMessage[] = [];
- function flushLiveBuffer() {
+ // Live decodes wait behind the history so the panels stay in order. Letting
+ // them through is not the same as being finished, and conflating the two is
+ // what made a slow history disappear: the safety valve released the buffer
+ // and tore the worker down with it, so whatever had not arrived never did.
+ function releaseLiveBuffer() {
+ if (historySettled) return;
historySettled = true;
- terminateDecodeHistoryWorker();
- setDecodeHistoryReplayActive(false);
- setDecodeHistoryOverlayVisible(false);
for (const msg of liveBuffer) {
try { dispatchDecodeMessage(msg); } catch (_) {}
}
liveBuffer.length = 0;
}
+ function finishHistoryReplay() {
+ clearTimeout(historyTimeout);
+ releaseLiveBuffer();
+ terminateDecodeHistoryWorker();
+ setDecodeHistoryReplayActive(false);
+ setDecodeHistoryOverlayVisible(false);
+ }
+
function updateHistoryReplayOverlay() {
setDecodeHistoryOverlayVisible(
true,
"Loading decode history…",
- `Replaying ${historyProcessed} / ${historyTotal} decoded messages`
+ `Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
+ historyTotal > 0 ? historyProcessed / historyTotal : null,
);
}
function maybeFinishHistoryReplay() {
- if (historySettled) return;
- if (historyWorkerDone && historyGroupQueue.length === 0) {
- clearTimeout(historyTimeout);
- flushLiveBuffer();
- }
+ if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
}
function pumpDecodeHistoryGroupQueue() {
@@ -6585,17 +6613,26 @@ function connectDecode() {
if (historyFallbackStarted || historySettled) return;
historyFallbackStarted = true;
loadDecodeHistoryOnMainThread((groups) => {
- clearTimeout(historyTimeout);
const total = totalDecodeHistoryMessages(groups);
if (total > 0) {
enqueueDecodeHistoryGroups(groups);
} else {
- flushLiveBuffer();
+ finishHistoryReplay();
}
}, (err: unknown) => {
console.error("Decode history fallback failed", err);
- clearTimeout(historyTimeout);
- flushLiveBuffer();
+ // One retry, then say so. Failing silently here is why the history
+ // sometimes only turned up on a second reload: nothing asked again and
+ // nothing said anything was missing.
+ if (historyRetried) {
+ showHint("Decode history unavailable", 3000);
+ finishHistoryReplay();
+ return;
+ }
+ historyRetried = true;
+ historyFallbackStarted = false;
+ setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
+ setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
});
}
@@ -6664,12 +6701,13 @@ function connectDecode() {
return true;
}
- // Safety valve: if the history fetch hangs, unblock after 20 s.
+ // Safety valve: after 20 s, stop holding live decodes back — but keep
+ // loading. The history is what the operator is waiting for, and dropping it
+ // on the floor at the timeout is not something they can even see happen.
const historyTimeout = setTimeout(() => {
- if (!historySettled) {
- terminateDecodeHistoryWorker();
- flushLiveBuffer();
- }
+ if (historySettled) return;
+ releaseLiveBuffer();
+ setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
}, 20000);
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
@@ -6692,7 +6730,7 @@ function connectDecode() {
const wasClosed = source.readyState === 2;
source.close();
terminateDecodeHistoryWorker();
- if (!historySettled) flushLiveBuffer();
+ if (!historySettled) releaseLiveBuffer();
if (wasClosed) {
updateDecodeStatus("Decode not available (check client audio config)");
setTimeout(connectDecode, 10000);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs
index ae47e94d..1dd8ed14 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs
@@ -97,3 +97,70 @@ try {
await browser.close();
await fixture.close();
}
+
+// Stored history, which is what is on screen a second after a page load. The
+// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
+// left the client on its retry path and the history path untested.
+const HISTORY_AIS = 900;
+const HISTORY_APRS = 300;
+const historyFixture = await startWebFixture({
+ spectrum: true,
+ mode: "AIS",
+ history: {
+ ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
+ mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
+ vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
+ rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
+ })),
+ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
+ src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
+ info: `history ${index}`, packet_type: "position", crc_ok: true,
+ lat: 54.3, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
+ })),
+ },
+});
+const replay = await startBrowser(chromium);
+
+try {
+ await replay.page.setViewportSize({ width: 1400, height: 900 });
+ await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
+ await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
+
+ // While it loads, the operator can still see the radio. This used to be a
+ // full-screen scrim over everything for as long as the replay ran.
+ const samples = [];
+ for (let attempt = 0; attempt < 30; attempt++) {
+ samples.push(await replay.page.evaluate(() => {
+ const element = document.getElementById("decode-history-overlay");
+ if (!element || element.classList.contains("is-hidden")) return null;
+ const rect = element.getBoundingClientRect();
+ return {
+ width: Math.round(rect.width),
+ coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
+ };
+ }));
+ await replay.page.waitForTimeout(100);
+ }
+ const shown = samples.filter(Boolean);
+ assert.ok(shown.length > 0, "no progress was shown while the history loaded");
+ for (const sample of shown) {
+ assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
+ assert.equal(sample.coversCentre, false, "the progress sits over the page");
+ }
+
+ // And all of it arrives, on the first load.
+ await replay.page.waitForTimeout(2000);
+ const restored = await replay.page.evaluate(() => ({
+ ais: document.getElementById("ais-messages")?.children.length ?? 0,
+ aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
+ progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
+ }));
+ assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
+ assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
+ assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
+
+ assert.deepEqual(replay.runtimeErrors, []);
+} finally {
+ await replay.browser.close();
+ await historyFixture.close();
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
index 6fbbea91..66d9f4cc 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
@@ -44,6 +44,68 @@ const CONTENT_TYPES = new Map([
[".woff2", "font/woff2"],
]);
+// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
+// that reads it takes the body as CBOR unconditionally. Serving JSON here left
+// every run exercising the client's retry path instead of its history path.
+function encodeCbor(value) {
+ const chunks = [];
+ const head = (major, length) => {
+ if (length < 24) return Buffer.from([(major << 5) | length]);
+ if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
+ if (length < 0x10000) {
+ const buffer = Buffer.alloc(3);
+ buffer[0] = (major << 5) | 25;
+ buffer.writeUInt16BE(length, 1);
+ return buffer;
+ }
+ if (length < 0x1_0000_0000) {
+ const buffer = Buffer.alloc(5);
+ buffer[0] = (major << 5) | 26;
+ buffer.writeUInt32BE(length, 1);
+ return buffer;
+ }
+ // Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
+ const buffer = Buffer.alloc(9);
+ buffer[0] = (major << 5) | 27;
+ buffer.writeBigUInt64BE(BigInt(length), 1);
+ return buffer;
+ };
+ const write = (item) => {
+ if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
+ if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
+ if (typeof item === "number") {
+ if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
+ if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
+ const buffer = Buffer.alloc(9);
+ buffer[0] = 0xfb;
+ buffer.writeDoubleBE(item, 1);
+ chunks.push(buffer);
+ return;
+ }
+ if (typeof item === "string") {
+ const bytes = Buffer.from(item, "utf8");
+ chunks.push(head(3, bytes.length), bytes);
+ return;
+ }
+ if (Array.isArray(item)) {
+ chunks.push(head(4, item.length));
+ item.forEach(write);
+ return;
+ }
+ const entries = Object.entries(item);
+ chunks.push(head(5, entries.length));
+ for (const [key, entryValue] of entries) {
+ const keyBytes = Buffer.from(key, "utf8");
+ chunks.push(head(3, keyBytes.length), keyBytes);
+ write(entryValue);
+ }
+ };
+ write(value);
+ return Buffer.concat(chunks);
+}
+
+const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
+
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
// so a deep link or a refresh serves the SPA shell, not a 404.
@@ -67,6 +129,7 @@ export async function startWebFixture({
meterDb = -70,
decodes = [],
mode = "FM",
+ history = {},
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
@@ -163,7 +226,6 @@ export async function startWebFixture({
["/status", status],
["/bookmarks", bookmarks],
["/bandplan.json", bandplan],
- ["/decode/history", {}],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
@@ -235,6 +297,12 @@ export async function startWebFixture({
// "type" naming the decoder, snake_case fields inside. The mini views, the
// map markers and the history all hang off it, and serving nothing left
// every one of them untested.
+ if (url.pathname === "/decode/history") {
+ const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
+ response.writeHead(200, { "content-type": "application/cbor" });
+ response.end(encodeCbor(payload));
+ return;
+ }
if (url.pathname === "/decode") {
response.writeHead(200, {
"cache-control": "no-cache",