This commit is contained in:
2026-05-28 08:48:41 +02:00
parent 28da6f6165
commit a8b7622667
14 changed files with 2702 additions and 35 deletions
+243 -3
View File
@@ -11,10 +11,14 @@ import {
GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels } from '../../wailsjs/go/models';
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
@@ -35,6 +39,8 @@ type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
const emptyProfile = (): Profile => ({
@@ -94,7 +100,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster', disabled: true },
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
@@ -251,6 +257,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
});
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
async function reloadClusterServers() {
try {
const [list, ac, st] = await Promise.all([
ListClusterServers(),
GetClusterAutoConnect(),
GetClusterStatus(),
]);
setClusterServers((list ?? []) as ClusterServer[]);
setClusterAutoConnectState(ac);
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
// the panel as a plain function, not as a JSX element, so any useState
@@ -294,6 +318,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setActiveProfile(ap as Profile);
setLists(ls);
await reloadProfiles();
await reloadClusterServers();
setBandsText((ls.bands ?? []).join('\n'));
setCatCfg(c);
setRotator(r);
@@ -367,6 +392,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.');
onSaved();
@@ -966,6 +992,151 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function statusForServer(id: number): ClusterServerStatus | undefined {
return clusterStatuses.find((s) => (s.server_id as number) === id);
}
async function clusterToggleEnabled(srv: ClusterServer, on: boolean) {
try {
await SaveClusterServer({ ...srv, enabled: on } as any);
await reloadClusterServers();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function clusterDeleteServer(srv: ClusterServer) {
if (!confirm(`Delete cluster "${srv.name}"? Active session will be closed.`)) return;
try { await DeleteClusterServer(srv.id as number); await reloadClusterServers(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function clusterMove(srv: ClusterServer, dir: -1 | 1) {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const idx = sorted.findIndex((s) => s.id === srv.id);
const j = idx + dir;
if (idx < 0 || j < 0 || j >= sorted.length) return;
const a = sorted[idx], b = sorted[j];
try {
await SaveClusterServer({ ...a, sort_order: b.sort_order } as any);
await SaveClusterServer({ ...b, sort_order: a.sort_order } as any);
await reloadClusterServers();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function clusterAddNew() {
const next: ClusterServer = {
id: 0, name: '', host: '', port: 7300,
login_override: '', password: '', init_commands: '',
enabled: true, sort_order: clusterServers.length,
};
setEditingServer(next);
}
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
return (
<>
<SectionHeader
title="DX Cluster"
hint="Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it."
/>
<div className="space-y-4">
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-3 py-2 w-10"></th>
<th className="text-left px-3 py-2">Name</th>
<th className="text-left px-3 py-2">Host:port</th>
<th className="text-left px-3 py-2 w-28">Status</th>
<th className="px-3 py-2 w-32">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((s, i) => {
const st = statusForServer(s.id as number);
const state = (st?.state ?? 'disconnected') as string;
const isMaster = i === sorted.findIndex((x) => x.enabled);
return (
<tr key={s.id as number} className="border-t border-border align-middle">
<td className="px-2 py-2 text-center">
<Checkbox
checked={s.enabled}
onCheckedChange={(c) => clusterToggleEnabled(s, !!c)}
/>
</td>
<td className="px-3 py-2 font-medium">
{s.name}
{isMaster && s.enabled && (
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-amber-100 text-amber-800 border border-amber-300">MASTER</span>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
<td className="px-3 py-2">
<span className={cn(
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}>
{state.toUpperCase()}
{st?.retries ? ` #${st.retries}` : ''}
</span>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-0.5 justify-end">
<Button variant="ghost" size="icon" className="size-6" disabled={i === 0} onClick={() => clusterMove(s, -1)} title="Move up"><ArrowUp className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" disabled={i === sorted.length - 1} onClick={() => clusterMove(s, 1)} title="Move down"><ArrowDown className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" onClick={() => setEditingServer(s)} title="Edit"><Cog className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-6 text-destructive hover:text-destructive" onClick={() => clusterDeleteServer(s)} title="Delete"><Trash2 className="size-3.5" /></Button>
</div>
</td>
</tr>
);
})}
{sorted.length === 0 && (
<tr><td colSpan={5} className="px-3 py-4 text-center text-muted-foreground text-xs">No cluster nodes saved yet.</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={clusterAddNew}>
<Plus className="size-3.5 mr-1" /> Add cluster
</Button>
<Button variant="outline" size="sm" onClick={async () => { await ConnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Connect all
</Button>
<Button variant="outline" size="sm" onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Disconnect all
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer ml-auto">
<Checkbox checked={clusterAutoConnect} onCheckedChange={(c) => setClusterAutoConnectState(!!c)} />
Auto-connect all enabled on app start
</label>
</div>
<p className="text-xs text-muted-foreground">
Free public nodes: <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
<span className="font-mono">w8avi.net:7300</span>.
</p>
</div>
{editingServer && (
<ClusterServerEditor
value={editingServer}
onCancel={() => setEditingServer(null)}
onSave={async (srv) => {
try {
await SaveClusterServer(srv as any);
await reloadClusterServers();
setEditingServer(null);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}
/>
)}
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
@@ -973,7 +1144,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: () => <ComingSoon id="cluster" icon={Wifi} />,
cluster: ClusterPanel,
backup: () => <ComingSoon id="backup" icon={Database} />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
@@ -1028,3 +1199,72 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Dialog>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
value: Omit<clusterModels.ServerConfig, 'convertValues'>;
onCancel: () => void;
onSave: (s: Omit<clusterModels.ServerConfig, 'convertValues'>) => void | Promise<void>;
}
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
const [s, setS] = useState(value);
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
return (
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-[640px] px-6">
<DialogHeader className="px-2">
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
<DialogDescription className="text-xs">
Telnet endpoint + optional login override and init commands. Init commands are sent one per line, 0.5s apart, right after login.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2 px-2">
<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…" />
</div>
<div className="space-y-1">
<Label>Host</Label>
<Input className="font-mono" value={s.host} onChange={(e) => update({ host: e.target.value })} placeholder="dxc.k0xm.net" />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
</div>
<div className="space-y-1">
<Label>Login callsign (optional)</Label>
<Input className="font-mono uppercase" value={s.login_override} onChange={(e) => update({ login_override: e.target.value })} placeholder="Active profile if empty" />
</div>
<div className="space-y-1">
<Label>Password (optional)</Label>
<Input type="password" value={s.password} onChange={(e) => update({ password: e.target.value })} autoComplete="off" />
</div>
<div className="space-y-1 col-span-2">
<Label>Init commands (one per line, // for comments)</Label>
<Textarea
className="font-mono text-xs min-h-[120px]"
value={s.init_commands}
onChange={(e) => update({ init_commands: e.target.value })}
placeholder={`// turn on DXCC info\nset/needsdxcc\nsh/dx 30`}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-2">
<Checkbox checked={s.enabled} onCheckedChange={(c) => update({ enabled: !!c })} />
Enabled (will be connected at startup if Auto-connect is on)
</label>
</div>
<DialogFooter className="px-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => onSave({ ...s, name: s.name.trim(), host: s.host.trim(), login_override: s.login_override.trim().toUpperCase() })}
disabled={!s.name.trim() || !s.host.trim()}
>
{s.id ? 'Save changes' : 'Create cluster'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}