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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
{/* 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')}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,{' '}
|
||||
|
||||
Reference in New Issue
Block a user