up
This commit is contained in:
+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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user