400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
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<UDPConfig>;
|
|
}> = [
|
|
{
|
|
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<UDPConfig[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editing, setEditing] = useState<UDPConfig | null>(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 <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
|
|
|
const inbound = items.filter((i) => i.direction === 'inbound');
|
|
const outbound = items.filter((i) => i.direction === 'outbound');
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
|
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.
|
|
</div>
|
|
|
|
<Section
|
|
title="Inbound — OpsLog listens"
|
|
icon={<ArrowDownToLine className="size-4" />}
|
|
items={inbound}
|
|
onAdd={() => addNew('inbound')}
|
|
onEdit={(c) => setEditing(c)}
|
|
onDelete={remove}
|
|
onToggle={toggleEnabled}
|
|
/>
|
|
<Section
|
|
title="Outbound — OpsLog sends"
|
|
icon={<ArrowUpFromLine className="size-4" />}
|
|
items={outbound}
|
|
onAdd={() => addNew('outbound')}
|
|
onEdit={(c) => setEditing(c)}
|
|
onDelete={remove}
|
|
onToggle={toggleEnabled}
|
|
/>
|
|
|
|
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
|
<Button size="sm" variant="outline" onClick={reloadServers}>
|
|
<RefreshCcw className="size-3.5" /> Reload all
|
|
</Button>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
Restarts every enabled listener after a manual change.
|
|
</span>
|
|
</div>
|
|
|
|
{editing && (
|
|
<EditDialog
|
|
cfg={editing}
|
|
onCancel={() => setEditing(null)}
|
|
onSave={save}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div className="rounded-md border border-border bg-card">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
{icon}
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
|
<div className="flex-1" />
|
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
|
<Plus className="size-3" /> Add
|
|
</Button>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
|
|
) : (
|
|
<div className="divide-y divide-border/60">
|
|
{items.map((c) => {
|
|
const svc = SERVICE_TYPES.find((s) => s.id === c.service_type);
|
|
return (
|
|
<div key={c.id} className="flex items-center gap-2 px-3 py-2">
|
|
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{svc?.label ?? c.service_type}
|
|
</span>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground font-mono">
|
|
{c.multicast
|
|
? <>multicast <strong>{c.multicast_group || '?'}</strong>:{c.port}</>
|
|
: c.direction === 'outbound'
|
|
? <>→ {c.destination_ip || '?'}:{c.port}</>
|
|
: <>:{c.port}</>
|
|
}
|
|
</div>
|
|
</div>
|
|
<Button size="icon" variant="ghost" className="size-7" onClick={() => onEdit(c)}>
|
|
<Edit2 className="size-3.5" />
|
|
</Button>
|
|
<Button size="icon" variant="ghost" className="size-7 text-destructive hover:bg-destructive/10" onClick={() => onDelete(c.id)}>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Edit dialog ────────────────────────────────────────────────────────
|
|
|
|
function EditDialog({
|
|
cfg, onCancel, onSave,
|
|
}: {
|
|
cfg: UDPConfig;
|
|
onCancel: () => void;
|
|
onSave: (c: UDPConfig) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState<UDPConfig>(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 (
|
|
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{currentService?.hint}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 px-5 py-4">
|
|
<div className="space-y-1">
|
|
<Label>Name</Label>
|
|
<Input
|
|
autoFocus
|
|
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
|
value={draft.name}
|
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>Service type</Label>
|
|
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{services.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
|
<div className="space-y-1">
|
|
<Label>Port</Label>
|
|
<Input
|
|
type="number"
|
|
min={1} max={65535}
|
|
className="font-mono"
|
|
value={draft.port}
|
|
onChange={(e) => {
|
|
const n = Number(e.target.value);
|
|
if (Number.isFinite(n)) setDraft((d) => ({ ...d, port: Math.floor(n) }));
|
|
}}
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
|
<Checkbox
|
|
checked={draft.multicast}
|
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
|
/>
|
|
<span>Multicast</span>
|
|
</label>
|
|
</div>
|
|
|
|
{draft.multicast && (
|
|
<div className="space-y-1">
|
|
<Label>Multicast group</Label>
|
|
<Input
|
|
className="font-mono"
|
|
placeholder="224.0.0.1"
|
|
value={draft.multicast_group}
|
|
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{draft.direction === 'outbound' && (
|
|
<div className="space-y-1">
|
|
<Label>Destination IP</Label>
|
|
<Input
|
|
className="font-mono"
|
|
placeholder="127.0.0.1"
|
|
value={draft.destination_ip}
|
|
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox
|
|
checked={draft.enabled}
|
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
|
/>
|
|
<span>Enabled</span>
|
|
</label>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
<Button
|
|
onClick={() => onSave(draft)}
|
|
disabled={!draft.name.trim() || !draft.port}
|
|
>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// silence unused-import for cn — kept for future styling tweaks
|
|
void cn;
|