update
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { spotStatusKey } from '@/lib/spot';
|
||||
|
||||
// BandMap — vertical spectrum panel. Layout follows Log4OM's well-loved
|
||||
// design: a kHz scale on the left, callsign labels stacked vertically on
|
||||
// the right (one per line, no overlap), connected to their actual
|
||||
// frequency on the scale by diagonal "leader" lines. Wheel-scroll for
|
||||
// long spot lists, Ctrl+wheel to zoom.
|
||||
|
||||
interface Spot {
|
||||
source_id?: number;
|
||||
source_name?: string;
|
||||
dx_call: string;
|
||||
freq_khz: number;
|
||||
freq_hz: number;
|
||||
band?: string;
|
||||
comment?: string;
|
||||
spotter?: string;
|
||||
}
|
||||
|
||||
type SpotStatusEntry = { status: string; country?: string };
|
||||
|
||||
interface Props {
|
||||
band: string;
|
||||
spots: Spot[];
|
||||
spotStatus: Record<string, SpotStatusEntry>;
|
||||
currentFreqHz: number;
|
||||
onSpotClick: (s: Spot) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Visible kHz range per band — covers IARU R1 plus a small pad so spots
|
||||
// right at the edge are still drawn.
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'160m': [1800, 2000],
|
||||
'80m': [3500, 3800],
|
||||
'60m': [5350, 5450],
|
||||
'40m': [7000, 7200],
|
||||
'30m': [10100, 10150],
|
||||
'20m': [14000, 14350],
|
||||
'17m': [18068, 18168],
|
||||
'15m': [21000, 21450],
|
||||
'12m': [24890, 24990],
|
||||
'10m': [28000, 29700],
|
||||
'6m': [50000, 50500],
|
||||
'4m': [70000, 70500],
|
||||
'2m': [144000, 146000],
|
||||
'70cm': [430000, 440000],
|
||||
};
|
||||
|
||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
};
|
||||
|
||||
function statusColor(s: string): { fg: string; line: string } {
|
||||
// fg is the label text colour; line is the SVG stroke. Both follow the
|
||||
// same NEW / NEW BAND / NEW SLOT / WORKED palette as the spot table.
|
||||
switch (s) {
|
||||
case 'new': return { fg: 'text-rose-700', line: 'stroke-rose-500' };
|
||||
case 'new-band': return { fg: 'text-amber-700', line: 'stroke-amber-500' };
|
||||
case 'new-slot': return { fg: 'text-yellow-700', line: 'stroke-yellow-600' };
|
||||
case 'worked': return { fg: 'text-muted-foreground', line: 'stroke-border' };
|
||||
default: return { fg: 'text-emerald-700', line: 'stroke-emerald-600' };
|
||||
}
|
||||
}
|
||||
|
||||
const ZOOMS = [1, 2, 4, 8, 16];
|
||||
const SCALE_W = 56; // px — left freq scale column
|
||||
const LINE_H = 18; // px — per-callsign row height
|
||||
const LABEL_PAD_LEFT = 24; // px — diagonal line lands here, before the text
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(0);
|
||||
const [center, setCenter] = useState<number | null>(null);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const innerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerH, setContainerH] = useState(400);
|
||||
|
||||
// Track the visible container height so we can stretch the scale.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => setContainerH(el.clientHeight));
|
||||
ro.observe(el);
|
||||
setContainerH(el.clientHeight);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Window geometry.
|
||||
const zoom = ZOOMS[zoomIdx];
|
||||
const fallback: [number, number] = range ?? [0, 1];
|
||||
const [bandLo, bandHi] = fallback;
|
||||
const visSpan = (bandHi - bandLo) / zoom;
|
||||
const c0 = center ?? (currentFreqHz > 0 ? currentFreqHz / 1000 : (bandLo + (bandHi - bandLo) / 2));
|
||||
const c = clampCenter(c0, fallback, zoom);
|
||||
const lo = c - visSpan / 2;
|
||||
const hi = c + visSpan / 2;
|
||||
const span = hi - lo;
|
||||
|
||||
// Filtered + sorted spots (highest freq first → top of the column).
|
||||
const visible = useMemo(() => {
|
||||
if (!range) return [];
|
||||
return spots
|
||||
.filter((s) => s.freq_khz >= lo && s.freq_khz <= hi)
|
||||
.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
}, [spots, lo, hi, range]);
|
||||
|
||||
// Total content height: stretch so every label has its own row, but
|
||||
// never shrink below the visible container so the scale fills the box
|
||||
// when there are few spots.
|
||||
const totalH = Math.max(containerH, visible.length * LINE_H + 16);
|
||||
|
||||
// Ctrl+wheel = zoom, regular wheel = native scroll (default browser).
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!range) return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
setZoomIdx((z) => Math.max(0, Math.min(ZOOMS.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
||||
}
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [range]);
|
||||
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
<div className="text-sm font-semibold mb-1">Band map</div>
|
||||
Not configured for {band || '—'}.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tick step adapts to visible kHz span so labels stay legible.
|
||||
let step = 100;
|
||||
if (span <= 1500) step = 50;
|
||||
if (span <= 800) step = 25;
|
||||
if (span <= 300) step = 10;
|
||||
if (span <= 100) step = 5;
|
||||
if (span <= 40) step = 2;
|
||||
if (span <= 20) step = 1;
|
||||
const ticks: number[] = [];
|
||||
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
|
||||
|
||||
// Y-axis convention: HIGH frequency at top, LOW at bottom (matches a
|
||||
// physical receiver dial). freqToY maps a kHz to pixel-Y in totalH.
|
||||
const freqToY = (kHz: number) => (1 - (kHz - lo) / span) * totalH;
|
||||
|
||||
function recenterOnRig() {
|
||||
if (currentFreqHz > 0) setCenter(clampCenter(currentFreqHz / 1000, range, zoom));
|
||||
else setCenter(null);
|
||||
// Also scroll to keep the rig pointer in view.
|
||||
if (scrollerRef.current && currentFreqHz > 0) {
|
||||
const y = freqToY(currentFreqHz / 1000);
|
||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||
}
|
||||
}
|
||||
|
||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||
const rigY = freqToY(currentKHz);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
|
||||
<span className="flex-1">Map · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom out">
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<span className="font-mono text-[10px] w-7 text-center">{zoom}×</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(ZOOMS.length - 1, z + 1))} disabled={zoomIdx === ZOOMS.length - 1}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom in">
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
<button type="button" onClick={recenterOnRig}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Center on current rig frequency">
|
||||
<Crosshair className="size-3" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Hide band map">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
|
||||
<div ref={innerRef} className="relative" style={{ height: totalH }}>
|
||||
{/* Scale column background — full height, segments stretched */}
|
||||
<svg
|
||||
className="absolute top-0 left-0 pointer-events-none"
|
||||
width={SCALE_W}
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{segments.map(([s, e, cls], i) => {
|
||||
if (e < lo || s > hi) return null;
|
||||
const y1 = freqToY(Math.min(e, hi));
|
||||
const y2 = freqToY(Math.max(s, lo));
|
||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||
})}
|
||||
{/* Scale border */}
|
||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||
</svg>
|
||||
|
||||
{/* Tick marks + labels on scale */}
|
||||
{ticks.map((t) => {
|
||||
const y = freqToY(t);
|
||||
const major = t % (step * 5) === 0;
|
||||
return (
|
||||
<div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
|
||||
<div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
|
||||
{major && (
|
||||
<span className="absolute left-1 text-[10px] font-mono text-muted-foreground/90 bg-card/80 px-0.5">
|
||||
{t.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* SVG layer for leader lines + rig pointer */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
width="100%"
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{visible.map((s, i) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const color = statusColor(st);
|
||||
const fy = freqToY(s.freq_khz);
|
||||
const ly = i * LINE_H + LINE_H / 2 + 8;
|
||||
return (
|
||||
<line
|
||||
key={`l-${i}-${s.dx_call}`}
|
||||
x1={SCALE_W}
|
||||
y1={fy}
|
||||
x2={SCALE_W + LABEL_PAD_LEFT}
|
||||
y2={ly}
|
||||
className={color.line}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showRigPointer && (
|
||||
<>
|
||||
<polygon
|
||||
points={`${SCALE_W - 1},${rigY - 4} ${SCALE_W + 5},${rigY} ${SCALE_W - 1},${rigY + 4}`}
|
||||
className="fill-primary"
|
||||
/>
|
||||
<line
|
||||
x1={SCALE_W + 5}
|
||||
y1={rigY}
|
||||
x2="100%"
|
||||
y2={rigY}
|
||||
className="stroke-primary/40"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Callsign label stack — one per line, sorted by freq desc */}
|
||||
<div className="absolute" style={{ left: SCALE_W + LABEL_PAD_LEFT, top: 8, right: 0 }}>
|
||||
{visible.map((s, i) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const color = statusColor(st);
|
||||
return (
|
||||
<button
|
||||
key={`${s.freq_khz}-${s.dx_call}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onSpotClick(s)}
|
||||
style={{ height: LINE_H, lineHeight: `${LINE_H}px` }}
|
||||
className={cn(
|
||||
'block w-full text-left px-1 font-mono text-[11px] font-bold hover:bg-accent/30 transition-colors whitespace-nowrap',
|
||||
color.fg,
|
||||
)}
|
||||
title={`${s.dx_call} · ${s.freq_khz.toFixed(1)} kHz${s.comment ? ' · ' + s.comment : ''}${s.spotter ? ' · de ' + s.spotter : ''}`}
|
||||
>
|
||||
{s.dx_call}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = recenter
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampCenter(c: number, [lo, hi]: [number, number], zoom: number): number {
|
||||
const halfSpan = (hi - lo) / zoom / 2;
|
||||
return Math.max(lo + halfSpan, Math.min(hi - halfSpan, c));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user