import { useCallback, useEffect, useState } from 'react'; import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations, } from '../../wailsjs/go/main/App'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; // Local mirror of the Go struct — we duplicate the type rather than depend // on the generated Wails model because the inline `as any` casts are // noisier than just owning the shape here. type UDPConfig = { id: number; direction: 'inbound' | 'outbound'; name: string; port: number; service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated'; multicast: boolean; multicast_group: string; destination_ip: string; enabled: boolean; sort_order: number; }; // Service-type catalog used by the dropdowns; each entry is restricted to // inbound or outbound and carries a hint suggesting reasonable defaults // for the "preset" button. const SERVICE_TYPES: Array<{ id: UDPConfig['service_type']; direction: UDPConfig['direction']; label: string; hint: string; defaults: Partial; }> = [ { id: 'wsjt', direction: 'inbound', label: 'WSJT-X / JTDX / MSHV', hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' }, }, { id: 'adif', direction: 'inbound', label: 'ADIF message (JTAlert, GridTracker)', hint: 'Receives a single ADIF record per packet and logs it.', defaults: { port: 2333, multicast: false }, }, { id: 'n1mm', direction: 'inbound', label: 'N1MM Logger+ (contest XML)', hint: 'Receives contest QSOs as XML messages.', defaults: { port: 12060, multicast: false }, }, { id: 'remote_call', direction: 'inbound', label: 'Remote callsign (DXHunter, custom)', hint: 'A short text packet containing just a callsign — fills the entry field.', defaults: { port: 12090, multicast: false }, }, { id: 'db_updated', direction: 'outbound', label: 'DB updated → notify other apps', hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', defaults: { port: 2333, destination_ip: '127.0.0.1' }, }, ]; type Props = { onError: (msg: string) => void }; export function UDPIntegrationsPanel({ onError }: Props) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const reload = useCallback(async () => { try { const list = await ListUDPIntegrations(); setItems(((list ?? []) as any[]) as UDPConfig[]); } catch (e: any) { onError(String(e?.message ?? e)); } finally { setLoading(false); } }, [onError]); useEffect(() => { void reload(); }, [reload]); function addNew(direction: UDPConfig['direction']) { const preset = SERVICE_TYPES.find((s) => s.direction === direction)!; setEditing({ id: 0, direction, name: '', port: preset.defaults.port ?? 2237, service_type: preset.id, multicast: !!preset.defaults.multicast, multicast_group: preset.defaults.multicast_group ?? '', destination_ip: preset.defaults.destination_ip ?? '', enabled: true, sort_order: items.filter((i) => i.direction === direction).length, }); } async function save(cfg: UDPConfig) { try { const saved = await SaveUDPIntegration(cfg as any) as UDPConfig; setItems((prev) => { if (cfg.id === 0) return [...prev, saved]; return prev.map((x) => x.id === saved.id ? saved : x); }); setEditing(null); } catch (e: any) { onError(String(e?.message ?? e)); } } async function remove(id: number) { if (!confirm('Delete this UDP connection?')) return; try { await DeleteUDPIntegration(id); setItems((prev) => prev.filter((x) => x.id !== id)); } catch (e: any) { onError(String(e?.message ?? e)); } } async function toggleEnabled(cfg: UDPConfig) { await save({ ...cfg, enabled: !cfg.enabled }); } async function reloadServers() { try { const errs = await ReloadUDPIntegrations(); if (errs && (errs as string[]).length > 0) { onError((errs as string[]).join(' • ')); } } catch (e: any) { onError(String(e?.message ?? e)); } } if (loading) return
Loading…
; const inbound = items.filter((i) => i.direction === 'inbound'); const outbound = items.filter((i) => i.direction === 'outbound'); return (
UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.
} items={inbound} onAdd={() => addNew('inbound')} onEdit={(c) => setEditing(c)} onDelete={remove} onToggle={toggleEnabled} />
} items={outbound} onAdd={() => addNew('outbound')} onEdit={(c) => setEditing(c)} onDelete={remove} onToggle={toggleEnabled} />
Restarts every enabled listener after a manual change.
{editing && ( setEditing(null)} onSave={save} /> )}
); } // ── Section listing ──────────────────────────────────────────────────── function Section({ title, icon, items, onAdd, onEdit, onDelete, onToggle, }: { title: string; icon: React.ReactNode; items: UDPConfig[]; onAdd: () => void; onEdit: (c: UDPConfig) => void; onDelete: (id: number) => void; onToggle: (c: UDPConfig) => void; }) { return (
{icon} {title}
{items.length === 0 ? (
No connection.
) : (
{items.map((c) => { const svc = SERVICE_TYPES.find((s) => s.id === c.service_type); return (
onToggle(c)} />
{c.name || '(unnamed)'} {svc?.label ?? c.service_type}
{c.multicast ? <>multicast {c.multicast_group || '?'}:{c.port} : c.direction === 'outbound' ? <>→ {c.destination_ip || '?'}:{c.port} : <>:{c.port} }
); })}
)}
); } // ── Edit dialog ──────────────────────────────────────────────────────── function EditDialog({ cfg, onCancel, onSave, }: { cfg: UDPConfig; onCancel: () => void; onSave: (c: UDPConfig) => void; }) { const [draft, setDraft] = useState(cfg); // Service-type list filtered to this connection's direction. const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction); const currentService = services.find((s) => s.id === draft.service_type); function applyPreset(id: UDPConfig['service_type']) { const preset = SERVICE_TYPES.find((s) => s.id === id); if (!preset) return; setDraft((d) => ({ ...d, service_type: id, port: preset.defaults.port ?? d.port, multicast: preset.defaults.multicast ?? d.multicast, multicast_group: preset.defaults.multicast_group ?? d.multicast_group, destination_ip: preset.defaults.destination_ip ?? d.destination_ip, })); } return ( { if (!o) onCancel(); }}> {cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection {currentService?.hint}
setDraft((d) => ({ ...d, name: e.target.value }))} />
{ const n = Number(e.target.value); if (Number.isFinite(n)) setDraft((d) => ({ ...d, port: Math.floor(n) })); }} />
{draft.multicast && (
setDraft((d) => ({ ...d, multicast_group: e.target.value }))} />
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
)} {draft.direction === 'outbound' && (
setDraft((d) => ({ ...d, destination_ip: e.target.value }))} />
)}
); } // silence unused-import for cn — kept for future styling tweaks void cn;