fix: while connected to MySQL if internet was lost no qso would be logged anymore
This commit is contained in:
+84
-2
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+6
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user