fix: while connected to MySQL if internet was lost no qso would be logged anymore

This commit is contained in:
2026-07-12 18:01:03 +02:00
parent 7398261c50
commit a00817b93e
16 changed files with 850 additions and 9 deletions
+84 -2
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
ContestDupe,
@@ -830,6 +831,24 @@ export default function App() {
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
// Offline outbox: QSOs parked locally because the database was unreachable.
const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
const [pendingOpen, setPendingOpen] = useState(false);
const [pendingList, setPendingList] = useState<any[]>([]);
useEffect(() => {
const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
load();
const off = EventsOn('offline:status', (s: any) => {
setOfflineStatus(s);
// Keep the open list in step as QSOs are parked or replayed.
if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
});
return () => { off?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const pendingOpenRef = useRef(false);
useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
const ALERT_TTL_MS = 10 * 60 * 1000;
useEffect(() => {
const id = window.setInterval(() => {
@@ -1988,7 +2007,13 @@ export default function App() {
srx: rE.num, srx_string: rE.str,
});
}
await AddQSO(payload);
// id = -1 means the database was unreachable and the QSO was parked in the
// offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
// it as a success so logging never stops; the banner tells the operator.
const newId = await AddQSO(payload);
if (typeof newId === 'number' && newId < 0) {
showToast(t('offline.queued'));
}
// Advance the sent serial only after a successful log.
if (contest.active && contest.code && contest.exchange === 'serial') {
updateContest({ nextSerial: contest.nextSerial + 1 });
@@ -2777,6 +2802,62 @@ export default function App() {
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
// row instead of the intrusive floating cards. It lights + shows a count when
// alerts are pending; click it to see the last 3 (each clickable to tune).
// ── Offline outbox (safety net) ──────────────────────────────────────
// When the shared database is unreachable, QSOs are parked in a local ADIF
// file rather than lost. Surface that HONESTLY: the operator must know their
// worked-before check doesn't include these, and see what's waiting.
const offlinePendingCount = offlineStatus.pending ?? 0;
const offlineBlock = offlinePendingCount === 0 ? null : (
<div className="relative self-end mb-0.5 shrink-0">
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
title={t('offline.tip', { n: offlinePendingCount })}
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
<CloudOff className="size-4" />
<span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
</button>
{pendingOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
<div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
<p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
<p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
</div>
<div className="max-h-72 overflow-auto divide-y divide-border/60">
{pendingList.length === 0 ? (
<p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
) : pendingList.map((q, i) => (
<div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
<span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
<span className="text-muted-foreground w-12">{q.band}</span>
<span className="text-muted-foreground w-14">{q.mode}</span>
<span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
{q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
</span>
</div>
))}
</div>
<div className="px-3 py-2 border-t border-border flex items-center gap-2">
<button type="button"
onClick={async () => {
try {
const n = await RetryOfflineSync();
if (n > 0) showToast(t('offline.synced', { n }));
else showToast(t('offline.stillDown'));
GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
} catch (e: any) { setError(String(e?.message ?? e)); }
}}
className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
{t('offline.retry')}
</button>
<span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
</div>
</div>
</>
)}
</div>
);
const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise.
const alertLedBlock = !hasAlerts ? null : (
@@ -3738,6 +3819,7 @@ export default function App() {
{qthBlock}
{gridBlock}
{lotwBlock}
{offlineBlock}
{alertLedBlock}
</div>
+16
View File
@@ -46,6 +46,14 @@ const en: Dict = {
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
'offline.title': 'Offline — {n} QSO(s) waiting',
'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.",
'offline.empty': 'Nothing waiting.',
'offline.retry': 'Retry now',
'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
@@ -256,6 +264,14 @@ const fr: Dict = {
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
'offline.tip': '{n} QSO en attente — la base est injoignable',
'offline.title': 'Hors ligne — {n} QSO en attente',
'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.",
'offline.empty': 'Rien en attente.',
'offline.retry': 'Réessayer maintenant',
'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
+6
View File
@@ -332,6 +332,8 @@ export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
@@ -340,6 +342,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>;
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>;
@@ -624,6 +628,8 @@ export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>;
+12
View File
@@ -622,6 +622,10 @@ export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
export function GetOfflineStatus() {
return window['go']['main']['App']['GetOfflineStatus']();
}
export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
@@ -638,6 +642,10 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
export function GetPendingQSOs() {
return window['go']['main']['App']['GetPendingQSOs']();
}
export function GetQSLDefaults() {
return window['go']['main']['App']['GetQSLDefaults']();
}
@@ -1206,6 +1214,10 @@ export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder']();
}
export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
}
+16
View File
@@ -1828,6 +1828,22 @@ export namespace main {
this.database = source["database"];
}
}
export class OfflineStatus {
offline: boolean;
pending: number;
path: string;
static createFrom(source: any = {}) {
return new OfflineStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.offline = source["offline"];
this.pending = source["pending"];
this.path = source["path"];
}
}
export class PGXLSettings {
enabled: boolean;
host: string;