This commit is contained in:
2026-06-14 00:55:27 +02:00
parent 08162fa126
commit 67203cd4a8
16 changed files with 897 additions and 212 deletions
+80 -45
View File
@@ -25,7 +25,7 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
@@ -194,11 +194,11 @@ const TREE: TreeNode[] = [
},
{
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'Rotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna', id: 'antenna' },
{ kind: 'item', label: 'Audio devices', id: 'audio' },
],
},
];
@@ -220,7 +220,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)',
winkeyer: 'CW Keyer',
antenna: 'Antenna',
audio: 'Audio devices',
};
@@ -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 [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
@@ -633,6 +634,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setDataDir(await GetDataDir()); } catch {}
try {
const locs: any = await ListTQSLStationLocations();
@@ -1725,7 +1727,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<>
<SectionHeader
title="CW Keyer (WinKeyer)"
title="CW Keyer"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)."
/>
<div className="space-y-4 max-w-2xl">
@@ -2695,6 +2697,44 @@ 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">
<Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
<div className="max-w-2xl mb-4">
{backendStatus.fallback ? (
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
MySQL is enabled but the connection failed at startup — OpsLog is running on the local <strong>SQLite</strong> database.
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : backendStatus.active === 'mysql' ? (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
<strong>Logbook:</strong> shared MySQL <span className="text-emerald-700">connected ✓</span>
<span className="mx-1.5 text-muted-foreground">·</span>
<strong>Config:</strong> local SQLite
<div className="text-[10px] text-muted-foreground mt-1">
Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied import them into the shared log if you want your history there.
</div>
</div>
) : (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
Active backend: <strong className="uppercase">{backendStatus.active}</strong>
</div>
)}
</div>
)}
{!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>Current database</Label>
@@ -2721,50 +2761,45 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div>
)}
</div>
)}
{/* Shared MySQL database (multi-operator) */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">Shared database (multi-operator)</div>
{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.
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">Host</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">Port</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<Label className="text-sm">Database</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">User</Label>
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
</div>
{mysqlCfg.enabled && (
<>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">Port</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<Label className="text-sm">Database</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">User</Label>
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setMysqlMsg('Testing'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected database ready ')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
Test &amp; create database
</Button>
<Button size="sm" className="h-8"
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setMysqlMsg('Saved.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
Save
</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div>
</>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setMysqlMsg('Testing'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected database ready ')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
Test &amp; 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))); }}>
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>
)}
{/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">