feat(cluster): start from a known node instead of a blank form

Setting up a telnet cluster is the step operators get stuck on. The
address and the port are two pieces of information nobody has to hand,
and a typo in either looks exactly like a node that is down — there is
nothing to read, only a connection that never comes.

So the editor offers a list, and choosing one fills the fields. Six to
begin with: F4BPO, DXFun, SOTA, POTA, and the two Reverse Beacon feeds —
listed separately and named for what they carry, because they are one
network on two ports where 7000 is CW and RTTY and 7001 is FT8 and FT4,
and no amount of trying will tell you which is which.

The preset then gets out of the way. Everything stays editable, the name
is only filled when the operator has not chosen one of their own, and a
node typed in by hand behaves exactly the same. Reopening a node created
from a preset shows it selected, so the list also answers "which one is
this".
This commit is contained in:
2026-09-07 23:17:20 +02:00
parent 659e33676a
commit 3e206268b4
4 changed files with 91 additions and 2 deletions
+39
View File
@@ -94,6 +94,7 @@ import { OperatingPanel } from '@/components/OperatingPanel';
import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
import { CLUSTER_PRESETS } from '@/lib/clusterPresets';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -8946,8 +8947,13 @@ interface ClusterEditorProps {
}
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
const { t } = useI18n();
const [s, setS] = useState(value);
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
// Which preset the fields currently match, so reopening a node created from
// one shows it selected rather than blank.
const presetIdx = CLUSTER_PRESETS.findIndex(
(p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
return (
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-[640px] px-6">
@@ -8958,6 +8964,39 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2 px-2">
{/* Start from a node that is known to work. The host and the port are
two pieces of information nobody has to hand, and a typo in either
looks exactly like a node that is down. Everything stays editable
afterwards. */}
<div className="space-y-1 col-span-2">
<Label>{t('clu.preset')}</Label>
<Select
value={presetIdx >= 0 ? String(presetIdx) : '_'}
onValueChange={(v) => {
const p = CLUSTER_PRESETS[Number(v)];
if (!p) return;
update({
host: p.host, port: p.port,
// The name is only filled when the operator has not chosen one
// of their own: renaming a node is the first thing anybody with
// two of them does.
name: s.name.trim() ? s.name : p.name,
init_commands: s.init_commands?.trim() ? s.init_commands : (p.init ?? ''),
});
}}
>
<SelectTrigger className="h-9"><SelectValue placeholder={t('clu.presetPick')} /></SelectTrigger>
<SelectContent>
<SelectItem value="_" disabled>{t('clu.presetPick')}</SelectItem>
{CLUSTER_PRESETS.map((p, i) => (
<SelectItem key={`${p.host}:${p.port}`} value={String(i)}>
{p.name} {p.about}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{t('clu.presetHint')}</p>
</div>
<div className="space-y-1 col-span-2">
<Label>Display name</Label>
<Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />