The motorised antenna gagged the transmitter for a second or two after it had finished moving. Three delays were stacked: the antenna polled every two seconds, the gag held for three after the command whatever the antenna said, and the widget refreshed every three. The antenna is now asked four times a second WHILE IT MOVES — an idle one has nothing to say and stays at two seconds — the gag only bridges the command itself (900 ms), and the widget follows at half a second while moving. WSJT-X highlighting: - A watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked every other verdict including the log's, so a worked station stayed pink for the session with nothing to tell it from one still needed. - The four colours are the operator's to choose (Settings → UDP). Only the background: the text colour is derived by luma, so a dark blue cannot come back as black-on-black in somebody else's window. Changing one clears the installed highlights, or the de-duplication would keep showing yesterday's colour until a callsign changed verdict.
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||
import {
|
||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||
GetWsjtHighlightColours, SetWsjtHighlightColours,
|
||
} 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' | 'wsjt_log' | 'wsjt_relay' | 'custom';
|
||
// Custom rows only.
|
||
trigger?: string;
|
||
template?: string;
|
||
transport?: string;
|
||
url?: string;
|
||
line_end?: string;
|
||
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' },
|
||
},
|
||
{
|
||
// Announces the QSO on the WSJT-X UDP interface instead of as plain text.
|
||
// Named for the protocol, not for one program: Logger32 is what it was
|
||
// built against, but anything listening on that interface takes it, and a
|
||
// label saying "Logger32" would have every other operator scroll past.
|
||
// 2250 is Logger32's first additional socket — a starting point, not a rule.
|
||
id: 'wsjt_log',
|
||
direction: 'outbound',
|
||
label: 'udpp.svcWsjtLogLabel',
|
||
hint: 'udpp.svcWsjtLogHint',
|
||
defaults: { port: 2250, destination_ip: '127.0.0.1' },
|
||
},
|
||
{
|
||
// Feeds a SECOND application the stream this OpsLog receives. WSJT-X, JTDX
|
||
// and MSHV each send to ONE address, so without a relay the choice is
|
||
// between OpsLog and JTAlert/GridTracker rather than both.
|
||
//
|
||
// 2238 by default: beside the 2237 the sender uses, and free. Pointing it AT
|
||
// 2237 would name an OpsLog listener, which the relay refuses.
|
||
id: 'wsjt_relay',
|
||
direction: 'outbound',
|
||
label: 'udpp.svcWsjtRelayLabel',
|
||
hint: 'udpp.svcWsjtRelayHint',
|
||
defaults: { port: 2238, destination_ip: '127.0.0.1' },
|
||
},
|
||
{
|
||
// The general case: the operator picks the moment and writes the message.
|
||
// Mainly for antenna switches, which are driven by a URL and want the band
|
||
// change — but nothing about it is specific to them.
|
||
id: 'custom',
|
||
direction: 'outbound',
|
||
label: 'udpp.svcCustomLabel',
|
||
hint: 'udpp.svcCustomHint',
|
||
defaults: { port: 12000, 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' },
|
||
},
|
||
];
|
||
|
||
// The fields each trigger carries, and NOTHING else.
|
||
//
|
||
// This list is the point of the whole feature. A rotation has no frequency, a
|
||
// lookup has no report — and a placeholder the trigger cannot fill renders as
|
||
// nothing at all. Without seeing what is available, an operator writes {freq}
|
||
// on a band change, gets an empty string, and has no way to learn why. So the
|
||
// panel prints them under the box, for the trigger actually selected.
|
||
const TRIGGER_FIELDS: Record<string, string[]> = {
|
||
qso_logged: ['call', 'band', 'band_m', 'mode', 'freq_hz', 'freq_mhz', 'grid', 'name', 'country', 'dxcc', 'rst_s', 'rst_r', 'comment', 'date', 'time'],
|
||
rotator_goto: ['az', 'el', 'path'],
|
||
lookup_done: ['call', 'name', 'grid', 'country', 'qth', 'state', 'dxcc'],
|
||
band_change: ['band', 'band_m', 'freq_hz', 'freq_mhz', 'mode'],
|
||
};
|
||
|
||
const TRIGGERS = [
|
||
{ id: 'band_change', label: 'udpp.trgBand' },
|
||
{ id: 'qso_logged', label: 'udpp.trgQso' },
|
||
{ id: 'rotator_goto', label: 'udpp.trgRotator' },
|
||
{ id: 'lookup_done', label: 'udpp.trgLookup' },
|
||
];
|
||
|
||
type Props = { onError: (msg: string) => void };
|
||
|
||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||
const [highlightOn, setHighlightOn] = useState(false);
|
||
const [hlWorked, setHlWorked] = useState(false);
|
||
// The palette, as chosen. Background per verdict; the text colour is the
|
||
// backend’s business (see colourFor).
|
||
const [colours, setColours] = useState({ watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' });
|
||
const [followMode, setFollowMode] = useState(true);
|
||
useEffect(() => {
|
||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||
GetWsjtHighlightColours().then((c: any) => { if (c) setColours(c); }).catch(() => {});
|
||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||
}, []);
|
||
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,
|
||
trigger: '', template: '', transport: 'udp', url: '', line_end: '',
|
||
});
|
||
}
|
||
|
||
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">
|
||
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||
this panel is where the WSJT-X link is configured. */}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||
<Checkbox checked={highlightOn}
|
||
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
|
||
<span>
|
||
{t('udpp.highlight')}
|
||
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||
</span>
|
||
</label>
|
||
{/* The palette, nested under the switch for the same reason as the box
|
||
below it. One colour per verdict, and the background only: the text
|
||
colour is worked out from it, so a chosen colour cannot come back
|
||
unreadable in the decoder's window. */}
|
||
{highlightOn && (
|
||
<div className="pl-6 max-w-2xl space-y-1.5">
|
||
<div className="text-[11px] text-muted-foreground">{t('udpp.hlColours')}</div>
|
||
<div className="flex flex-wrap gap-3">
|
||
{([
|
||
['watchlist', t('udpp.hlWatchlist')],
|
||
['new_dxcc', t('udpp.hlNewDxcc')],
|
||
['new_band', t('udpp.hlNewBand')],
|
||
['worked', t('udpp.hlWorkedC')],
|
||
] as const).map(([k, label]) => (
|
||
<label key={k} className="inline-flex items-center gap-1.5 text-xs">
|
||
<input
|
||
type="color"
|
||
value={(colours as any)[k] || '#000000'}
|
||
onChange={(e) => {
|
||
const next = { ...colours, [k]: e.target.value.toUpperCase() };
|
||
setColours(next);
|
||
void SetWsjtHighlightColours(next as any);
|
||
}}
|
||
className="h-6 w-8 rounded border border-border bg-background p-0.5 cursor-pointer"
|
||
/>
|
||
{label}
|
||
</label>
|
||
))}
|
||
<button type="button" className="text-xs text-muted-foreground underline hover:text-foreground"
|
||
onClick={() => {
|
||
const def = { watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' };
|
||
setColours(def as any);
|
||
void SetWsjtHighlightColours(def as any);
|
||
}}>
|
||
{t('udpp.hlReset')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Nested under the switch above: the same feature, and meaningless
|
||
while that one is off. */}
|
||
{highlightOn && (
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||
<Checkbox checked={hlWorked}
|
||
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||
<span>
|
||
{t('udpp.hlWorked')}
|
||
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
||
</span>
|
||
</label>
|
||
)}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||
<Checkbox checked={followMode}
|
||
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||
<span>
|
||
{t('udpp.followMode')}
|
||
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
|
||
</span>
|
||
</label>
|
||
<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
|
||
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' && draft.service_type !== 'custom' && (
|
||
<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>
|
||
)}
|
||
|
||
{draft.service_type === 'custom' && (
|
||
<CustomFields draft={draft} setDraft={setDraft} />
|
||
)}
|
||
|
||
<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;
|
||
|
||
// CustomFields is the editor for a custom outbound row: what fires it, how it
|
||
// leaves, and what it says.
|
||
function CustomFields({ draft, setDraft }: {
|
||
draft: UDPConfig;
|
||
setDraft: React.Dispatch<React.SetStateAction<UDPConfig>>;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
|
||
const isURL = draft.transport === 'url';
|
||
return (
|
||
<div className="space-y-4 rounded-md border border-border/60 p-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('udpp.trigger')}</Label>
|
||
<Select value={draft.trigger || 'band_change'} onValueChange={(v) => setDraft((d) => ({ ...d, trigger: v }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{TRIGGERS.map((x) => <SelectItem key={x.id} value={x.id}>{t(x.label)}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('udpp.transport')}</Label>
|
||
<Select value={draft.transport || 'udp'} onValueChange={(v) => setDraft((d) => ({ ...d, transport: v }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="udp">{t('udpp.transportUdp')}</SelectItem>
|
||
<SelectItem value="url">{t('udpp.transportUrl')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The one place these two features overlap. A relay board is a STATE per
|
||
band, not an event: Station Control tracks it, reads the boards at
|
||
startup and does not re-switch while the VFO moves inside a band. A
|
||
URL fired on a band change has none of that, and building the mapping
|
||
by hand here is work that is already done there. */}
|
||
{draft.trigger === 'band_change' && isURL && (
|
||
<div className="rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground leading-relaxed">
|
||
{t('udpp.relayInstead')}
|
||
</div>
|
||
)}
|
||
|
||
{isURL ? (
|
||
<div className="space-y-1">
|
||
<Label>{t('udpp.url')}</Label>
|
||
<Input
|
||
className="font-mono text-xs"
|
||
placeholder="http://192.168.1.50/antenna?band={band}"
|
||
value={draft.url ?? ''}
|
||
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
|
||
/>
|
||
<div className="text-[10px] text-muted-foreground">{t('udpp.urlHint')}</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||
<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>
|
||
<div className="space-y-1">
|
||
<Label>{t('udpp.lineEnd')}</Label>
|
||
<Select value={draft.line_end || 'none'} onValueChange={(v) => setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
|
||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">{t('udpp.lineEndNone')}</SelectItem>
|
||
<SelectItem value="lf">LF</SelectItem>
|
||
<SelectItem value="crlf">CRLF</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('udpp.template')}</Label>
|
||
<textarea
|
||
className="w-full min-h-[64px] rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
|
||
placeholder="<AZIMUT>{az}</AZIMUT>"
|
||
value={draft.template ?? ''}
|
||
onChange={(e) => setDraft((d) => ({ ...d, template: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* What this trigger can actually fill in. */}
|
||
<div className="space-y-1">
|
||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('udpp.fieldsAvailable')}</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{fields.map((f) => (
|
||
<code key={f} className="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{`{${f}}`}</code>
|
||
))}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">{t('udpp.fieldsHint')}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|