feat(decodes): an FT decodes tab fed by the inbound UDP link

Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.

The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.

Three fields had to be carried up from the wire to make it possible:

  - the decode's OWN timestamp, which the parser read and threw away. It is
    what assigns a slot: a period's decodes arrive in one burst a second or
    two after it closes, so arrival time piles a whole period into the next
    one. Rebuilt to UTC from milliseconds-since-midnight, with the
    day-boundary case handled - a decode stamped 23:59:58 arriving at
    00:00:01 would otherwise be dated a day ahead and sit at the top of the
    list for the rest of the session.
  - the decoded line itself. The exchange is what says where a station is in
    a QSO, and no set of extracted fields reads like "R-09" does.
  - tx_message and transmitting from Status, which nothing parsed before.
    Recorded once per message rather than on every Status, which repeats it
    about once a second for the whole over.

Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.

Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.

Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.

Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
This commit is contained in:
2026-08-18 05:51:34 +02:00
parent 9599c3e0b9
commit a197d124dc
7 changed files with 791 additions and 13 deletions
+147
View File
@@ -108,6 +108,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -1031,6 +1032,20 @@ export default function App() {
setStationTabOpen(false);
setActiveTab((t) => (t === 'station' ? 'recent' : t));
}
// FTx decodes — same closable-tab pattern, but its open state is REMEMBERED:
// unlike Statistics, which is consulted and closed, this one is a panel an
// operator running digital modes leaves open for the session.
const [decodesTabOpen, setDecodesTabOpen] = useState(() => localStorage.getItem('opslog.decodesTab') === '1');
function openDecodesTab() {
setDecodesTabOpen(true);
writeUiPref('opslog.decodesTab', '1');
setActiveTab('decodes');
}
function closeDecodesTab() {
setDecodesTabOpen(false);
writeUiPref('opslog.decodesTab', '0');
setActiveTab((t) => (t === 'decodes' ? 'recent' : t));
}
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting
@@ -2018,6 +2033,20 @@ export default function App() {
// settings dialog closes, which is the only place it changes.
const [rowColors, setRowColors] = useState<any>(null);
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
// ── FTx decodes from the inbound UDP feed ──────────────────────────
//
// Held in the frontend, like the cluster spots: they are a live view, not
// data, and nothing outside this panel reads them. Pruned to a rolling
// half hour — long enough to hold a whole opening, short enough that a night
// of FT8 on 20 m does not turn the list into something no filter can rescue.
const DECODE_KEEP_MS = 30 * 60 * 1000;
const [decodes, setDecodes] = useState<DecodeRow[]>([]);
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
// Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]);
const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
// row colours: the settings dialog is the only place they change.
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
@@ -3010,6 +3039,83 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── FTx decodes ────────────────────────────────────────────────────
useEffect(() => {
// Resolve the new-entity / new-slot flags into the SAME map the cluster
// fills. One cache, one verdict: a call must not be "new band" in the
// decodes panel and plain worked in the cluster list two seconds later.
const flushDecodes = async () => {
pendingDecodeTimer.current = undefined;
const batch = pendingDecodesRef.current;
pendingDecodesRef.current = [];
if (batch.length === 0) return;
try {
const known = spotStatusRef.current;
const seen = new Set<string>();
const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
for (const d of batch) {
const k = `${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`;
if (seen.has(k) || known[k]) continue;
seen.add(k);
unknown.push({ call: d.call, band: d.band ?? '', mode: (d.mode ?? '').toUpperCase(), pota_ref: '', spotter: '' });
}
if (unknown.length > 0) {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
}
return next;
});
}
} catch { /* status unresolved — the decode still shows, just unflagged */ }
setDecodes((arr) => {
const cutoff = Date.now() - DECODE_KEEP_MS;
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff);
return next;
});
};
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
pendingDecodesRef.current.push(d);
if (pendingDecodeTimer.current === undefined) {
pendingDecodeTimer.current = window.setTimeout(flushDecodes, 300);
}
});
// The operator's own transmission. Status repeats it about once a second
// for the whole over, so it is recorded ONCE per message: the panel wants
// "I sent this in that period", not sixty copies of it.
const unsubTx = EventsOn('udp:tx_message', (m: any) => {
if (!m?.transmitting || !String(m?.msg ?? '').trim()) return;
setTxMsgs((arr) => {
const last = arr[arr.length - 1];
if (last && last.msg === m.msg && Date.parse(m.at) - Date.parse(last.at) < 30_000) return arr;
const cutoff = Date.now() - DECODE_KEEP_MS;
return [...arr, m as TxMsgRow].filter((x) => Date.parse(x.at) >= cutoff);
});
});
return () => {
unsubDecode?.(); unsubTx?.();
if (pendingDecodeTimer.current !== undefined) window.clearTimeout(pendingDecodeTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── UDP integration events ───────────────────────────────────────────
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
// We push the broadcast DX call into the entry field and auto-log any
@@ -4173,6 +4279,7 @@ export default function App() {
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -4222,6 +4329,7 @@ export default function App() {
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
@@ -6596,6 +6704,24 @@ export default function App() {
</span>
</TabsTrigger>
)}
{decodesTabOpen && (
<TabsTrigger value="decodes" className="gap-1.5">
{t('dec.tab')}
{decodes.length > 0 && (
<span className="text-[10px] tabular-nums text-muted-foreground">{decodes.length}</span>
)}
<span
role="button"
aria-label="Close FT decodes"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeDecodesTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{stationTabOpen && (
<TabsTrigger value="station" className="gap-1.5">
{t('station.title')}
@@ -7045,6 +7171,27 @@ export default function App() {
</TabsContent>
)}
{decodesTabOpen && (
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
spotStatus={spotStatus as any}
myCall={station.callsign}
// Same handler as a cluster spot click: one way to answer a
// station, whether it came off the telnet feed or the receiver.
onCall={(d) => handleSpotClick({
dx_call: d.call,
freq_hz: d.freq_hz,
freq_khz: d.freq_hz / 1000,
band: d.band,
comment: d.mode,
spotter: '',
} as any)}
/>
</TabsContent>
)}
{stationTabOpen && (
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StationControlPanel