feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:
- Stop() did not wait for the poll loop, so a stopped client kept the COM
port. Every later client then failed with "Serial port busy" — the
program holding it being OpsLog itself.
- startUltrabeam tore the old client down CONCURRENTLY with starting the
new one, and "Test connection" built a second client on a port already
ours. Harmless over TCP, fatal on a port with one owner.
- A silent serial port returns (0, nil) and bufio retries that a hundred
times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
nothing.
The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.
Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.
Also:
- Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
searched, ready for the card dates.
- The record button no longer goes missing after an update: whether manual
recording is possible is a per-profile question that was asked once, at
startup, before the profile was known.
- Alert rules and filter presets confirm that they were saved.
- Spot clicks on the radio panadapter carry the POTA park into F3.
- The build gate is re-checked wherever the active callsign can change; it
ran at startup alone, and a fresh install has no callsign then.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
@@ -102,17 +102,40 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||
});
|
||||
|
||||
// Saving used to be silent: the dialog stays open and the rule looks exactly
|
||||
// as it did a second earlier, so there was nothing to tell an operator whether
|
||||
// the click had registered. A line that says so, and clears itself.
|
||||
const [savedMsg, setSavedMsg] = useState('');
|
||||
const savedTimer = useRef(0);
|
||||
function flashSaved(text: string) {
|
||||
setSavedMsg(text);
|
||||
window.clearTimeout(savedTimer.current);
|
||||
savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000);
|
||||
}
|
||||
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
try {
|
||||
const saved = await SaveAlertRule(draft);
|
||||
await refresh();
|
||||
loadDraft(saved as Rule);
|
||||
setErr('');
|
||||
flashSaved(t('altm.saved', { name: draft.name.trim() }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { loadDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||
try {
|
||||
const name = draft.name;
|
||||
await DeleteAlertRule(draft.id);
|
||||
loadDraft(null);
|
||||
await refresh();
|
||||
flashSaved(t('altm.deleted', { name }));
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -257,6 +280,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
{/* Editor actions */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||
{!err && savedMsg && <span className="text-[11px] text-success flex-1 truncate">{savedMsg}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||
|
||||
Reference in New Issue
Block a user