up
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user