fix: stop losing decodes, hanging on exit, and wedging the rig link

Three faults an operator's log finally made visible, plus the interface
work that came out of the same session.

Reliability:

- UDP events were dropped on backpressure without a word. A period hands
  over twenty-odd decodes at once, and one slow write to the radio was
  enough to fill the queue — so a decode simply never appeared, and the
  only detector was the operator comparing the panel with JTDX. The drop
  is now counted and logged, panadapter spots went to their own goroutine
  so the radio can no longer hold the decode stream up, and the queue is
  deep enough for a full period.

- The CAT manager waited for its poll loop with a bare <-done. A loop
  wedged in a serial read then blocked every later restart inside Start,
  before it could even try to connect: the rig stayed dead, no line was
  written anywhere, and only killing the process recovered it. The wait
  is bounded at ten seconds and says what it abandoned and why the next
  connect may fail.

- Shutdown had no logging at all, so a hang left nothing to go on and a
  process the operator had to kill — which then blocked the restart after
  an update. Every step is logged, and a watchdog forces the exit if one
  of them never returns.

Auto-call:

- A QSO in progress is now held by OpsLog itself rather than inferred
  from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or
  the Enable-Tx flag between overs, the exchange looked finished and the
  next CQ was answered, interleaving two and then three QSOs on one
  slice. Released on log, on halt, on taking over, and by a watchdog.

Cluster console:

- Replies to a command were buried under the spot flood; a Replies
  toggle hides the DX spots, which the list above already shows.
- Twelve named command buttons beside the input, configured in
  Settings -> Cluster; a button with no command is not drawn.
- Following the tail is now an explicit switch, and sending a command
  re-arms it. It used to measure "am I at the bottom" AFTER committing
  the new lines, so a ten-line reply looked like the operator had
  scrolled up and was never followed — the one case it exists for.

Awards:

- An award can name NO field. The matching controls disappear with it
  and only hand-assigned references count, which is the only thing that
  can feed a reference like WWBOTA. A test pins that nothing else is
  scanned.
- WWBOTA added to the catalogue with its 31 342 references.

Elsewhere: the rotor widget's Stop button acknowledges the press like
the direction presets already did, and the docked band map can be
switched to fit-to-band from its own header.
This commit is contained in:
2026-08-21 01:07:10 +02:00
parent 1e507225dd
commit e3b7a35e2c
14 changed files with 251306 additions and 32 deletions
+169 -17
View File
@@ -110,6 +110,7 @@ import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/Winkeye
import { RotorCompass } from '@/components/RotorCompass';
import { GridSquareMap } from '@/components/GridSquareMap';
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
@@ -1499,7 +1500,42 @@ export default function App() {
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
// Hide the spot flood in the console, on by default.
//
// The spots have a grid of their own two panels up; what the console is FOR is
// the traffic that is not a spot — the reply to SH/DX, the filter listing, the
// node's complaint about a command it did not understand. On a busy node those
// answers scrolled out of sight in under a second, which made the command box
// look inert even though it had worked.
const [clusterHideSpots, setClusterHideSpots] = useState(() => lsBool('opslog.clusterHideSpots', true));
// Follow the tail, or stay where you put it.
//
// The console used to follow the bottom whenever you happened to be at the
// bottom, which is not the same as being asked to: reading a long SH/DX reply
// meant scrolling up and hoping nothing pulled you back. This is the explicit
// switch — off means the view never moves on its own, however much traffic
// arrives, and the button below jumps back down when you want it.
const [clusterFollow, setClusterFollow] = useState(() => lsBool('opslog.clusterConsoleFollow', true));
// What the console actually renders. A spot line is the node's own "DX de …"
// announcement — the one shape every cluster software agrees on — and it is
// matched on that alone: guessing more (WCY, WWV, a node's chatter) would hide
// traffic the operator asked to see. Lines WE sent are never hidden; they are
// the anchor for reading the reply that follows.
const clusterShown = useMemo(
() => (clusterHideSpots ? clusterLines.filter((l) => l.sent || !/^\s*DX de /i.test(l.text)) : clusterLines),
[clusterLines, clusterHideSpots],
);
const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
// Whether the view was at the bottom BEFORE the new lines landed.
//
// Measuring it inside the effect — after React had committed them — is what
// broke following a command reply: a ten-line answer to SH/FILTER makes the
// distance to the bottom jump past any threshold in the same frame, so the
// console concluded the operator had scrolled up and stayed where it was.
// Exactly the case the feature exists for. The scroll event is the only
// honest source: it fires when the OPERATOR moves, and not when content grows
// underneath them.
const clusterAtBottomRef = useRef(true);
// Console lines are STAGED and flushed on a timer, never applied one by one.
//
// Every line of cluster traffic reaches this handler — spots, MOTD, WHO, the
@@ -1540,11 +1576,26 @@ export default function App() {
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
// to read a SH/DX reply would yank you back down on the next spot.
useEffect(() => {
if (!clusterFollow) return; // pinned by the operator — never move the view
// Even while following, a view the operator scrolled up is left alone: they
// are mid-sentence, and scrolling back to the bottom re-arms it by itself.
if (!clusterAtBottomRef.current) return;
const el = clusterConsoleRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
if (atBottom) el.scrollTop = el.scrollHeight;
}, [clusterLines]);
if (el) el.scrollTop = el.scrollHeight;
// clusterShown, not clusterLines: with the spots hidden, the rendered list
// is the only thing whose change can move the view.
}, [clusterShown, clusterFollow]);
const clusterScrollToBottom = () => {
clusterAtBottomRef.current = true;
const el = clusterConsoleRef.current;
if (el) el.scrollTop = el.scrollHeight;
};
// Sending a command is a request to SEE its answer: it re-arms the follow
// whatever the scroll position was, then the reply lands and is followed.
const clusterSend = (cmd: string) => {
clusterScrollToBottom();
return SendClusterCommand(cmd).catch((err) => setError(String(err?.message ?? err)));
};
// Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
// Lock-to-entry: when on, the band filter follows the entry's current
@@ -2100,6 +2151,11 @@ export default function App() {
// argued with); this is only the plumbing that runs it and keys the radio.
//
// Re-read when Preferences closes, like every other setting edited there.
// The named command buttons, re-read when Preferences closes like every other
// setting edited there.
const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
// When each callsign was last answered, so a station still calling CQ is not
@@ -2112,6 +2168,22 @@ export default function App() {
// Set when a call goes out, so nothing else fires until the receiver's own
// status catches up and `busy` can be trusted again.
const autoHoldUntilRef = useRef(0);
// The station auto-call is currently working, and when it started.
//
// This is OpsLog's OWN record of "a QSO is running", and it exists because
// deriving that from the sender's Status was not enough: the moment
// WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which
// they do — the exchange looks finished and the next CQ gets answered,
// interleaving two and then three QSOs on one slice. A lock we set ourselves
// cannot be cleared by a flag we do not control.
//
// Released when that station's QSO is logged, when the operator halts or
// takes over by clicking a decode, and by the watchdog below.
const autoTargetRef = useRef<{ call: string; at: number } | null>(null);
// An exchange abandoned mid-way must not lock auto-call out for ever: four
// minutes covers a repeated FT8 QSO and still frees the next period soon
// enough to matter.
const AUTO_TARGET_MAX_MS = 240_000;
// Per receiver, when the carrier was last up. Feeds the stale-exchange
// backstop below.
const lastTxAtRef = useRef<Map<string, number>>(new Map());
@@ -2141,6 +2213,13 @@ export default function App() {
// it has acted on it — about a second. Without it the OTHER instance still
// looks idle in that window and gets a call of its own.
|| now < autoHoldUntilRef.current;
// Our own lock, evaluated after the watchdog so an abandoned exchange does
// not hold the transmitter shut.
if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) {
LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, '');
autoTargetRef.current = null;
}
const locked = busy || autoTargetRef.current !== null;
for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue;
@@ -2155,7 +2234,7 @@ export default function App() {
if (!e) continue;
autoSeenRef.current.add(seenKey);
const verdict = shouldAutoCall(autoCall, d, e as any, {
busy,
busy: locked,
calledAt: autoCalledRef.current,
now,
myCall: station.callsign,
@@ -2163,6 +2242,7 @@ export default function App() {
if (!verdict.call) continue;
autoCalledRef.current.set(d.call.toUpperCase(), now);
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
autoTargetRef.current = { call: d.call.toUpperCase(), at: now };
// Same reason as a manual click: put the transmitter on the decode's band
// before answering, or a second slice answers on the wrong one.
FlexTXOnBand(d.band ?? '').catch(() => {});
@@ -3348,6 +3428,11 @@ export default function App() {
try {
await LogUDPLoggedADIF(text);
await refresh();
// The QSO auto-call started has finished — release the lock so the next
// CQ can be answered. Matched on the callsign: a QSO logged from
// somewhere else must not free a run that is still going.
const logged = /<call:d+(?::[^>]*)?>([^<s]+)/i.exec(text)?.[1]?.toUpperCase();
if (logged && autoTargetRef.current?.call === logged) autoTargetRef.current = null;
} catch (e: any) {
const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
@@ -5609,6 +5694,9 @@ export default function App() {
// who gets answered — and it would only fight the digital application
// for the VFO. The entry is still filled, so the QSO can be logged here.
onCall={(d) => {
// The operator has picked a station: that is now the QSO in progress, so
// auto-call must not answer someone else over the top of it.
autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() };
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move
@@ -5627,6 +5715,7 @@ export default function App() {
// buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => {
if (!instance) {
autoTargetRef.current = null;
pendingDecodesRef.current = [];
setDecodes([]);
setTxMsgs([]);
@@ -5644,6 +5733,8 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case.
onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started.
autoTargetRef.current = null;
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
@@ -7315,8 +7406,35 @@ export default function App() {
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('cluster.console')}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterShown.length}</span>
<div className="flex-1" />
<button
className={cn('text-[11px] px-1.5 rounded border',
clusterHideSpots
? 'border-primary/60 bg-primary/15 text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground')}
title={t('cluster.repliesOnlyTip')}
onClick={() => {
const v = !clusterHideSpots;
setClusterHideSpots(v);
writeUiPref('opslog.clusterHideSpots', v ? '1' : '0');
}}>{t('cluster.repliesOnly')}</button>
<button
className={cn('text-[11px] px-1.5 rounded border',
clusterFollow
? 'border-primary/60 bg-primary/15 text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground')}
title={t('cluster.followTip')}
onClick={() => {
const v = !clusterFollow;
setClusterFollow(v);
writeUiPref('opslog.clusterConsoleFollow', v ? '1' : '0');
if (v) clusterScrollToBottom(); // switching it on means "take me there"
}}>{t('cluster.follow')}</button>
<button
className="text-[11px] px-1.5 rounded border border-transparent text-muted-foreground hover:text-foreground"
title={t('cluster.toBottom')}
onClick={clusterScrollToBottom}></button>
<button className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
<button className="text-muted-foreground hover:text-foreground"
@@ -7324,10 +7442,17 @@ export default function App() {
<X className="size-3.5" />
</button>
</div>
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
{clusterLines.length === 0 ? (
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
) : clusterLines.map((l, i) => (
<div ref={clusterConsoleRef}
onScroll={(e) => {
const el = e.currentTarget;
clusterAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
{clusterShown.length === 0 ? (
<p className="text-muted-foreground italic">
{t(clusterHideSpots && clusterLines.length > 0 ? 'cluster.consoleOnlySpots' : 'cluster.consoleEmpty')}
</p>
) : clusterShown.map((l, i) => (
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
@@ -7348,15 +7473,13 @@ export default function App() {
)}
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input
className="font-mono text-xs h-8"
className="font-mono text-xs h-8 w-64 shrink-0"
placeholder='sh/dx 30, set/needsdxcc, …'
value={clusterCmd}
onChange={(e) => setClusterCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && clusterCmd.trim()) {
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
}
}}
/>
@@ -7364,14 +7487,38 @@ export default function App() {
variant="outline" size="sm"
onClick={() => {
if (!clusterCmd.trim()) return;
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
}}
disabled={!clusterCmd.trim()}
>
Send
</Button>
{/* The macro buttons, in the space the command box used to take up
for itself. After Send, so the two ways of sending a command
are not interleaved and the eye finds Send where it has always
been. Wraps rather than scrolls: twelve short labels fit on one
line at any usable width, and a hidden button is a button that
does not exist. */}
{clusterMacrosShown.length > 0 && (
<div className="flex flex-wrap items-center gap-1 min-w-0">
{clusterMacrosShown.map((m, i) => (
<button
key={`${m.label}-${i}`}
type="button"
title={m.cmd}
onClick={() => {
// Straight out, no round trip through the input box: the
// point of the button is not to type the command for you.
clusterSend(m.cmd);
}}
className="h-8 px-2 rounded-md border border-border bg-muted/40 text-xs font-medium
hover:bg-muted active:scale-95 transition-all duration-150 truncate max-w-[10rem]"
>
{m.label}
</button>
))}
</div>
)}
</div>
</div>{/* /left column */}
@@ -7611,6 +7758,11 @@ export default function App() {
onSpotClick={handleSpotClick}
onClose={() => setBandMapShown(false)}
showLotw={!!rowColors?.bandmap_lotw}
// The SAME setting the Band Map tab drives, deliberately: one
// answer to "does a band map show the whole band", not one per
// place a band map happens to be drawn.
fitToBand={bandMapFit}
onToggleFit={toggleBandMapFit}
keyNav
/>
</div>