fix: while connected to MySQL if internet was lost no qso would be logged anymore
This commit is contained in:
@@ -42,6 +42,7 @@ import (
|
|||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
"hamlog/internal/lotwusers"
|
"hamlog/internal/lotwusers"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
@@ -456,6 +457,9 @@ type App struct {
|
|||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||||
|
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
||||||
|
offlineMode bool // last write failed because the DB was unreachable
|
||||||
|
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||||
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
catFlexDecodeSecs int // decode spot display duration (seconds)
|
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||||
@@ -772,6 +776,25 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||||
}()
|
}()
|
||||||
|
// Offline safety net: with the shared MySQL logbook, losing the network would
|
||||||
|
// otherwise mean you simply cannot log. Instead, a QSO that can't reach the DB
|
||||||
|
// is parked in a local ADIF outbox and replayed automatically once the DB
|
||||||
|
// answers again. Anything already waiting from a previous session (a crash, a
|
||||||
|
// quit while offline) is replayed at startup.
|
||||||
|
a.offlineQ = newOfflineQueue(dataDir)
|
||||||
|
if n := a.offlineQ.Count(); n > 0 {
|
||||||
|
a.offlineMode = true
|
||||||
|
applog.Printf("offline: %d QSO(s) waiting in %s", n, a.offlineQ.Path())
|
||||||
|
}
|
||||||
|
go a.offlineReplayLoop()
|
||||||
|
go func() {
|
||||||
|
if a.offlineQ.Count() > 0 {
|
||||||
|
if n, err := a.replayOfflineQueue(); err == nil && n > 0 {
|
||||||
|
applog.Printf("offline: replayed %d QSO(s) left over from a previous session", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
||||||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||||||
// demand. Resolution applied only when the user enables it.
|
// demand. Resolution applied only when the user enables it.
|
||||||
@@ -1711,6 +1734,17 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
id, err = a.qso.Add(a.ctx, q)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
|
if err != nil && db.IsConnLost(err) {
|
||||||
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
|
// offline outbox rather than lose it. Returns id = -1 so the UI can say
|
||||||
|
// "saved, waiting to sync" instead of showing a failure. The post-save
|
||||||
|
// side effects (recording, uploads, UDP) are skipped: they need a real
|
||||||
|
// row id and will not be replayed — the operator can upload later.
|
||||||
|
if a.queueOffline(q, err) {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
// Couldn't even write the outbox → report the real error, never pretend.
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -1722,6 +1756,11 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
||||||
}
|
}
|
||||||
|
// A successful write means the link is healthy again — if QSOs are still
|
||||||
|
// parked, get them in now rather than waiting for the next tick.
|
||||||
|
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||||
|
go func() { _, _ = a.replayOfflineQueue() }()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-2
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
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,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
|
GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
ContestDupe,
|
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 };
|
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 [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||||
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
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;
|
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
@@ -1988,7 +2007,13 @@ export default function App() {
|
|||||||
srx: rE.num, srx_string: rE.str,
|
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.
|
// Advance the sent serial only after a successful log.
|
||||||
if (contest.active && contest.code && contest.exchange === 'serial') {
|
if (contest.active && contest.code && contest.exchange === 'serial') {
|
||||||
updateContest({ nextSerial: contest.nextSerial + 1 });
|
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
|
// 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
|
// 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).
|
// 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;
|
const hasAlerts = recentAlerts.length > 0;
|
||||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||||
const alertLedBlock = !hasAlerts ? null : (
|
const alertLedBlock = !hasAlerts ? null : (
|
||||||
@@ -3738,6 +3819,7 @@ export default function App() {
|
|||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
{lotwBlock}
|
{lotwBlock}
|
||||||
|
{offlineBlock}
|
||||||
{alertLedBlock}
|
{alertLedBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ const en: Dict = {
|
|||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'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.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
'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',
|
'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.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'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.",
|
'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.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',
|
'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 GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
@@ -340,6 +342,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -624,6 +628,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
|
|||||||
@@ -622,6 +622,10 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOfflineStatus() {
|
||||||
|
return window['go']['main']['App']['GetOfflineStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetOnlineOperators() {
|
export function GetOnlineOperators() {
|
||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
@@ -638,6 +642,10 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPendingQSOs() {
|
||||||
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1214,10 @@ export function RestartQSORecorder() {
|
|||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetryOfflineSync() {
|
||||||
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1828,6 +1828,22 @@ export namespace main {
|
|||||||
this.database = source["database"];
|
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 {
|
export class PGXLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
|
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||||
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, true)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsConnLost reports whether err means the database was UNREACHABLE (network
|
||||||
|
// down, server gone, dead pooled connection) as opposed to a legitimate
|
||||||
|
// server-side SQL error (constraint violation, bad data, syntax).
|
||||||
|
//
|
||||||
|
// This distinction is the linchpin of the offline safety net: a QSO is parked in
|
||||||
|
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
|
||||||
|
// genuine data bug would silently vanish into the file instead of being
|
||||||
|
// reported — the worst possible outcome.
|
||||||
|
//
|
||||||
|
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
|
||||||
|
// a connection loss, whatever its code.
|
||||||
|
func IsConnLost(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The server responded → a real SQL error, not a lost link.
|
||||||
|
var me *mysql.MySQLError
|
||||||
|
if errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var oe *net.OpError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The MySQL driver reports a few of these as plain strings.
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
for _, p := range []string{
|
||||||
|
"invalid connection",
|
||||||
|
"bad connection",
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"broken pipe",
|
||||||
|
"no such host",
|
||||||
|
"i/o timeout",
|
||||||
|
"dial tcp",
|
||||||
|
"unexpected eof",
|
||||||
|
"driver: bad connection",
|
||||||
|
"can't connect",
|
||||||
|
"network is unreachable",
|
||||||
|
"host is unreachable",
|
||||||
|
} {
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
|||||||
// N QSOs is one HTTP request instead of N realtime.php calls.
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
|
// clublogUserAgent identifies OpsLog to Club Log. Go's default
|
||||||
|
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
|
||||||
|
// returns 403 Forbidden before the request reaches the app), so every request
|
||||||
|
// must send a real, app-identifying User-Agent.
|
||||||
|
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 120 * time.Second}
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
}
|
}
|
||||||
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
return UploadResult{OK: true, Message: msg}, nil
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
}
|
}
|
||||||
if msg == "" {
|
diag := clublogFailureDiag(resp, msg)
|
||||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
|
||||||
}
|
}
|
||||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
|
||||||
|
// clublogFailureDiag turns a non-200 response into a readable one-liner that
|
||||||
|
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
|
||||||
|
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
|
||||||
|
// page means the request never reached the app; the fingerprint headers below
|
||||||
|
// tell the operator whether it's Club Log's own WAF or something on their side.
|
||||||
|
func clublogFailureDiag(resp *http.Response, body string) string {
|
||||||
|
server := strings.TrimSpace(resp.Header.Get("Server"))
|
||||||
|
// Notable intermediary fingerprints — presence points at the culprit.
|
||||||
|
fp := []string{}
|
||||||
|
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
|
||||||
|
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
|
||||||
|
fp = append(fp, h+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse the HTML error page to something short.
|
||||||
|
summary := stripHTMLBrief(body)
|
||||||
|
if summary == "" {
|
||||||
|
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
|
||||||
|
if server != "" {
|
||||||
|
out += " [server=" + server + "]"
|
||||||
|
}
|
||||||
|
if len(fp) > 0 {
|
||||||
|
out += " [" + strings.Join(fp, " ") + "]"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
|
||||||
|
// chars of visible text — enough to read "403 Forbidden" without the markup.
|
||||||
|
func stripHTMLBrief(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if len(out) > 160 {
|
||||||
|
out = out[:160] + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 20 * time.Second}
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if ee, ok := runErr.(*exec.ExitError); ok {
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||||
code = ee.ExitCode()
|
code = ee.ExitCode()
|
||||||
|
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
|
||||||
|
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
|
||||||
|
// rights (its "Run as administrator" compatibility flag, or an
|
||||||
|
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
|
||||||
|
// refuses to launch it. Actionable message instead of the raw error.
|
||||||
|
return UploadResult{}, fmt.Errorf(
|
||||||
|
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
|
||||||
|
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
|
||||||
|
"Or run OpsLog itself as administrator.", tqsl)
|
||||||
} else {
|
} else {
|
||||||
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Package offlineq is OpsLog's offline safety net.
|
||||||
|
//
|
||||||
|
// When the shared MySQL logbook is unreachable, a QSO must never be lost just
|
||||||
|
// because the network blinked. Instead of failing, the QSO is appended to a
|
||||||
|
// local ADIF file (the "outbox") and replayed into the database as soon as it
|
||||||
|
// comes back — then the file is ARCHIVED, never deleted.
|
||||||
|
//
|
||||||
|
// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs.
|
||||||
|
// There is no mirror, no pull, no merge, no tombstones — which is exactly why it
|
||||||
|
// stays small. The cost, accepted by design: during an outage you don't see other
|
||||||
|
// operators' QSOs and the worked-before check doesn't know about your pending
|
||||||
|
// ones. See the queue view in the UI for what's waiting.
|
||||||
|
//
|
||||||
|
// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder
|
||||||
|
// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're
|
||||||
|
// escaping in the first place.
|
||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileName is the outbox. Pending QSOs accumulate here while the DB is down.
|
||||||
|
const FileName = "opslog-pending.adi"
|
||||||
|
|
||||||
|
// Queue owns the outbox file. All operations are serialised: the logging path
|
||||||
|
// (append) and the replay loop (read/rewrite/archive) run on different
|
||||||
|
// goroutines.
|
||||||
|
type Queue struct {
|
||||||
|
dir string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a queue backed by <dir>/opslog-pending.adi.
|
||||||
|
func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} }
|
||||||
|
|
||||||
|
// Path is the outbox file's location (shown in the UI so the operator always
|
||||||
|
// knows where their QSOs physically are).
|
||||||
|
func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) }
|
||||||
|
|
||||||
|
// newQueueID mints the id that makes replay idempotent.
|
||||||
|
func newQueueID() string {
|
||||||
|
var b [12]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a
|
||||||
|
// repeated replay can recognise it. The file is created with an ADIF header on
|
||||||
|
// first use, then appended to — an append can't corrupt what's already there.
|
||||||
|
func (q *Queue) Append(rec qso.QSO) (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if rec.Extras == nil {
|
||||||
|
rec.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey])
|
||||||
|
if qid == "" {
|
||||||
|
qid = newQueueID()
|
||||||
|
rec.Extras[qso.OfflineQueueKey] = qid
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(q.dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: create dir: %w", err)
|
||||||
|
}
|
||||||
|
path := q.Path()
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if _, err := f.WriteString(b.String()); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: write: %w", err)
|
||||||
|
}
|
||||||
|
// Flush to disk: the whole point is surviving a crash/power cut.
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: sync: %w", err)
|
||||||
|
}
|
||||||
|
return qid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending parses the outbox into QSOs (each carrying its queue id in Extras).
|
||||||
|
// A missing file simply means nothing is pending.
|
||||||
|
func (q *Queue) Pending() ([]qso.QSO, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.pendingLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) pendingLocked() ([]qso.QSO, error) {
|
||||||
|
f, err := os.Open(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []qso.QSO
|
||||||
|
err = adif.Parse(f, func(rec adif.Record) error {
|
||||||
|
if v, ok := adif.RecordToQSO(rec); ok {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count is how many QSOs are waiting (0 when the file is absent).
|
||||||
|
func (q *Queue) Count() int {
|
||||||
|
p, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite replaces the outbox with exactly these QSOs — used after a replay to
|
||||||
|
// keep only the ones that FAILED. Written to a temp file and renamed, so a crash
|
||||||
|
// mid-write can't truncate the queue.
|
||||||
|
func (q *Queue) Rewrite(keep []qso.QSO) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.rewriteLocked(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) rewriteLocked(keep []qso.QSO) error {
|
||||||
|
path := q.Path()
|
||||||
|
if len(keep) == 0 {
|
||||||
|
// Nothing left: remove the (now empty) outbox. The caller archives the
|
||||||
|
// original content first, so this never destroys the only copy.
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("offlineq: remove: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
for _, v := range keep {
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: replace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the
|
||||||
|
// QSOs always exist somewhere on disk even if the replay later turns out to have
|
||||||
|
// gone wrong. Deleting the only copy of someone's contacts is the one mistake
|
||||||
|
// you don't get to undo.
|
||||||
|
func (q *Queue) Archive() (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504"))
|
||||||
|
dst := filepath.Join(q.dir, name)
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: archive: %w", err)
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The outbox is the ONLY copy of a QSO logged while the database was down, so the
|
||||||
|
// round-trip must be lossless: append → read back identical (callsign, band, mode,
|
||||||
|
// date) and each record must carry a queue id (what makes the replay idempotent).
|
||||||
|
func TestQueueRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
q := New(dir)
|
||||||
|
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Fatalf("fresh queue: count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
mk := func(call, band, mode string) qso.QSO {
|
||||||
|
return qso.QSO{
|
||||||
|
Callsign: call, Band: band, Mode: mode,
|
||||||
|
QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id1, err := q.Append(mk("K1ABC", "20m", "SSB"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 1: %v", err)
|
||||||
|
}
|
||||||
|
id2, err := q.Append(mk("DL1XYZ", "40m", "CW"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 2: %v", err)
|
||||||
|
}
|
||||||
|
if id1 == "" || id2 == "" || id1 == id2 {
|
||||||
|
t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("pending = %d, want 2", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" {
|
||||||
|
t.Errorf("record 1 round-tripped wrong: %+v", pending[0])
|
||||||
|
}
|
||||||
|
if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" {
|
||||||
|
t.Errorf("record 2 round-tripped wrong: %+v", pending[1])
|
||||||
|
}
|
||||||
|
// The queue id must survive the ADIF round-trip — without it the replay
|
||||||
|
// can't be idempotent and a crash would duplicate contacts.
|
||||||
|
for i, p := range pending {
|
||||||
|
if p.Extras[qso.OfflineQueueKey] == "" {
|
||||||
|
t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive keeps a copy BEFORE we clear anything.
|
||||||
|
arch, err := q.Archive()
|
||||||
|
if err != nil || arch == "" {
|
||||||
|
t.Fatalf("archive: %v (path %q)", err, arch)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(arch); err != nil {
|
||||||
|
t.Fatalf("archive file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial replay: the first QSO went in, the second failed → keep only it.
|
||||||
|
if err := q.Rewrite(pending[1:]); err != nil {
|
||||||
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
left, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending after rewrite: %v", err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].Callsign != "DL1XYZ" {
|
||||||
|
t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything replayed → the outbox is removed (the archive still holds it).
|
||||||
|
if err := q.Rewrite(nil); err != nil {
|
||||||
|
t.Fatalf("rewrite empty: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("outbox should be gone once fully replayed, stat err = %v", err)
|
||||||
|
}
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Errorf("count after full replay = %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1011,6 +1011,32 @@ func FilterableFields() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// columnExpr resolves a filter field to a safe SQL expression — either a
|
// columnExpr resolves a filter field to a safe SQL expression — either a
|
||||||
|
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
|
||||||
|
// offline safety file. It survives the ADIF round-trip and makes the replay
|
||||||
|
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
|
||||||
|
// the file", the next replay sees the id already in the log and skips it instead
|
||||||
|
// of creating a duplicate.
|
||||||
|
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"
|
||||||
|
|
||||||
|
// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
|
||||||
|
// in the logbook — the guard that makes replaying the safety file safe to repeat.
|
||||||
|
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
|
||||||
|
qid = strings.TrimSpace(qid)
|
||||||
|
if qid == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
|
||||||
|
if db.IsMySQL() {
|
||||||
|
expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// whitelisted column name or a json_extract over extras_json.
|
// whitelisted column name or a json_extract over extras_json.
|
||||||
func columnExpr(field string) (string, bool) {
|
func columnExpr(field string) (string, bool) {
|
||||||
f := strings.ToLower(strings.TrimSpace(field))
|
f := strings.ToLower(strings.TrimSpace(field))
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Offline safety net ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// With the shared MySQL logbook, the network sits on the critical path of every
|
||||||
|
// write: lose it and you can't log at all. Rather than a fragile "fall back to
|
||||||
|
// SQLite" (which would leave you with a partial log and needs a restart), we
|
||||||
|
// keep the architecture exactly as it is and add a NET:
|
||||||
|
//
|
||||||
|
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
|
||||||
|
// DB back → replayed into the logbook, idempotently, file archived
|
||||||
|
//
|
||||||
|
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
|
||||||
|
// keeps it small. Accepted trade-off: while offline you don't see other ops'
|
||||||
|
// QSOs, and worked-before doesn't know about your own pending ones (they're
|
||||||
|
// listed separately in the UI instead).
|
||||||
|
|
||||||
|
// offlineReplayEvery is how often we re-probe the database while QSOs are
|
||||||
|
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
|
||||||
|
const offlineReplayEvery = 15 * time.Second
|
||||||
|
|
||||||
|
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
|
||||||
|
type OfflineStatus struct {
|
||||||
|
Offline bool `json:"offline"` // last write failed because the DB was unreachable
|
||||||
|
Pending int `json:"pending"` // QSOs sitting in the outbox
|
||||||
|
Path string `json:"path"` // where the outbox physically is
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueOffline parks a QSO that couldn't reach the database. Returns false when
|
||||||
|
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
|
||||||
|
// outbox itself failed — in which case the caller MUST surface the original error
|
||||||
|
// rather than pretend the QSO was saved.
|
||||||
|
func (a *App) queueOffline(q qso.QSO, cause error) bool {
|
||||||
|
if a.offlineQ == nil || !db.IsMySQL() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qid, err := a.offlineQ.Append(q)
|
||||||
|
if err != nil {
|
||||||
|
// The net itself tore: do NOT claim success.
|
||||||
|
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
|
||||||
|
a.offlineMode = true
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitOfflineStatus pushes the banner state to the UI.
|
||||||
|
func (a *App) emitOfflineStatus() {
|
||||||
|
if a.ctx == nil || a.offlineQ == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
|
||||||
|
func (a *App) GetOfflineStatus() OfflineStatus {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return OfflineStatus{}
|
||||||
|
}
|
||||||
|
n := a.offlineQ.Count()
|
||||||
|
return OfflineStatus{
|
||||||
|
Offline: a.offlineMode && n > 0,
|
||||||
|
Pending: n,
|
||||||
|
Path: a.offlineQ.Path(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
|
||||||
|
// what they logged while the database was down instead of flying blind.
|
||||||
|
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return a.offlineQ.Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
|
||||||
|
// waiting for the next tick.
|
||||||
|
func (a *App) RetryOfflineSync() (int, error) {
|
||||||
|
return a.replayOfflineQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
|
||||||
|
// them the moment it answers.
|
||||||
|
func (a *App) offlineReplayLoop() {
|
||||||
|
t := time.NewTicker(offlineReplayEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.logDb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err := a.logDb.PingContext(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
continue // still down — keep waiting, the QSOs are safe on disk
|
||||||
|
}
|
||||||
|
if n, rerr := a.replayOfflineQueue(); rerr != nil {
|
||||||
|
applog.Printf("offline: replay failed: %v", rerr)
|
||||||
|
} else if n > 0 {
|
||||||
|
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
|
||||||
|
// parked QSO carries a queue id, and one already present in the log is skipped —
|
||||||
|
// so a crash between "inserted" and "removed from the file" can never duplicate a
|
||||||
|
// contact. The file is archived BEFORE being cleared.
|
||||||
|
func (a *App) replayOfflineQueue() (int, error) {
|
||||||
|
if a.offlineQ == nil || a.qso == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
pending, err := a.offlineQ.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
a.offlineMode = false
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
var failed []qso.QSO
|
||||||
|
for _, q := range pending {
|
||||||
|
qid := ""
|
||||||
|
if q.Extras != nil {
|
||||||
|
qid = q.Extras[qso.OfflineQueueKey]
|
||||||
|
}
|
||||||
|
// Idempotency guard: already replayed on an earlier (interrupted) pass?
|
||||||
|
if qid != "" {
|
||||||
|
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
|
||||||
|
imported++ // it IS in the log — treat as done, drop from the outbox
|
||||||
|
continue
|
||||||
|
} else if e != nil && db.IsConnLost(e) {
|
||||||
|
failed = append(failed, q) // DB went away again mid-replay
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, e := a.qso.Add(a.ctx, q); e != nil {
|
||||||
|
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
|
||||||
|
failed = append(failed, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the outbox as it was, then keep only what still failed.
|
||||||
|
if imported > 0 {
|
||||||
|
if dst, e := a.offlineQ.Archive(); e != nil {
|
||||||
|
applog.Printf("offline: archive failed: %v", e)
|
||||||
|
} else if dst != "" {
|
||||||
|
applog.Printf("offline: outbox archived to %s", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := a.offlineQ.Rewrite(failed); e != nil {
|
||||||
|
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.offlineMode = len(failed) > 0
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
if imported > 0 && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
|
||||||
|
}
|
||||||
|
return imported, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
|
||||||
|
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
|
||||||
|
// very thing this design avoids.
|
||||||
|
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
//go:build !windows
|
//go:build !windows || bindings
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
// acquireSingleInstance is a no-op off Windows (the single-instance guard uses a
|
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
||||||
// Windows named mutex). Always allows the app to start.
|
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
||||||
|
// runs this binary, and a real OpsLog already running would otherwise make it
|
||||||
|
// exit before Wails could reflect the bindings.
|
||||||
func acquireSingleInstance() bool { return true }
|
func acquireSingleInstance() bool { return true }
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
//go:build windows
|
//go:build windows && !bindings
|
||||||
|
|
||||||
|
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
|
||||||
|
// RUNNING this binary. With the guard active, a normal OpsLog already running on
|
||||||
|
// the dev machine holds the mutex, the generator's process exits instantly, and
|
||||||
|
// no bindings are produced. Excluding the guard from that build keeps generation
|
||||||
|
// working while shipping builds still get it.
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user