Files
OpsLog/frontend/src/components/UDPIntegrationsPanel.tsx
T
2026-05-28 21:32:46 +02:00

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;