feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort

- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown.
- Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment.
- Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped.
- Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time.
- Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
This commit is contained in:
2026-07-23 13:22:02 +02:00
parent 1070637c40
commit 2823f3e401
13 changed files with 606 additions and 214 deletions
+93 -7
View File
@@ -14,6 +14,7 @@ import {
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
SMTPConfigured, SendLogToDeveloper,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -959,8 +960,10 @@ export default function App() {
const dvkActiveRef = useRef(false);
const dvkPlayingRef = useRef(false);
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
const dvkMsgsRef = useRef<DVKMsg[]>([]);
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]);
useEffect(() => {
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
return () => { off?.(); };
@@ -972,7 +975,48 @@ export default function App() {
reloadDvk();
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
}, [dvkEnabled, reloadDvk]);
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
// DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's
// auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays
// once (and stops a running loop), so a report doesn't keep repeating.
const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1');
const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3);
const dvkAutoCqRef = useRef(dvkAutoCq);
const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs);
const dvkAutoCqGenRef = useRef(0);
const dvkAutoCqSlotRef = useRef(-1);
useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]);
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
// with speech on a data slot. Only allow it on phone modes.
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
const modeRef = useRef(mode);
useEffect(() => { modeRef.current = mode; }, [mode]);
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
async function runDvkAutoCq(slot: number) {
const gen = ++dvkAutoCqGenRef.current;
dvkAutoCqSlotRef.current = slot;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; }
await DVKPlay(slot).catch(() => {});
await sleep(300); // let playback flip "playing" true
let guard = 0; // then wait for it to finish (cap ~90 s)
while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
if (gen !== dvkAutoCqGenRef.current) break;
await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
}
}
const dvkPlay = useCallback((slot: number) => {
if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
const m = dvkMsgsRef.current.find((x) => x.slot === slot);
const isCQ = (m?.label || '').toUpperCase().includes('CQ');
if (dvkAutoCqRef.current && isCQ) {
runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change
} else {
stopDvkAutoCq();
DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e)));
}
}, []);
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
// it from the keyboard without clashing with the F1-F12 keyer macros).
@@ -1190,6 +1234,15 @@ export default function App() {
useEffect(() => {
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
}, []);
// Whether an SMTP server is configured — gates the "Send log to F4BPO" help
// entry. Re-checked when the Settings modal closes (the user may have just set
// it up), so the entry appears without a restart.
const [smtpConfigured, setSmtpConfigured] = useState(false);
const [sendingLog, setSendingLog] = useState(false);
useEffect(() => {
if (showSettings) return; // re-check after closing Settings
SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {});
}, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
@@ -2869,9 +2922,15 @@ export default function App() {
]},
{ name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
// Only when SMTP is set up: e-mail the diagnostic logs to the developer.
...(smtpConfigured ? [
{ type: 'separator' as const },
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []),
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
function handleMenu(action: string) {
switch (action) {
@@ -2900,6 +2959,21 @@ export default function App() {
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break;
}
}
// E-mail the diagnostic logs to the developer (Help → Send log to F4BPO).
async function sendLogToDeveloper() {
if (sendingLog) return;
setSendingLog(true);
try {
await SendLogToDeveloper();
showToast(t('help.sendLogOk'));
} catch (e: any) {
showToast(t('help.sendLogFail', { err: String(e?.message ?? e) }));
} finally {
setSendingLog(false);
}
}
@@ -2943,8 +3017,10 @@ export default function App() {
// callsign depends on the "ESC clears callsign" option; with the keyer
// off it always resets the entry (the classic behaviour).
if (e.key === 'Escape') {
// If a voice message is transmitting, ESC just stops it (keeps entry).
if (dvkActiveRef.current && dvkPlayingRef.current) {
// If a voice message is transmitting (or auto-CQ is looping), ESC stops it
// and cancels the loop (keeps entry).
if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) {
stopDvkAutoCq();
DVKStop();
e.preventDefault();
return;
@@ -4420,7 +4496,11 @@ export default function App() {
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside.
<div className="flex-1 min-w-0 min-h-0 relative">
<div className="absolute inset-0 flex gap-2.5 items-stretch">
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
their freq/mode (colour-coded) and OpsLog version. */}
{showLiveStations && dbConn?.backend === 'mysql' && (
@@ -4503,13 +4583,18 @@ export default function App() {
</div>
)}
{dvkEnabled && (
<div className="w-[264px] shrink-0 min-h-0">
<div className="w-[320px] shrink-0 min-h-0">
<DvkPanel
messages={dvkMsgs}
status={dvkStat}
onPlay={dvkPlay}
onStop={() => DVKStop()}
onStop={() => { stopDvkAutoCq(); DVKStop(); }}
onClose={() => setDvkEnabled(false)}
autoCq={dvkAutoCq}
autoCqSecs={dvkAutoCqSecs}
onToggleAutoCq={setDvkAutoCq}
onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))}
phoneOk={isPhoneMode(mode)}
/>
</div>
)}
@@ -4574,6 +4659,7 @@ export default function App() {
</div>
)}
</div>
</div>
)}
</div>{/* /entry + aside row */}