Compare commits
2
Commits
84bdf2593c
...
c1899229a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1899229a0 | ||
|
|
84a99a3636 |
@@ -1689,7 +1689,24 @@ function estimateNoiseFloorDb(bins) {
|
|||||||
|
|
||||||
// src/plugin-loader.ts
|
// src/plugin-loader.ts
|
||||||
var pluginGroups = {
|
var pluginGroups = {
|
||||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
|
// with it. They used to come only with the map group, which left their
|
||||||
|
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||||
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
|
"digital-modes": [
|
||||||
|
"/ft8.js",
|
||||||
|
"/ft4.js",
|
||||||
|
"/ft2.js",
|
||||||
|
"/wspr.js",
|
||||||
|
"/cw.js",
|
||||||
|
"/background-decode.js",
|
||||||
|
"/sat.js",
|
||||||
|
"/wefax.js",
|
||||||
|
"/ais.js",
|
||||||
|
"/vdes.js",
|
||||||
|
"/aprs.js",
|
||||||
|
"/hf-aprs.js"
|
||||||
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
@@ -2048,6 +2065,7 @@ var loadingSub = requiredElement("loading-sub");
|
|||||||
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -2197,10 +2215,19 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var overviewDrawPending = false;
|
var overviewDrawPending = false;
|
||||||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
|
||||||
if (!decodeHistoryOverlayEl) return;
|
if (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
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);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
||||||
@@ -7502,16 +7529,15 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue = [];
|
const historyGroupQueue = [];
|
||||||
const liveBuffer = [];
|
const liveBuffer = [];
|
||||||
function flushLiveBuffer() {
|
function releaseLiveBuffer() {
|
||||||
|
if (historySettled) return;
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try {
|
try {
|
||||||
dispatchDecodeMessage(msg);
|
dispatchDecodeMessage(msg);
|
||||||
@@ -7520,19 +7546,23 @@ function connectDecode() {
|
|||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
historyBatchDrainScheduled = false;
|
historyBatchDrainScheduled = false;
|
||||||
@@ -7586,17 +7616,25 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
if (historyRetried) {
|
||||||
flushLiveBuffer();
|
showHint("Decode history unavailable", 3e3);
|
||||||
|
finishHistoryReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyRetried = true;
|
||||||
|
historyFallbackStarted = false;
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||||
|
setTimeout(() => {
|
||||||
|
startDecodeHistoryFallback();
|
||||||
|
}, 2e3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function startDecodeHistoryWorkerReplay() {
|
function startDecodeHistoryWorkerReplay() {
|
||||||
@@ -7657,10 +7695,9 @@ function connectDecode() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const historyTimeout = setTimeout(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 2e4);
|
}, 2e4);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
decodeSource = new EventSource("/decode");
|
decodeSource = new EventSource("/decode");
|
||||||
@@ -7682,7 +7719,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 1e4);
|
setTimeout(connectDecode, 1e4);
|
||||||
|
|||||||
@@ -1660,11 +1660,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<div class="decode-history-overlay-card">
|
<div class="history-progress-text">
|
||||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||||
<script defer src="/vendor/leaflet.js"></script>
|
<script defer src="/vendor/leaflet.js"></script>
|
||||||
|
|||||||
@@ -2209,6 +2209,74 @@ small { color: var(--text-muted); }
|
|||||||
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 {
|
.decode-history-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ const loadingSub = requiredElement("loading-sub");
|
|||||||
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -880,10 +881,29 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let overviewDrawPending = false;
|
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 (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
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);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6494,36 +6514,44 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
||||||
const liveBuffer: DecodeMessage[] = [];
|
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;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try { dispatchDecodeMessage(msg); } catch (_) {}
|
try { dispatchDecodeMessage(msg); } catch (_) {}
|
||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
@@ -6585,17 +6613,26 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err: unknown) => {
|
}, (err: unknown) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
// One retry, then say so. Failing silently here is why the history
|
||||||
flushLiveBuffer();
|
// 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;
|
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(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 20000);
|
}, 20000);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
|
|
||||||
@@ -6692,7 +6730,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 10000);
|
setTimeout(connectDecode, 10000);
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
|
// with it. They used to come only with the map group, which left their
|
||||||
|
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||||
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
|
"digital-modes": [
|
||||||
|
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||||
|
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||||
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// What happens to a decode after it arrives: the panel on its tab, the mini
|
||||||
|
// view over the waterfall, the marker on the map, and the link between them.
|
||||||
|
// Nothing exercised this before — the fixture served an empty decode stream —
|
||||||
|
// which is how the map links came to be broken for every decoder at once.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, getComputedStyle, window, location */
|
||||||
|
|
||||||
|
const VESSEL = {
|
||||||
|
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
|
||||||
|
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
const BEACON = {
|
||||||
|
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
|
||||||
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// The decoders with panels on this tab have to load with it. They used to
|
||||||
|
// come only with the map group, so these panels stayed empty — decodes
|
||||||
|
// queued in the plugin runtime — until something opened the Map tab.
|
||||||
|
const panels = await page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
|
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||||
|
mapLoaded: !!window.trx.modules.map,
|
||||||
|
}));
|
||||||
|
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||||
|
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
|
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||||
|
|
||||||
|
// The mini view rides over the waterfall on the radio page.
|
||||||
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const miniView = await page.evaluate(() => {
|
||||||
|
const bar = document.getElementById("ais-bar-overlay");
|
||||||
|
return {
|
||||||
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||||
|
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||||
|
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||||
|
|
||||||
|
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||||
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
|
// been loaded, so the pin did nothing at all.
|
||||||
|
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
|
||||||
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const followed = await page.evaluate(() => {
|
||||||
|
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
|
||||||
|
return {
|
||||||
|
path: location.pathname,
|
||||||
|
lat: centre ? Number(centre.lat.toFixed(2)) : null,
|
||||||
|
lon: centre ? Number(centre.lng.toFixed(2)) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
|
||||||
|
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
|
||||||
|
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
|
||||||
|
|
||||||
|
// Both decoders put their own marker on it.
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const markers = await page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const size = (collection) => (collection instanceof Map
|
||||||
|
? collection.size
|
||||||
|
: Object.keys(collection ?? {}).length);
|
||||||
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
|
});
|
||||||
|
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
||||||
|
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -44,6 +44,68 @@ const CONTENT_TYPES = new Map([
|
|||||||
[".woff2", "font/woff2"],
|
[".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) {
|
function assetPath(urlPath) {
|
||||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
// 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.
|
// so a deep link or a refresh serves the SPA shell, not a 404.
|
||||||
@@ -65,6 +127,9 @@ export async function startWebFixture({
|
|||||||
spectrum = false,
|
spectrum = false,
|
||||||
tx = false,
|
tx = false,
|
||||||
meterDb = -70,
|
meterDb = -70,
|
||||||
|
decodes = [],
|
||||||
|
mode = "FM",
|
||||||
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
@@ -112,7 +177,7 @@ export async function startWebFixture({
|
|||||||
signal_meter: spectrum,
|
signal_meter: spectrum,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
||||||
// Reported only by SDR backends, and what makes the client show the
|
// Reported only by SDR backends, and what makes the client show the
|
||||||
// squelch control at all.
|
// squelch control at all.
|
||||||
filter: spectrum
|
filter: spectrum
|
||||||
@@ -228,6 +293,37 @@ export async function startWebFixture({
|
|||||||
request.on("close", () => clearInterval(timer));
|
request.on("close", () => clearInterval(timer));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Decodes arrive on this stream in the server's own shape: a routing
|
||||||
|
// "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",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
});
|
||||||
|
response.write(": decode stream\n\n");
|
||||||
|
// Repeats: a live decoder keeps producing, and the views that collapse
|
||||||
|
// by station or vessel need more than one frame to behave like they do
|
||||||
|
// in front of a radio.
|
||||||
|
let sent = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!decodes.length) return;
|
||||||
|
const decode = decodes[sent++ % decodes.length];
|
||||||
|
// Stamped as they leave: the client prunes anything older than the
|
||||||
|
// retention window, so a fixed epoch would be dropped on arrival.
|
||||||
|
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
|
||||||
|
}, 400);
|
||||||
|
request.on("close", () => clearInterval(timer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
|
|||||||
Reference in New Issue
Block a user