From 29fd832bcdf7d96af6f606950bfda01b577dac80 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sun, 14 Jun 2026 01:35:40 +0200 Subject: [PATCH] up --- app.go | 130 +++++++++++++++++----- frontend/src/App.tsx | 62 +++++++++-- frontend/src/components/SettingsModal.tsx | 44 ++++++-- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 14 +++ internal/backup/backup.go | 92 +++++++++++++-- 7 files changed, 290 insertions(+), 58 deletions(-) diff --git a/app.go b/app.go index b5efdc8..ef7d1f2 100644 --- a/app.go +++ b/app.go @@ -556,31 +556,10 @@ func (a *App) startup(ctx context.Context) { } a.db = conn - // Choose where the QSO logbook lives. On a MySQL failure we fall back to the - // local SQLite logbook so the operator can still log (and fix the config). - logbookConn := conn - if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled { - applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database) - mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{ - Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database, - }) - if mErr != nil { - applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr) - a.dbBackendErr = "MySQL: " + mErr.Error() - } else { - logbookConn = mysqlConn - a.dbBackend = "mysql" - } - } - if a.dbBackend == "" { - a.dbBackend = "sqlite" - } - // db.Dialect describes the LOGBOOK backend — the only place SQL actually - // varies (qso JSON extraction). Config repos always run on SQLite. - db.SetDialect(a.dbBackend) - applog.Printf("startup: logbook backend = %s", a.dbBackend) - a.logDb = logbookConn - a.qso = qso.NewRepo(logbookConn) + // Wire the LOCAL config repos first — they're backed by the already-open + // SQLite file, so the station/profiles/settings are ready instantly. Doing + // this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI + // doesn't briefly think the station is unconfigured while MySQL is dialing. a.settings = settings.NewStore(conn) a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.profiles = profile.NewRepo(conn) @@ -609,6 +588,32 @@ func (a *App) startup(ctx context.Context) { a.lookup = lookup.NewManager(a.cache) a.reloadLookupProviders() + // Now choose where the QSO logbook lives. On a MySQL failure we fall back to + // the local SQLite logbook so the operator can still log (and fix config). + logbookConn := conn + if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled { + applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database) + mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{ + Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database, + }) + if mErr != nil { + applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr) + a.dbBackendErr = "MySQL: " + mErr.Error() + } else { + logbookConn = mysqlConn + a.dbBackend = "mysql" + } + } + if a.dbBackend == "" { + a.dbBackend = "sqlite" + } + // db.Dialect describes the LOGBOOK backend — the only place SQL actually + // varies (qso JSON extraction). Config repos always run on SQLite. + db.SetDialect(a.dbBackend) + applog.Printf("startup: logbook backend = %s", a.dbBackend) + a.logDb = logbookConn + a.qso = qso.NewRepo(logbookConn) + // cty.dat for offline DXCC / country resolution. Cached on disk; first // run downloads it from country-files.com in the background so startup // stays fast even if the network is slow. @@ -877,12 +882,22 @@ func (a *App) runBackupForShutdown() error { if folder == "" { folder = s.DefaultFolder } - if backup.HasBackupToday(folder) { + mysql := a.dbBackend == "mysql" + done := backup.HasBackupToday(folder) + if mysql { + done = backup.HasADIFBackupToday(folder) + } + if done { return nil } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { return err } + if mysql { + if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil { + return err + } + } return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) } @@ -1103,6 +1118,30 @@ func (a *App) GetDBBackendStatus() DBBackendStatus { } } +// DBConnectionInfo is a compact description of where the QSO logbook lives, for +// the status bar: a MySQL server endpoint or the local SQLite file path. +type DBConnectionInfo struct { + Backend string `json:"backend"` // "sqlite" | "mysql" + Label string `json:"label"` // "host:port/database" or the .db path +} + +// GetDBConnectionInfo reports the logbook connection for display in the status +// bar. For MySQL it shows host:port/database (the shared logbook); for SQLite +// it shows the local database file path. +func (a *App) GetDBConnectionInfo() DBConnectionInfo { + if a.dbBackend == "mysql" { + if mb := readBootstrap(a.dataDir).MySQL; mb != nil { + port := mb.Port + if port == 0 { + port = 3306 + } + return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", mb.Host, port, mb.Database)} + } + return DBConnectionInfo{Backend: "mysql", Label: "MySQL"} + } + return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath} +} + // GetMySQLSettings returns the stored shared-database config from the bootstrap // file (config.json), with defaults applied. Read before the DB is open, so it // must not depend on the settings table. @@ -5748,14 +5787,38 @@ func (a *App) RunBackupNow() (string, error) { if folder == "" { folder = s.DefaultFolder } + // Always snapshot the local SQLite (config + any pre-MySQL local QSOs). path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip) if err != nil { return path, err } + // On MySQL the live QSO log isn't in the local DB — export it to ADIF so the + // contacts are actually protected. The ADIF path is the one we surface. + if a.dbBackend == "mysql" { + adiPath, aerr := a.backupLogADIF(folder, s.Rotation, s.Zip) + if aerr != nil { + return adiPath, aerr + } + path = adiPath + } a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339)) return path, nil } +// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the +// backup folder. The full set of ADIF + app fields is included so the backup is +// a complete, re-importable copy of the log. +func (a *App) backupLogADIF(folder string, rotation int, zip bool) (string, error) { + if a.qso == nil { + return "", fmt.Errorf("logbook not initialized") + } + return backup.RunADIF(folder, rotation, zip, func(p string) error { + ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true} + _, e := ex.ExportFile(a.ctx, p) + return e + }) +} + // maybeShutdownBackup runs a backup at shutdown if the user enabled it // and no backup for today already exists. Running at shutdown (not at // startup) means the snapshot includes the QSOs the user just logged @@ -5773,13 +5836,26 @@ func (a *App) maybeShutdownBackup() { if folder == "" { folder = s.DefaultFolder } - if backup.HasBackupToday(folder) { + mysql := a.dbBackend == "mysql" + // In MySQL mode the ADIF log export is the backup that matters; gate the + // "already done today" skip on whichever backup type applies. + done := backup.HasBackupToday(folder) + if mysql { + done = backup.HasADIFBackupToday(folder) + } + if done { return } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { fmt.Println("OpsLog: shutdown backup failed:", err) return } + if mysql { + if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil { + fmt.Println("OpsLog: shutdown ADIF log backup failed:", err) + return + } + } a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339)) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7891e24..83db057 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock, + AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, } from 'lucide-react'; @@ -17,6 +17,7 @@ import { GetSecretStatus, UnlockSecrets, RefreshCtyDat, RotatorGoTo, RotatorStop, GetRotatorHeading, + GetDBConnectionInfo, GetUltrabeamStatus, SetUltrabeamDirection, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, @@ -320,6 +321,7 @@ export default function App() { const [catState, setCatState] = useState({ enabled: false, connected: false } as any); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); + const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. @@ -798,7 +800,11 @@ export default function App() { offset: 0, }), [filterCallsign, activeFilter, qsoLimit]); - const refresh = useCallback(async () => { + // refresh reloads the grid. Returns false on failure. When silent, it doesn't + // surface the error — used by the startup retry, since the logbook DB (a remote + // MySQL especially) can take a few seconds to connect while the UI is already + // mounted, and we don't want to flash "db not available" during that window. + const refresh = useCallback(async (silent = false): Promise => { try { const f = buildActiveFilter(); const list = await ListQSOFiltered(f as any); @@ -809,8 +815,10 @@ export default function App() { setTotal(n); setMatchCount(matched); setError(''); + return true; } catch (e: any) { - setError(String(e?.message ?? e)); + if (!silent) setError(String(e?.message ?? e)); + return false; } }, [buildActiveFilter]); @@ -852,6 +860,10 @@ export default function App() { // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); + // Logbook connection label for the status bar (MySQL host:port/db, or the + // local SQLite file path). + useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []); + // RX freq mirrors TX freq on every TX change (unless the rig is in split, // where the RX freq is genuinely different). It stays editable by hand: // a manual RX edit sticks until the next TX-freq change re-syncs it. @@ -959,7 +971,30 @@ export default function App() { if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); } - useEffect(() => { refresh(); }, [refresh]); + // Initial grid load. The logbook (remote MySQL) may still be connecting while + // the UI is mounted, so retry quietly until the first load succeeds rather + // than leaving the grid empty until the next manual refresh. + useEffect(() => { + let alive = true; + let tries = 0; + let timer = 0; + const attempt = async () => { + if (!alive) return; + const ok = await refresh(true); + if (ok && alive) { + // The logbook is now connected — refresh the status-bar label too, in + // case its one-shot fetch ran during the startup race (before the + // backend was determined) and grabbed the wrong/stale value. + GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {}); + } else if (!ok && alive && tries++ < 30) { + timer = window.setTimeout(attempt, 500); + } else if (!ok && alive) { + refresh(); // give up quietly retrying; surface the error now + } + }; + attempt(); + return () => { alive = false; if (timer) window.clearTimeout(timer); }; + }, [refresh]); useEffect(() => { (async () => { try { @@ -1629,10 +1664,8 @@ export default function App() { if (!path) return; setExporting(true); const res = await ExportADIF(path, includeAppFields); - // Reuse the error banner area for a brief success note (4s auto-dismiss). - const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`; - setError(msg); - setTimeout(() => setError((e) => e === msg ? '' : e), 4000); + // Green success toast (auto-dismiss) — not the red error banner. + showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`); } catch (e: any) { setError(`ADIF export failed: ${String(e?.message ?? e)}`); } finally { @@ -2621,7 +2654,7 @@ export default function App() { value={filterCallsign} onChange={(e) => setFilterCallsign(e.target.value)} /> - @@ -3152,6 +3185,17 @@ export default function App() { onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} />
+ {dbConn && ( + + )} ); })()} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 3199c3d..de064a6 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -543,6 +543,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [mysqlCfg, setMysqlCfg] = useState({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); const setMysqlField = (patch: Partial) => setMysqlCfg((s) => ({ ...s, ...patch })); const [mysqlMsg, setMysqlMsg] = useState(''); + const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply" const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null); const [dataDir, setDataDir] = useState(''); @@ -2184,7 +2185,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <> .adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each." + : "OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."} />