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:
+169
-17
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user