up
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+53
-9
@@ -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<CATState>({ 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<boolean> => {
|
||||
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)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
<Button variant="outline" size="sm" onClick={() => refresh()}>
|
||||
<RefreshCw className="size-3.5" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3152,6 +3185,17 @@ export default function App() {
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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 setMysqlField = (patch: Partial<MySQLCfg>) => 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) {
|
||||
<>
|
||||
<SectionHeader
|
||||
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">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -2697,10 +2700,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<SectionHeader
|
||||
title="Database"
|
||||
/>
|
||||
{/* 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">
|
||||
{/* Backend selector — top of the panel. SQLite (solo) vs MySQL (shared).
|
||||
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>
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
|
||||
@@ -2709,6 +2726,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</Select>
|
||||
</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. */}
|
||||
{backendStatus && (
|
||||
<div className="max-w-2xl mb-4">
|
||||
@@ -2767,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
{mysqlCfg.enabled && (
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<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 className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">Host</Label>
|
||||
@@ -2787,17 +2813,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
Test & create database
|
||||
</Button>
|
||||
<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
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
Vendored
+2
@@ -137,6 +137,8 @@ export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||
|
||||
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
|
||||
|
||||
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
||||
|
||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||
|
||||
@@ -246,6 +246,10 @@ export function GetDBBackendStatus() {
|
||||
return window['go']['main']['App']['GetDBBackendStatus']();
|
||||
}
|
||||
|
||||
export function GetDBConnectionInfo() {
|
||||
return window['go']['main']['App']['GetDBConnectionInfo']();
|
||||
}
|
||||
|
||||
export function GetDVKMessages() {
|
||||
return window['go']['main']['App']['GetDVKMessages']();
|
||||
}
|
||||
|
||||
@@ -862,6 +862,20 @@ export namespace main {
|
||||
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 {
|
||||
slot: number;
|
||||
label: string;
|
||||
|
||||
+82
-10
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
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
|
||||
// the source is the live database; we want a fresh standalone file.
|
||||
func copyFile(src, dst string) error {
|
||||
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
||||
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
||||
// are touched — never user files that happen to live in the same folder.
|
||||
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
|
||||
// opslog-*.db.zip) and deletes the rest.
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "opslog-") {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
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
|
||||
}
|
||||
info, err := e.Info()
|
||||
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// HasBackupToday returns true if a backup for today's date already exists
|
||||
// in folder. Used by the startup auto-backup to skip when the user has
|
||||
// already restarted the app once today.
|
||||
// HasBackupToday returns true if a SQLite backup for today's date already
|
||||
// exists in folder. Used by the auto-backup to skip when the user has already
|
||||
// restarted the app once today.
|
||||
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 == "" {
|
||||
return false
|
||||
}
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
for _, ext := range []string{".db", ".db.zip"} {
|
||||
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
||||
for _, ext := range exts {
|
||||
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user