up
This commit is contained in:
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -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 & create database
|
Test & 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user