This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+190 -29
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -27,6 +27,8 @@ import {
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
GetPOTAToken, SavePOTAToken,
@@ -125,6 +127,7 @@ const emptyProfile = (): Profile => ({
tx_pwr: undefined,
is_active: false,
sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any,
updated_at: '' as any,
});
@@ -157,6 +160,7 @@ type SectionId =
| 'cluster'
| 'backup'
| 'database'
| 'autostart'
| 'awards'
| 'cat'
| 'rotator'
@@ -190,6 +194,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
{ kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Autostart', id: 'autostart' },
],
},
{
@@ -216,6 +221,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
@@ -316,6 +322,129 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
);
}
// AutostartPanelComponent manages the per-profile list of external programs to
// launch when OpsLog starts. It's a self-contained component (its own state) so
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
function AutostartPanelComponent() {
const [progs, setProgs] = useState<AutostartProg[]>([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
async function load() {
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setLoaded(true); }
}
useEffect(() => { load(); }, []);
useEffect(() => {
const off = EventsOn('profile:changed', () => load());
return () => { if (typeof off === 'function') off(); };
}, []);
async function commit(next: AutostartProg[]) {
setProgs(next);
try { await SaveAutostartPrograms(next as any); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const patch = (id: string, p: Partial<AutostartProg>) =>
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
async function addProgram() {
try {
const path = await BrowseExecutable();
if (!path) return;
const base = path.split(/[\\/]/).pop() || path;
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
commit([...progs, { id, name, path, args: '', enabled: true }]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function rebrowse(id: string) {
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function launchNow(id: string) {
try {
const r: any = await LaunchAutostartProgram(id);
const txt = r?.status === 'launched' ? '✓ launched'
: r?.status === 'already_running' ? 'already running — not started again'
: r?.status === 'missing' ? '✗ executable not found'
: (r?.message || r?.status || 'done');
setLaunchMsg((m) => ({ ...m, [id]: txt }));
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
}
return (
<>
<SectionHeader
title="Autostart"
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
/>
<div className="space-y-2 max-w-3xl">
{loaded && progs.length === 0 && (
<p className="text-sm text-muted-foreground italic">No programs yet add one below.</p>
)}
{progs.map((p) => (
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center gap-2">
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
onChange={(e) => patch(p.id, { name: e.target.value })} />
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
<Play className="size-3.5" /> Launch
</Button>
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground">Program</Label>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
<FolderOpen className="size-3.5" /> Browse
</Button>
</div>
<Label className="text-xs text-muted-foreground">Arguments</Label>
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
onChange={(e) => patch(p.id, { args: e.target.value })} />
</div>
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
</div>
))}
<Button variant="outline" onClick={addProgram}>
<Plus className="size-4" /> Add program
</Button>
{err && <div className="text-xs text-destructive">{err}</div>}
</div>
</>
);
}
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
}, []);
return (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
</label>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -656,6 +785,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})();
}, []);
// Every setting is per-profile, so when the active profile changes WHILE this
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
// — otherwise they keep showing the previous profile's values until reopen.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
(async () => {
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {}
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
try {
const ls: any = await GetListsSettings();
setLists(ls);
setRstText({
phone: (ls.rst_phone ?? []).join(' '),
cw: (ls.rst_cw ?? []).join(' '),
digital: (ls.rst_digital ?? []).join(' '),
});
} catch {}
})();
});
return () => { off(); };
}, []);
// Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
// are derived values, so they always recompute when the callsign or grid
@@ -2700,23 +2863,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SectionHeader
title="Database"
/>
{/* 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">
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
target its own database; choosing here and Save switches the live
logbook immediately (no restart). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">Backend</Label>
<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)));
}}
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -2725,15 +2879,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases switching profile switches the logbook.
</p>
{/* 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>
)}
{/* Save (always visible) applies the active profile's DB target live. */}
<div className="max-w-2xl mb-4 flex items-center gap-3">
<Button size="sm" className="h-8"
onClick={() => {
SaveMySQLSettings(mysqlCfg as any)
.then(() => setRestartMsg(mysqlCfg.enabled
? 'Logbook switched to MySQL ✓'
: 'Logbook switched to local SQLite ✓'))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}>
Save &amp; switch logbook
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
@@ -2793,7 +2956,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. 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 database see each other's QSOs live (refreshed every 2 s). <strong>Test &amp; create</strong> the database, then <strong>Save &amp; switch logbook</strong> above to start logging there.
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label>
@@ -2812,10 +2975,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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(() => 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>
</div>
@@ -3032,7 +3191,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<>
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
<div className="space-y-3 max-w-lg">
<div className="space-y-3 max-w-3xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
Auto-focus "Worked before" for known stations
@@ -3049,6 +3208,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
</label>
<TelemetryToggle />
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
@@ -3221,6 +3381,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel,
database: DatabasePanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,