This commit is contained in:
2026-06-14 01:35:40 +02:00
parent 67203cd4a8
commit 29fd832bcd
7 changed files with 290 additions and 58 deletions
+103 -27
View File
@@ -556,31 +556,10 @@ func (a *App) startup(ctx context.Context) {
} }
a.db = conn a.db = conn
// Choose where the QSO logbook lives. On a MySQL failure we fall back to the // Wire the LOCAL config repos first — they're backed by the already-open
// local SQLite logbook so the operator can still log (and fix the config). // SQLite file, so the station/profiles/settings are ready instantly. Doing
logbookConn := conn // this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled { // doesn't briefly think the station is unconfigured while MySQL is dialing.
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)
a.settings = settings.NewStore(conn) a.settings = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn) a.profiles = profile.NewRepo(conn)
@@ -609,6 +588,32 @@ func (a *App) startup(ctx context.Context) {
a.lookup = lookup.NewManager(a.cache) a.lookup = lookup.NewManager(a.cache)
a.reloadLookupProviders() 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 // cty.dat for offline DXCC / country resolution. Cached on disk; first
// run downloads it from country-files.com in the background so startup // run downloads it from country-files.com in the background so startup
// stays fast even if the network is slow. // stays fast even if the network is slow.
@@ -877,12 +882,22 @@ func (a *App) runBackupForShutdown() error {
if folder == "" { if folder == "" {
folder = s.DefaultFolder 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 return nil
} }
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
return err 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)) 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 // GetMySQLSettings returns the stored shared-database config from the bootstrap
// file (config.json), with defaults applied. Read before the DB is open, so it // file (config.json), with defaults applied. Read before the DB is open, so it
// must not depend on the settings table. // must not depend on the settings table.
@@ -5748,14 +5787,38 @@ func (a *App) RunBackupNow() (string, error) {
if folder == "" { if folder == "" {
folder = s.DefaultFolder 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) path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
if err != nil { if err != nil {
return path, err 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)) a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
return path, nil 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 // maybeShutdownBackup runs a backup at shutdown if the user enabled it
// and no backup for today already exists. Running at shutdown (not at // and no backup for today already exists. Running at shutdown (not at
// startup) means the snapshot includes the QSOs the user just logged // startup) means the snapshot includes the QSOs the user just logged
@@ -5773,13 +5836,26 @@ func (a *App) maybeShutdownBackup() {
if folder == "" { if folder == "" {
folder = s.DefaultFolder 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 return
} }
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err) fmt.Println("OpsLog: shutdown backup failed:", err)
return 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)) a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
} }
+53 -9
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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, Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
@@ -17,6 +17,7 @@ import {
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, RefreshCtyDat,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo,
GetUltrabeamStatus, SetUltrabeamDirection, GetUltrabeamStatus, SetUltrabeamDirection,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
@@ -320,6 +321,7 @@ export default function App() {
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); 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 [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 // 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 // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface. // in Preferences > Hardware > CAT interface.
@@ -798,7 +800,11 @@ export default function App() {
offset: 0, offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]); }), [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<boolean> => {
try { try {
const f = buildActiveFilter(); const f = buildActiveFilter();
const list = await ListQSOFiltered(f as any); const list = await ListQSOFiltered(f as any);
@@ -809,8 +815,10 @@ export default function App() {
setTotal(n); setTotal(n);
setMatchCount(matched); setMatchCount(matched);
setError(''); setError('');
return true;
} catch (e: any) { } catch (e: any) {
setError(String(e?.message ?? e)); if (!silent) setError(String(e?.message ?? e));
return false;
} }
}, [buildActiveFilter]); }, [buildActiveFilter]);
@@ -852,6 +860,10 @@ export default function App() {
// RX band auto-follows the TX band (only differs for cross-band work). // RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]); 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, // 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: // 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. // 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); 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(() => { useEffect(() => {
(async () => { (async () => {
try { try {
@@ -1629,10 +1664,8 @@ export default function App() {
if (!path) return; if (!path) return;
setExporting(true); setExporting(true);
const res = await ExportADIF(path, includeAppFields); const res = await ExportADIF(path, includeAppFields);
// Reuse the error banner area for a brief success note (4s auto-dismiss). // Green success toast (auto-dismiss) — not the red error banner.
const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`; showToast(`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);
} catch (e: any) { } catch (e: any) {
setError(`ADIF export failed: ${String(e?.message ?? e)}`); setError(`ADIF export failed: ${String(e?.message ?? e)}`);
} finally { } finally {
@@ -2621,7 +2654,7 @@ export default function App() {
value={filterCallsign} value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)} onChange={(e) => setFilterCallsign(e.target.value)}
/> />
<Button variant="outline" size="sm" onClick={refresh}> <Button variant="outline" size="sm" onClick={() => refresh()}>
<RefreshCw className="size-3.5" /> Refresh <RefreshCw className="size-3.5" /> Refresh
</Button> </Button>
</div> </div>
@@ -3152,6 +3185,17 @@ export default function App() {
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/> />
<div className="flex-1" /> <div className="flex-1" />
{dbConn && (
<button
type="button"
onClick={() => { setSettingsSection('database'); setShowSettings(true); }}
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
>
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-emerald-600' : 'text-muted-foreground')} />
<span className="font-mono truncate">{dbConn.label}</span>
</button>
)}
</footer> </footer>
); );
})()} })()}
+32 -12
View File
@@ -543,6 +543,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch })); const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
const [mysqlMsg, setMysqlMsg] = useState(''); 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 [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
const [dataDir, setDataDir] = useState(''); const [dataDir, setDataDir] = useState('');
@@ -2184,7 +2185,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<> <>
<SectionHeader <SectionHeader
title="Database backup" title="Database backup"
hint="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." hint={mysqlCfg.enabled
? "On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.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."}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2697,10 +2700,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SectionHeader <SectionHeader
title="Database" title="Database"
/> />
{/* Backend selector — top of the panel. SQLite (solo) vs MySQL (shared). */} {/* Backend selector — top of the panel. SQLite (solo) vs MySQL (shared).
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-5"> The choice is persisted immediately (it lives in config.json, read
before the DB opens) so switching to SQLite isn't lost when the MySQL
panel below which holds its own Save button disappears. */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-3">
<Label className="text-sm">Backend</Label> <Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}> <Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => {
const next = { ...mysqlCfg, enabled: v === 'mysql' };
setMysqlCfg(next);
SaveMySQLSettings(next as any)
.then(() => setRestartMsg(next.enabled
? 'MySQL selected — fill in the connection below, Test, then restart.'
: 'Switched to local SQLite — restart OpsLog to apply.'))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="sqlite">SQLite local file (solo)</SelectItem> <SelectItem value="sqlite">SQLite local file (solo)</SelectItem>
@@ -2709,6 +2726,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Select> </Select>
</div> </div>
{/* Restart prompt shown after any backend change (works in both states,
unlike the MySQL panel's own Save which is hidden when SQLite). */}
{restartMsg && (
<div className="max-w-2xl mb-4 text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3">
<span>{restartMsg}</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
</div>
)}
{/* Active-backend status: confirms what OpsLog actually opened at launch. */} {/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && ( {backendStatus && (
<div className="max-w-2xl mb-4"> <div className="max-w-2xl mb-4">
@@ -2767,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{mysqlCfg.enabled && ( {mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl"> <div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed"> <div className="text-[11px] text-muted-foreground leading-relaxed">
Several OpsLog instances pointed at one MySQL server see each other's QSOs live (à la Log4OM). Test the connection, then <strong>Save</strong> — OpsLog switches to MySQL (and creates all tables) on the next launch. Several OpsLog instances pointed at one MySQL server see each other's QSOs live. Test the connection, then <strong>Save</strong> OpsLog switches to MySQL (and creates all tables) on the next launch.
</div> </div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label> <Label className="text-sm">Host</Label>
@@ -2787,17 +2813,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
Test &amp; create database Test &amp; create database
</Button> </Button>
<Button size="sm" className="h-8" <Button size="sm" className="h-8"
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setMysqlMsg('Saved restart OpsLog to connect to MySQL.')).catch((e: any) => setErr(String(e?.message ?? e))); }}> onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setRestartMsg('Saved — restart OpsLog to connect to MySQL.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
Save Save
</Button> </Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span> <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div> </div>
{mysqlMsg.startsWith('Saved') && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3">
<span>Saved. OpsLog will use the shared MySQL database after a restart.</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
</div>
)}
</div> </div>
)} )}
+2
View File
@@ -137,6 +137,8 @@ export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>; export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>; export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>; export function GetDVKStatus():Promise<main.DVKStatus>;
+4
View File
@@ -246,6 +246,10 @@ export function GetDBBackendStatus() {
return window['go']['main']['App']['GetDBBackendStatus'](); return window['go']['main']['App']['GetDBBackendStatus']();
} }
export function GetDBConnectionInfo() {
return window['go']['main']['App']['GetDBConnectionInfo']();
}
export function GetDVKMessages() { export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages'](); return window['go']['main']['App']['GetDVKMessages']();
} }
+14
View File
@@ -862,6 +862,20 @@ export namespace main {
this.error = source["error"]; this.error = source["error"];
} }
} }
export class DBConnectionInfo {
backend: string;
label: string;
static createFrom(source: any = {}) {
return new DBConnectionInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.label = source["label"];
}
}
export class DVKMessage { export class DVKMessage {
slot: number; slot: number;
label: string; label: string;
+82 -10
View File
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
return dstPath, nil return dstPath, nil
} }
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
base := "opslog-log-" + time.Now().Format("2006-01-02")
tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("export adif: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".adi.zip")
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".adi")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because // copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file. // the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error { func copyFile(src, dst string) error {
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
return out.Close() return out.Close()
} }
// rotate keeps the most recent `keep` backups in folder and deletes the // rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern // opslog-*.db.zip) and deletes the rest.
// are touched — never user files that happen to live in the same folder.
func rotate(folder string, keep int) error { func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
entries, err := os.ReadDir(folder) entries, err := os.ReadDir(folder)
if err != nil { if err != nil {
return err return err
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
continue continue
} }
name := e.Name() name := e.Name()
if !strings.HasPrefix(name, "opslog-") { if !strings.HasPrefix(name, prefix) {
continue continue
} }
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) { ok := false
for _, sfx := range suffixes {
if strings.HasSuffix(name, sfx) {
ok = true
break
}
}
if !ok {
continue continue
} }
info, err := e.Info() info, err := e.Info()
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
return firstErr return firstErr
} }
// HasBackupToday returns true if a backup for today's date already exists // HasBackupToday returns true if a SQLite backup for today's date already
// in folder. Used by the startup auto-backup to skip when the user has // exists in folder. Used by the auto-backup to skip when the user has already
// already restarted the app once today. // restarted the app once today.
func HasBackupToday(folder string) bool { func HasBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}
func hasBackupToday(folder, prefix string, exts ...string) bool {
if folder == "" { if folder == "" {
return false return false
} }
stamp := time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} { for _, ext := range exts {
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil { if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
return true return true
} }
} }