up
This commit is contained in:
@@ -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 & 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 & create</strong> the database, then <strong>Save & 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 & 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,
|
||||
|
||||
Reference in New Issue
Block a user