417 lines
14 KiB
TypeScript
417 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';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
// 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' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
|
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: 'udpp.svcWsjtLabel',
|
|
hint: 'udpp.svcWsjtHint',
|
|
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
|
},
|
|
{
|
|
id: 'adif',
|
|
direction: 'inbound',
|
|
label: 'udpp.svcAdifLabel',
|
|
hint: 'udpp.svcAdifHint',
|
|
defaults: { port: 2333, multicast: false },
|
|
},
|
|
{
|
|
id: 'n1mm',
|
|
direction: 'inbound',
|
|
label: 'udpp.svcN1mmLabel',
|
|
hint: 'udpp.svcN1mmHint',
|
|
defaults: { port: 12060, multicast: false },
|
|
},
|
|
{
|
|
id: 'remote_call',
|
|
direction: 'inbound',
|
|
label: 'udpp.svcRemoteLabel',
|
|
hint: 'udpp.svcRemoteHint',
|
|
defaults: { port: 12090, multicast: false },
|
|
},
|
|
{
|
|
id: 'db_updated',
|
|
direction: 'outbound',
|
|
label: 'udpp.svcDbLabel',
|
|
hint: 'udpp.svcDbHint',
|
|
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
|
},
|
|
{
|
|
id: 'pstrotator_freq',
|
|
direction: 'outbound',
|
|
label: 'udpp.svcPstLabel',
|
|
hint: 'udpp.svcPstHint',
|
|
defaults: { port: 12040, destination_ip: '127.0.0.1' },
|
|
},
|
|
{
|
|
id: 'n1mm_radioinfo',
|
|
direction: 'outbound',
|
|
label: 'udpp.svcN1mmRadioLabel',
|
|
hint: 'udpp.svcN1mmRadioHint',
|
|
defaults: { port: 12060, destination_ip: '127.0.0.1' },
|
|
},
|
|
];
|
|
|
|
type Props = { onError: (msg: string) => void };
|
|
|
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
|
const { t } = useI18n();
|
|
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(t('udpp.deleteConfirm'))) 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">{t('udpp.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">
|
|
{t('udpp.intro')}
|
|
</div>
|
|
|
|
<Section
|
|
title={t('udpp.inboundTitle')}
|
|
icon={<ArrowDownToLine className="size-4" />}
|
|
items={inbound}
|
|
onAdd={() => addNew('inbound')}
|
|
onEdit={(c) => setEditing(c)}
|
|
onDelete={remove}
|
|
onToggle={toggleEnabled}
|
|
/>
|
|
<Section
|
|
title={t('udpp.outboundTitle')}
|
|
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" /> {t('udpp.reloadAll')}
|
|
</Button>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{t('udpp.reloadHint')}
|
|
</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;
|
|
}) {
|
|
const { t } = useI18n();
|
|
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" /> {t('udpp.add')}
|
|
</Button>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<div className="px-3 py-3 text-xs text-muted-foreground italic">{t('udpp.noConnection')}</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 || t('udpp.unnamed')}</span>
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{svc ? t(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 { t } = useI18n();
|
|
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>
|
|
{t('udpp.dialogTitle', {
|
|
action: cfg.id === 0 ? t('udpp.new') : t('udpp.edit'),
|
|
direction: draft.direction === 'inbound' ? t('udpp.directionInbound') : t('udpp.directionOutbound'),
|
|
})}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{currentService ? t(currentService.hint) : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 px-5 py-4">
|
|
<div className="space-y-1">
|
|
<Label>{t('udpp.name')}</Label>
|
|
<Input
|
|
autoFocus
|
|
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
value={draft.name}
|
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>{t('udpp.serviceType')}</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}>{t(s.label)}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
|
<div className="space-y-1">
|
|
<Label>{t('udpp.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>{t('udpp.multicast')}</span>
|
|
</label>
|
|
</div>
|
|
|
|
{draft.multicast && (
|
|
<div className="space-y-1">
|
|
<Label>{t('udpp.multicastGroup')}</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">
|
|
{t('udpp.multicastHint')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{draft.direction === 'outbound' && (
|
|
<div className="space-y-1">
|
|
<Label>{t('udpp.destinationIp')}</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>{t('udpp.enabled')}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
|
|
<Button
|
|
onClick={() => onSave(draft)}
|
|
disabled={!draft.name.trim() || !draft.port}
|
|
>
|
|
{t('udpp.save')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// silence unused-import for cn — kept for future styling tweaks
|
|
void cn;
|