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
+65 -2
View File
@@ -1545,6 +1545,22 @@ func (a *App) GetStartupStatus() StartupStatus {
}
}
// armExitWatchdog guarantees the process dies.
//
// A graceful shutdown is preferable and is what everything below attempts; this
// only fires when that has already failed. By then the log says which step never
// returned, and the databases are either closed or safe to leave — SQLite's WAL
// survives a hard exit, which a zombie process does not.
func (a *App) armExitWatchdog(after time.Duration) {
go func() {
time.Sleep(after)
applog.Printf("shutdown: still not finished after %s — forcing exit "+
"(the last 'shutdown:' line above names the step that hung)", after)
applog.Close()
os.Exit(0)
}()
}
// beforeClose intercepts the window-close event so we can run shutdown
// tasks (backup, future LoTW upload, ...) while showing a progress modal
// to the user. Returns true the first time to block the close; the
@@ -1555,6 +1571,13 @@ func (a *App) beforeClose(ctx context.Context) bool {
return false
}
a.shuttingDown = true
applog.Printf("shutdown: close requested")
// From here the application MUST end. Everything below waits on hardware and
// databases, and any one of them hanging used to leave a window that would
// not close and a process the operator had to kill from the task manager —
// which then blocked the next launch (single-instance guard) and the restart
// after an update. The watchdog is the promise that it ends anyway.
a.armExitWatchdog(30 * time.Second)
// Capture geometry now, before any shutdown UI can resize the window, so the
// next launch reopens exactly here.
@@ -1636,6 +1659,7 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
steps[i].Status = "done"
}
a.emitShutdownEvent("shutdown:update", steps)
applog.Printf("shutdown: step %q %s %s", steps[i].ID, steps[i].Status, steps[i].Detail)
}
a.emitShutdownEvent("shutdown:done", steps)
// Cleanly tear down hardware links before quitting. Especially the CAT
@@ -1643,11 +1667,14 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
// TCP is gone, and just letting the process exit left the slot stale — the
// NEXT launch then spent ~30s retrying the connect before the radio freed it.
// cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
applog.Printf("shutdown: stopping CAT")
if a.cat != nil {
a.cat.Stop()
}
applog.Printf("shutdown: releasing relay drivers")
a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
time.Sleep(600 * time.Millisecond)
applog.Printf("shutdown: calling Quit")
wruntime.Quit(ctx)
}
@@ -1706,12 +1733,18 @@ func (a *App) shutdown(ctx context.Context) {
// If the user managed to skip beforeClose (force kill, OS shutdown,
// crash recovery) we still try the backup here as a best-effort
// safety net. HasBackupToday makes a double-run a no-op.
applog.Printf("shutdown: teardown starting")
// Covers the paths beforeClose never saw (force kill of the window, OS
// shutdown) — and re-arms a shorter fuse for the teardown itself.
a.armExitWatchdog(15 * time.Second)
if !a.shuttingDown {
a.maybeShutdownBackup()
}
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
}
applog.Printf("shutdown: stopping solar")
if a.solar != nil {
a.solar.Stop()
}
@@ -1719,6 +1752,7 @@ func (a *App) shutdown(ctx context.Context) {
// backend: without this the rig never gets a disconnect and holds its single
// control session for minutes, refusing every new login (even from the Icom
// Remote Utility) until it times out on its own.
applog.Printf("shutdown: stopping CAT sharing")
if a.catShare != nil {
// Before the CAT stop, so no client is mid-command against a backend that
// is disconnecting — and so the port is free for the next launch.
@@ -1729,29 +1763,35 @@ func (a *App) shutdown(ctx context.Context) {
a.catShareTCI.Stop()
a.catShareTCI = nil
}
applog.Printf("shutdown: stopping CAT")
if a.cat != nil {
a.cat.Stop()
}
applog.Printf("shutdown: stopping WinKeyer")
if a.winkeyer != nil {
a.winkeyer.Disconnect()
}
applog.Printf("shutdown: stopping QSO recorder")
if a.qsoRec != nil {
a.qsoRec.Stop()
}
// Before the databases: Close flushes what the last minute learnt, and a
// restart is exactly when the grid cache is worth the most.
applog.Printf("shutdown: closing grid cache")
if a.gridStore != nil {
if err := a.gridStore.Close(); err != nil {
applog.Printf("gridcache: close: %v", err)
}
a.gridStore = nil
}
applog.Printf("shutdown: closing databases")
if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
}
if a.db != nil {
_ = a.db.Close()
}
applog.Printf("shutdown: teardown done")
}
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
@@ -12861,6 +12901,23 @@ func (a *App) consumeUDPEvents() {
// 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{}
// Panadapter spots go out on their OWN goroutine.
//
// SendSpot writes to the radio's TCP socket, and this loop is the only
// consumer of the UDP event channel — which drops on backpressure. A period
// hands over twenty-odd decodes at once, so one slow write to the Flex was
// enough to fill that queue and lose decodes the panel was meant to show.
// The spot is an ornament; the decode is the point. Buffered and
// non-blocking, so a radio that stops reading costs spots and nothing else.
spotQ := make(chan cat.SpotInfo, 64)
go func() {
for si := range spotQ {
if a.cat != nil {
a.cat.SendSpot(si)
}
}
}()
defer close(spotQ)
for ev := range a.udp.Events() {
if a.ctx == nil {
continue
@@ -12954,14 +13011,20 @@ func (a *App) consumeUDPEvents() {
if secs <= 0 {
secs = 120
}
a.cat.SendSpot(cat.SpotInfo{
select {
case spotQ <- cat.SpotInfo{
FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall,
Mode: ev.Mode,
Color: "#FF34C759", // green — distinct from cluster orange
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
LifetimeSec: secs,
})
}:
default:
// Radio not keeping up: skip the spot rather than stall the
// decode stream behind it.
delete(lastDecodeSpot, key) // not spotted after all — let the next one try
}
}
case ev.LoggedADIF != "":
// The RECORD, not just its size. "Was the grid in what WSJT-X sent, or
+30
View File
@@ -1,4 +1,34 @@
[
{
"version": "0.26.1",
"date": "",
"en": [
"Auto-call: a QSO in progress is now held by OpsLog itself, so a new station is no longer called in the middle of an exchange. The hold is released when that QSO is logged, when you halt or click another decode, and after four minutes without a contact.",
"Rotor widget: the Stop button now lights up when pressed, like the direction presets, so there is no doubt the order went out.",
"FT decodes: a decode could go missing from the panel when a period arrived as one burst — the internal queue discarded it in silence. It is now deep enough for a full period, panadapter spots no longer hold the stream up, and anything still lost is written to the log.",
"Closing OpsLog can no longer leave a process behind. Each shutdown step is written to the log, and if one of them hangs the application now forces its own exit instead of staying open — which also unblocks the restart after an update.",
"CAT: a rig link whose polling loop got stuck no longer freezes every later reconnection attempt in silence. The wait is now bounded and the problem is written to the log, instead of a radio that never comes back and no explanation anywhere.",
"Cluster console: the replies to your commands are no longer buried under the spot flood. A Replies button — on by default — hides the DX spots, which the list above already shows.",
"Cluster: twelve named command buttons beside the command box — name one \"Show filters\", give it SH/FILTER, and one click sends it. They are set up in Settings → Cluster; a button with no command is not shown.",
"Cluster console: a Follow button decides whether the view sticks to the newest line, and sending a command re-arms it — a long reply is now scrolled to instead of landing out of sight. Switch Follow off to read without being pulled back down; the arrow beside it returns to the end.",
"Band map: the docked map can be switched to fit-to-band from its own header — click the px/kHz readout. It is the same setting the Band Map tab drives, so the two always agree.",
"Awards: an award can now be set to search in NO field. The matching options disappear with it, and the award counts only the references you assign to a contact by hand — for a reference like WWBOTA, which has no ADIF field anywhere.",
"New award: WWBOTA (World Wide Bunkers on the Air), with its full reference list — 31 342 bunkers. References are assigned to a contact by hand, the award having no ADIF field of its own."
],
"fr": [
"Appel automatique : le QSO en cours est désormais verrouillé par OpsLog lui-même, si bien qu'une nouvelle station n'est plus appelée au milieu d'un échange. Le verrou est levé quand ce QSO est journalisé, quand tu fais Stop ou cliques un autre décodage, et au bout de quatre minutes sans contact.",
"Widget rotor : le bouton Stop s'allume maintenant quand on l'enfonce, comme les présélections de direction, pour ne plus douter que l'ordre est parti.",
"Décodages FT : un décodage pouvait manquer dans le panneau quand une période arrivait d'un bloc — la file interne le jetait sans rien dire. Elle est désormais dimensionnée pour une période entière, les spots panadapter ne retiennent plus le flux, et ce qui serait encore perdu est écrit dans le journal.",
"Fermer OpsLog ne peut plus laisser un processus derrière. Chaque étape d'arrêt est écrite dans le journal, et si l'une d'elles se bloque l'application force désormais sa propre sortie au lieu de rester ouverte — ce qui débloque aussi le redémarrage après une mise à jour.",
"CAT : une liaison radio dont la boucle d'interrogation se bloquait figeait en silence toutes les tentatives de reconnexion suivantes. L'attente est désormais bornée et le problème écrit dans le journal, au lieu d'une radio qui ne revient jamais sans la moindre explication.",
"Console cluster : les réponses à tes commandes ne sont plus noyées sous le flot de spots. Un bouton Réponses — actif par défaut — masque les spots DX, que la liste au-dessus affiche déjà.",
"Cluster : douze boutons de commande nommés à côté du champ de saisie — nomme-en un « Voir filtres », donne-lui SH/FILTER, et un clic l'envoie. Ils se règlent dans Réglages → Cluster ; un bouton sans commande n'est pas affiché.",
"Console cluster : un bouton Suivre décide si la vue reste collée à la dernière ligne, et envoyer une commande le réarme — une longue réponse défile maintenant jusqu'à elle au lieu d'arriver hors champ. Désactive Suivre pour lire sans être ramené en bas ; la flèche à côté revient à la fin.",
"Bandmap : la carte ancrée peut passer en ajustement à la bande depuis son propre en-tête — cliquer l'indicateur px/kHz. C'est le même réglage que celui de l'onglet Bandmap, les deux sont donc toujours d'accord.",
"Diplômes : un diplôme peut désormais ne chercher dans AUCUN champ. Les options de reconnaissance disparaissent avec lui, et le diplôme ne compte que les références attribuées à la main sur un contact — pour une référence comme WWBOTA, qui n'a de champ ADIF nulle part.",
"Nouveau diplôme : WWBOTA (World Wide Bunkers on the Air), avec sa liste de références complète — 31 342 bunkers. Les références s'attribuent à la main sur un contact, le diplôme n'ayant pas de champ ADIF propre."
]
},
{
"version": "0.26.0",
"date": "",
+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>
+23 -2
View File
@@ -62,6 +62,10 @@ type Preset = { key: string; name: string; field: string; dxcc: number; refs: Aw
// organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
// Sentinel for "no QSO field at all". The definition stores an empty field, and
// the matcher already treats that as "nothing to scan" — this only gives the
// operator a way to ASK for it.
const NO_FIELD = '__none__';
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -277,6 +281,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}
return groups;
}, [testRows]);
const noField = !String(cur?.field ?? '').trim();
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
@@ -566,11 +571,26 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
<Field2 label={t('awed.searchInField')}>
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
{/* NO_FIELD is a sentinel because the stored value is the
empty string and a Select cannot carry one — an empty
item value means "show the placeholder" to Radix. */}
<Select value={cur.field || NO_FIELD}
onValueChange={(v) => patch({ field: v === NO_FIELD ? '' : v })}>
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
<SelectContent className="max-h-72">
<SelectItem value={NO_FIELD}>{t('awed.fieldNone')}</SelectItem>
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
</Field2>
{/* An award whose references live in no ADIF field at all —
WWBOTA has no column anywhere — has nothing to match on,
and every control below would be a question with no
answer. Hiding them is the point: the references are the
ones the operator assigns to a QSO by hand. */}
{noField ? (
<p className="text-[11px] text-muted-foreground pl-[128px]">{t('awed.fieldNoneHint')}</p>
) : (<>
<Field2 label={t('awed.matchBy')}>
<div className="flex items-center gap-3 text-xs">
{['code', 'description', 'pattern'].map((m) => (
@@ -630,6 +650,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
);
})}
</div>
</>)}
</div>
</TabsContent>
+17 -1
View File
@@ -83,6 +83,11 @@ interface Props {
// globally from the band-map tab toolbar.
hideDigital?: boolean;
fitToBand?: boolean;
// onToggleFit turns the zoom readout into the fit-to-band switch. Only the
// docked map passes it: the Band Map tab has the same control in its own
// toolbar, above maps that all obey it at once, and a second switch inside
// each card would be four ways to change one setting.
onToggleFit?: () => void;
// Mark stations that upload to LoTW (Settings → Appearance).
showLotw?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
@@ -263,7 +268,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, keyNav = false, showLotw = false }: Props) {
const { t } = useI18n();
// The two display options are applied ONCE here, on the whole map, so the
@@ -559,7 +564,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
{/* The readout IS the switch when the parent offers one — the header is
already tight with four maps side by side, and a separate chip would
cost width to say what this text says anyway. */}
{onToggleFit ? (
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
className={cn('shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-1 rounded border transition-colors',
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:bg-muted')}>
{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}
</button>
) : (
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
)}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
+24 -2
View File
@@ -72,6 +72,21 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
onGoto(az);
};
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
@@ -332,9 +347,16 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
{onStop && (
<button
type="button"
onClick={onStop}
onClick={pressStop}
title={t('rotor.stop')}
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
+41
View File
@@ -83,6 +83,7 @@ import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -1530,6 +1531,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -4677,6 +4681,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of
// text boxes whose contents only take effect on some other button is how
// work gets lost.
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
setClusterMacros(next);
saveClusterMacros(next);
};
return (
<>
<SectionHeader
@@ -4769,6 +4781,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('clu.autoConnect')}
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<div>
<span className="text-sm font-medium">{t('clu.macros')}</span>
<p className="text-xs text-muted-foreground">{t('clu.macrosHint')}</p>
</div>
{/* Two columns of six: twelve rows stacked would push everything else
in this panel off the screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
{clusterMacros.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
<Input
className="h-8 w-28 shrink-0 text-xs"
placeholder={t('clu.macroLabel')}
value={m.label}
maxLength={24}
onChange={(e) => setMacro(i, { label: e.target.value })}
/>
<Input
className="h-8 flex-1 min-w-0 font-mono text-xs"
placeholder={t('clu.macroCmd')}
value={m.cmd}
maxLength={120}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
+57
View File
@@ -0,0 +1,57 @@
// Cluster command macros — a named button for a command you would otherwise
// retype.
//
// Twelve slots, fixed. A list you can grow needs add/remove/reorder controls and
// a decision about what an empty row means; twelve boxes you fill in need
// neither, and nobody has thirteen cluster commands they use daily. A slot with
// no command is simply not drawn, so the toolbar is as long as the operator made
// it and no longer.
//
// Stored through writeUiPref like every other portable preference, so the
// buttons travel with data/ rather than living in one browser profile.
import { writeUiPref } from '@/lib/uiPref';
export type ClusterMacro = {
label: string; // what the button says
cmd: string; // what is sent to the master server
};
export const CLUSTER_MACRO_COUNT = 12;
export const clusterMacrosKey = 'opslog.clusterMacros';
export const emptyClusterMacros = (): ClusterMacro[] =>
Array.from({ length: CLUSTER_MACRO_COUNT }, () => ({ label: '', cmd: '' }));
// loadClusterMacros always returns exactly CLUSTER_MACRO_COUNT entries, whatever
// was stored: a saved list from a build with fewer slots must not leave the
// editor rendering undefined rows.
export function loadClusterMacros(): ClusterMacro[] {
const out = emptyClusterMacros();
try {
const raw = localStorage.getItem(clusterMacrosKey);
if (!raw) return out;
const v = JSON.parse(raw);
if (!Array.isArray(v)) return out;
for (let i = 0; i < CLUSTER_MACRO_COUNT && i < v.length; i++) {
out[i] = {
label: String(v[i]?.label ?? '').slice(0, 24),
cmd: String(v[i]?.cmd ?? '').slice(0, 120),
};
}
} catch { /* a corrupt preference is not worth failing the panel over */ }
return out;
}
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
// what decides: a slot with a label and no command is a button that lies, and a
// command with no label still has something to show — its own text.
export function visibleClusterMacros(macros: ClusterMacro[]): ClusterMacro[] {
return macros
.filter((m) => m.cmd.trim() !== '')
.map((m) => ({ label: m.label.trim() || m.cmd.trim(), cmd: m.cmd.trim() }));
}
File diff suppressed because one or more lines are too long
+3
View File
@@ -48,6 +48,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
'opslog.clusterMacros', // cluster console: the twelve named command buttons
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+23
View File
@@ -684,3 +684,26 @@ func TestCatalogForcesBuiltin(t *testing.T) {
t.Fatal("empty catalog")
}
}
// An award with NO QSO field at all counts only what the operator assigned by
// hand. WWBOTA is the case: bunker references have no ADIF field anywhere, so
// the definition names no field and the matcher must scan nothing rather than
// fall back on some default. The manual override still applies — it is the only
// thing that can feed such an award.
func TestComputeNoFieldManualOnly(t *testing.T) {
def := Def{Code: "WWBOTA", Type: TypeReference, Field: "", Dynamic: true, Valid: true}
qsos := []qso.QSO{
// Assigned by hand → counts.
{Callsign: "F4ABC", Band: "40m", Extras: map[string]string{ManualRefsKey: "WWBOTA@B/F-0123"}},
// Nothing assigned. The other fields are deliberately full: none of them
// may be scanned just because no field was named.
{Callsign: "F4DEF", Band: "20m", POTARef: "FF-0001", QTH: "B/F-0999", Comment: "B/F-0888"},
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
if r.Worked != 1 {
t.Errorf("WWBOTA worked = %d, want 1 (%v)", r.Worked, refCodes(r))
}
if got := refCodes(r); len(got) != 1 || got[0] != "B/F-0123" {
t.Errorf("WWBOTA refs = %v, want [B/F-0123]", got)
}
}
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -184,7 +184,29 @@ func (m *Manager) stopLocked() {
iv.Interrupt()
}
if done != nil {
<-done
// Bounded, and loud when it expires.
//
// This used to be a bare <-done. A poll goroutine wedged in a serial read
// then held every caller for ever: each restart attempt blocked inside
// Start before it could even try to connect, so the rig stayed dead and
// NOTHING was written to the log — the operator saw a CAT link that would
// not come back and no reason anywhere. Shutdown blocked on the same wait.
//
// Ten seconds is far longer than any honest disconnect. Past that the old
// poller is not coming back, and carrying on without it — noisily — beats
// freezing the application around it. The abandoned goroutine still holds
// its port, which is exactly what the log line has to say, because the
// next connect will fail because of it.
select {
case <-done:
case <-time.After(10 * time.Second):
name := "cat"
if b != nil {
name = b.Name()
}
debugLog.Printf("%s: poll loop did not stop within 10s — abandoning it; "+
"its port stays open, so the next connect may fail until OpsLog is restarted", name)
}
}
}
+48 -2
View File
@@ -223,6 +223,15 @@ type Server struct {
// which fills the whole rotating log with the same line and buries the
// evidence of anything else.
badPkts int
// drops counts events discarded because the consumer could not keep up —
// see the select at the foot of handle(). They used to vanish in silence,
// which made a missing decode indistinguishable from one the sender never
// broadcast: an operator comparing the panel against JTDX side by side was
// the only detector we had. A period's decodes arrive as one burst, so this
// is exactly when it happens.
drops int
lastDropL time.Time
}
// maxBadPktDumps is how many unparseable datagrams a listener describes in full
@@ -663,10 +672,43 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
select {
case s.out <- ev:
default:
// Drop on backpressure rather than block the read loop.
// Drop on backpressure rather than block the read loop — but say so.
s.noteDrop(ev)
}
}
// noteDrop reports events lost to a full queue, first one immediately and then
// at most one line a minute with the running total.
//
// Rate-limited because the condition is self-sustaining: a consumer that fell
// behind on one period is behind for the next, and a line per lost decode would
// bury the rest of the log under the symptom. The event's own description goes
// in, because "a decode was lost" and "a Status was lost" are different faults.
func (s *Server) noteDrop(ev Event) {
what := "event"
switch {
case ev.DecodeCall != "":
what = "decode from " + ev.DecodeCall
case ev.LoggedADIF != "":
what = "logged QSO"
case ev.TxMessage != "" || ev.DECall != "":
what = "transmit status"
}
s.mu.Lock()
s.drops++
n := s.drops
quiet := n > 1 && time.Since(s.lastDropL) < time.Minute
if !quiet {
s.lastDropL = time.Now()
}
s.mu.Unlock()
if quiet {
return
}
applog.Printf("udp: [%s] queue full — dropped %s (%d lost this session); "+
"the decodes panel is missing what the sender broadcast", s.cfg.Name, what, n)
}
func (s *Server) close() {
s.mu.Lock()
if s.stopped {
@@ -735,7 +777,11 @@ type Manager struct {
func NewManager(repo *Repo) *Manager {
return &Manager{
repo: repo,
out: make(chan Event, 64),
// 256, not 64: a period delivers twenty-odd decodes in one burst while
// Status keeps arriving, and the consumer does real work per event. The
// depth is headroom for that burst — the drop counter above is what says
// whether it was enough.
out: make(chan Event, 256),
inbound: map[int64]*Server{},
}
}