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
+542 -6
View File
@@ -6,7 +6,7 @@ import {
import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus,
@@ -16,6 +16,8 @@ import {
RefreshCtyDat,
RotatorGoTo, RotatorStop,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses,
GetCATSettings,
} from '../wailsjs/go/main/App';
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
@@ -27,6 +29,8 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { BandMap } from '@/components/BandMap';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
@@ -81,6 +85,9 @@ function fmtFreq(hz?: number): string {
if (!hz) return '';
return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
@@ -334,6 +341,59 @@ export default function App() {
const [filterMode, setFilterMode] = useState('');
const [activeTab, setActiveTab] = useState('recent');
// === DX Cluster live state ===
type ClusterSpot = {
source_id: number;
source_name: string;
spotter: string;
dx_call: string;
freq_khz: number;
freq_hz: number;
band?: string;
comment?: string;
locator?: string;
time_utc?: string;
received_at: string;
raw: string;
};
type ServerStatus = {
server_id: number;
name: string;
host: string;
port: number;
state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
login?: string;
error?: string;
spots_count?: number;
retries?: number;
};
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]);
const SPOTS_CAP = 1000;
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState('');
// Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current
// band and the mode filter follows the entry's current mode.
const [clusterLockBand, setClusterLockBand] = useState(false);
const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
const [clusterSearch, setClusterSearch] = useState('');
const [showBandMap, setShowBandMap] = useState(false);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
@@ -348,6 +408,7 @@ export default function App() {
// === ADIF ===
const [importing, setImporting] = useState(false);
const [exporting, setExporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
const [importDupsOpen, setImportDupsOpen] = useState(false);
@@ -495,6 +556,64 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events.
async function reloadClusterMeta() {
try {
const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
setClusterServerStatuses((st ?? []) as ServerStatus[]);
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
}
useEffect(() => {
reloadClusterMeta();
EventsOn('cluster:state', (sts: ServerStatus[]) => setClusterServerStatuses(sts ?? []));
EventsOn('cluster:spot', (sp: ClusterSpot) => {
setSpots((arr) => {
const next = [sp, ...arr];
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
});
return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Resolve slot status for any spot we haven't seen yet — debounced so we
// don't hammer the backend at firehose rate. The mode passed to the
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
// band-plan fallback, NOT just digital watering-hole detection — that's
// how CW spots get correctly classified instead of being labelled
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string }[] = [];
const seen = new Set<string>();
for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue;
seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
}
if (unknown.length === 0) return;
try {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { status: r.status ?? '', country: r.country };
}
return next;
});
} catch {}
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spots]);
async function save() {
if (!callsign.trim()) { setError('Callsign required'); return; }
setSaving(true); setError('');
@@ -692,6 +811,25 @@ export default function App() {
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function exportAdif() {
if (exporting) return;
setError('');
try {
const path = await SaveADIFFile();
if (!path) return;
setExporting(true);
const res = await ExportADIF(path);
// Reuse the error banner area for a brief success note (4s auto-dismiss).
const msg = `ADIF exported: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
setError(msg);
setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
} catch (e: any) {
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
} finally {
setExporting(false);
}
}
async function runImport() {
const path = pendingImportPath;
if (!path || importing) return;
@@ -714,7 +852,7 @@ export default function App() {
const menus: Menu[] = useMemo(() => [
{ name: 'file', label: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: true },
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
@@ -743,11 +881,12 @@ export default function App() {
{ name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About HamLog', action: 'help.about', disabled: true },
]},
], [total, selectedId, ctyRefreshing]);
], [total, selectedId, ctyRefreshing, exporting]);
function handleMenu(action: string) {
switch (action) {
case 'file.import': importAdif(); break;
case 'file.export': exportAdif(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); break;
@@ -973,6 +1112,15 @@ export default function App() {
<div className="font-bold text-[15px] leading-none">{total.toLocaleString('en-US')}</div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
</div>
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle band map (visible across all tabs)"
className="h-8"
>
Band map
</Button>
<Button
variant="outline"
size="icon"
@@ -1263,8 +1411,8 @@ export default function App() {
onChange={updateDetails}
/>
{/* ===== LOWER: tabs+table | call history ===== */}
<div className="grid grid-cols-[1fr_360px] gap-2.5 p-2.5 flex-1 min-h-0">
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0', showBandMap ? 'grid-cols-[1fr_360px_260px]' : 'grid-cols-[1fr_360px]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0">
<TabsList className="px-3 shrink-0">
@@ -1412,7 +1560,372 @@ export default function App() {
</div>
</TabsContent>
{(['main','cluster','awards','propagation'] as const).map((t) => (
<TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
<div className="flex flex-col min-h-0 flex-1">
{/* Row 1: actions + per-server pills */}
<div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
<Button
variant="outline" size="sm"
onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
>
Connect all
</Button>
<Button
variant="outline" size="sm"
onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
>
Disconnect all
</Button>
{clusterServerStatuses.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster.
</span>
)}
{clusterServerStatuses.map((s) => {
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
return (
<span
key={s.server_id}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
>
{isMaster && <span className="text-amber-600" title="Master (commands go here)"></span>}
{s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span>
);
})}
<div className="flex-1" />
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
</div>
{/* Row 2: filters */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
<Input
className="w-32 h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
<span className="text-muted-foreground">Bands:</span>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => {
const n = new Set(s);
if (n.has(b)) n.delete(b); else n.add(b);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
on
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
>
{b}
</button>
);
})}
{clusterBands.size > 0 && (
<button
type="button"
onClick={() => setClusterBands(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline"
title="Clear band filter"
>
clear
</button>
)}
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockBand
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots on the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock band ({band})
</button>
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockMode
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock mode ({mode})
</button>
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Status:</span>
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterStatusFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="flex-1" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group
</label>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-auto">
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
// Treat empty inferred mode as wildcard so we don't
// hide perfectly good spots just because the comment
// was ambiguous.
if (spotMode && mode && spotMode !== mode) return false;
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
}
// Apply sort. Time defaults to descending (newest first).
const dir = clusterSort.dir === 'asc' ? 1 : -1;
const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0);
rendered = [...rendered].sort((a, b) => {
switch (clusterSort.key) {
case 'time': return cmp(a.received_at, b.received_at);
case 'call': return cmp(a.dx_call, b.dx_call);
case 'freq': return cmp(a.freq_khz, b.freq_khz);
case 'band': return cmp(a.band ?? '', b.band ?? '');
case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz));
case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter));
case 'source': return cmp(a.source_name, b.source_name);
}
});
if (rendered.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
}
const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [
{ key: 'time', label: 'Time' },
{ key: 'call', label: 'Call' },
{ key: 'freq', label: 'Freq', align: 'right' },
{ key: 'band', label: 'Band' },
{ key: 'mode', label: 'Mode' },
{ key: 'spotter', label: 'Spotter' },
{ key: 'source', label: 'Source' },
{ key: null, label: 'Loc' },
{ key: null, label: 'Comment' },
];
const toggleSort = (k: SortKey) => setClusterSort((s) =>
s.key === k
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
const rowColor = (s: ClusterSpot): string => {
// The cache key includes the inferred mode (from
// comment / band-plan) so CW vs FT8 on the same
// band get distinct statuses.
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k];
if (!st) return '';
switch (st.status) {
case 'new': return 'bg-rose-50 hover:bg-rose-100';
case 'new-band': return 'bg-amber-50 hover:bg-amber-100';
case 'new-slot': return 'bg-yellow-50 hover:bg-yellow-100';
default: return '';
}
};
return (
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr>
{headers.map((h, i) => {
const sortable = h.key !== null;
const active = sortable && clusterSort.key === h.key;
return (
<th
key={i}
onClick={sortable ? () => toggleSort(h.key as SortKey) : undefined}
className={cn(
'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0',
h.align === 'right' ? 'text-right' : 'text-left',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary',
)}
>
{h.label}
{active && (
<span className="ml-1 inline-block text-[9px]">
{clusterSort.dir === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rendered.map((s, i) => (
<tr
key={`${s.received_at}-${s.dx_call}-${i}`}
className={cn('cursor-pointer', rowColor(s) || 'hover:bg-accent/30')}
onClick={() => {
// Mode comes from the spot itself (comment text
// first, band plan fallback). Sending it to CAT
// matters because skipping it leaves the rig
// on whatever it had — typically DIGU after a
// previous FT8 contact, which breaks a SSB click.
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
title={s.raw}
>
<td
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
>{s.time_utc || ''}</td>
<td className="px-2.5 py-1.5 font-mono font-bold text-primary whitespace-nowrap border-b border-border/40">{s.dx_call}</td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
<td className="px-2.5 py-1.5 border-b border-border/40"><Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge></td>
<td className="px-2.5 py-1.5 border-b border-border/40">{(() => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (!m) return <span className="text-muted-foreground text-[10px]"></span>;
return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[10px] py-0" variant="outline">{m}</Badge>;
})()}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
</tr>
))}
</tbody>
</table>
);
})()}
</div>
{/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input
className="font-mono text-xs h-8"
placeholder='sh/dx 30, set/needsdxcc, …'
value={clusterCmd}
onChange={(e) => setClusterCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && clusterCmd.trim()) {
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}
}}
/>
<Button
variant="outline" size="sm"
onClick={() => {
if (!clusterCmd.trim()) return;
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}}
disabled={!clusterCmd.trim()}
>
Send
</Button>
</div>
</div>{/* /left column */}
{/* BandMap moved to a global side panel below — toggle is
now in the topbar, visible on every tab. */}
</TabsContent>
{(['main','awards','propagation'] as const).map((t) => (
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-base font-semibold text-foreground/70">{t[0].toUpperCase() + t.slice(1)}</div>
@@ -1423,6 +1936,29 @@ export default function App() {
</section>
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
{showBandMap && (
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<BandMap
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</div>
</>}
+323
View File
@@ -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));
}
+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>
);
}
+68
View File
@@ -0,0 +1,68 @@
// Shared helpers used by the cluster table and the band map — keeps the
// mode-inference logic and the status-cache key in one place so both
// surfaces always read the same data.
export function cleanSpotter(s: string): string {
if (!s) return '';
const i = s.indexOf('-');
return i > 0 ? s.slice(0, i) : s;
}
// inferSpotMode picks an ADIF mode for a cluster spot. Comment text is
// the strongest hint (skimmers say "FT8", "CW 24 WPM", "RTTY"); when it
// fails we fall back to the IARU R1 band-plan segment for the frequency.
// Returns '' when nothing matches — caller should leave the rig mode
// alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase();
if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8';
if (/\bQ65\b/.test(c)) return 'Q65';
if (/\bMSK144\b/.test(c)) return 'MSK144';
if (/\bJT65\b/.test(c)) return 'JT65';
if (/\bJT9\b/.test(c)) return 'JT9';
if (/\bRTTY\b/.test(c)) return 'RTTY';
if (/\bPSK(63|125|250|500)\b/.test(c)) return RegExp.$1 ? `PSK${RegExp.$1}` : 'PSK31';
if (/\bPSK31?\b/.test(c)) return 'PSK31';
if (/\bOLIVIA\b/.test(c)) return 'OLIVIA';
if (/\bMFSK\b/.test(c)) return 'MFSK16';
if (/\bCW\b/.test(c) || /\bWPM\b/.test(c)) return 'CW';
if (/\bFM\b/.test(c)) return 'FM';
if (/\bAM\b/.test(c)) return 'AM';
if (/\b(SSB|USB|LSB)\b/.test(c)) return 'SSB';
const mhz = freqHz / 1_000_000;
type Seg = [number, number, string];
const segs: Seg[] = [
[1.8, 1.838, 'CW'], [1.838, 1.84, 'FT8'], [1.84, 2.0, 'SSB'],
[3.5, 3.58, 'CW'], [3.573, 3.576, 'FT8'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'],
[5.3, 5.5, 'SSB'],
[7.0, 7.04, 'CW'], [7.074, 7.077, 'FT8'], [7.0475, 7.0485, 'FT4'],
[7.04, 7.1, 'DATA'], [7.1, 7.3, 'SSB'],
[10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'],
[14.0, 14.07, 'CW'], [14.074, 14.077, 'FT8'], [14.08, 14.0815, 'FT4'],
[14.07, 14.1, 'DATA'], [14.1, 14.35, 'SSB'],
[18.068, 18.095, 'CW'], [18.1, 18.103, 'FT8'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'],
[21.0, 21.07, 'CW'], [21.074, 21.077, 'FT8'], [21.14, 21.143, 'FT4'],
[21.07, 21.15, 'DATA'], [21.15, 21.45, 'SSB'],
[24.89, 24.915, 'CW'], [24.915, 24.917, 'FT8'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'],
[28.0, 28.07, 'CW'], [28.074, 28.077, 'FT8'], [28.18, 28.183, 'FT4'],
[28.07, 28.3, 'DATA'], [28.3, 29.7, 'SSB'],
[50.0, 50.1, 'CW'], [50.313, 50.316, 'FT8'], [50.318, 50.321, 'FT4'],
[50.1, 50.5, 'SSB'],
[144.0, 144.15, 'CW'], [144.174, 144.177, 'FT8'], [144.15, 144.5, 'SSB'],
];
for (const [lo, hi, m] of segs) {
if (mhz >= lo && mhz < hi) return m;
}
return '';
}
// spotStatusKey is the cache key for ClusterSpotStatuses results. Must be
// computed identically in the fetcher and every reader — including the
// band map and the spot table — so a CW spot's status doesn't get looked
// up under an empty-mode key (which always misses → false "new-slot").
export function spotStatusKey(call: string, band: string, comment: string, freqHz: number): string {
return `${call}|${band}|${inferSpotMode(comment, freqHz)}`;
}
+31 -2
View File
@@ -1,10 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {qso} from '../models';
import {profile} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {adif} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {lookup} from '../models';
export function ActivateProfile(arg1:number):Promise<void>;
@@ -13,22 +14,40 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>;
export function CountQSO():Promise<number>;
export function DeleteAllQSO():Promise<number>;
export function DeleteClusterServer(arg1:number):Promise<void>;
export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>;
export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetListsSettings():Promise<main.ListsSettings>;
@@ -45,6 +64,8 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListProfiles():Promise<Array<profile.Profile>>;
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
@@ -63,8 +84,12 @@ export function RotatorPark():Promise<void>;
export function RotatorStop():Promise<void>;
export function SaveADIFFile():Promise<string>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -75,10 +100,14 @@ export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SendClusterCommand(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
+56
View File
@@ -14,6 +14,18 @@ export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache']();
}
export function ClusterSpotStatuses(arg1) {
return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
}
export function ConnectAllClusters() {
return window['go']['main']['App']['ConnectAllClusters']();
}
export function ConnectClusterServer(arg1) {
return window['go']['main']['App']['ConnectClusterServer'](arg1);
}
export function CountQSO() {
return window['go']['main']['App']['CountQSO']();
}
@@ -22,6 +34,10 @@ export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO']();
}
export function DeleteClusterServer(arg1) {
return window['go']['main']['App']['DeleteClusterServer'](arg1);
}
export function DeleteProfile(arg1) {
return window['go']['main']['App']['DeleteProfile'](arg1);
}
@@ -30,10 +46,22 @@ export function DeleteQSO(arg1) {
return window['go']['main']['App']['DeleteQSO'](arg1);
}
export function DisconnectAllClusters() {
return window['go']['main']['App']['DisconnectAllClusters']();
}
export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
}
export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
export function ExportADIF(arg1) {
return window['go']['main']['App']['ExportADIF'](arg1);
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
@@ -46,6 +74,14 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect']();
}
export function GetClusterStatus() {
return window['go']['main']['App']['GetClusterStatus']();
}
export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo']();
}
@@ -78,6 +114,10 @@ export function ImportADIF(arg1, arg2) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2);
}
export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers']();
}
export function ListProfiles() {
return window['go']['main']['App']['ListProfiles']();
}
@@ -114,10 +154,18 @@ export function RotatorStop() {
return window['go']['main']['App']['RotatorStop']();
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1);
}
export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
@@ -138,6 +186,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -146,6 +198,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
}
export function SetCompactMode(arg1) {
return window['go']['main']['App']['SetCompactMode'](arg1);
}
+134
View File
@@ -1,5 +1,21 @@
export namespace adif {
export class ExportResult {
path: string;
count: number;
size_kb: number;
static createFrom(source: any = {}) {
return new ExportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.count = source["count"];
this.size_kb = source["size_kb"];
}
}
export class ImportResult {
total: number;
imported: number;
@@ -85,6 +101,88 @@ export namespace cat {
}
export namespace cluster {
export class ServerConfig {
id: number;
name: string;
host: string;
port: number;
login_override: string;
password?: string;
init_commands: string;
enabled: boolean;
sort_order: number;
static createFrom(source: any = {}) {
return new ServerConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.host = source["host"];
this.port = source["port"];
this.login_override = source["login_override"];
this.password = source["password"];
this.init_commands = source["init_commands"];
this.enabled = source["enabled"];
this.sort_order = source["sort_order"];
}
}
export class ServerStatus {
server_id: number;
name: string;
host: string;
port: number;
state: string;
login?: string;
error?: string;
// Go type: time
connected_at?: any;
spots_count?: number;
retries?: number;
static createFrom(source: any = {}) {
return new ServerStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.server_id = source["server_id"];
this.name = source["name"];
this.host = source["host"];
this.port = source["port"];
this.state = source["state"];
this.login = source["login"];
this.error = source["error"];
this.connected_at = this.convertValues(source["connected_at"], null);
this.spots_count = source["spots_count"];
this.retries = source["retries"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace lookup {
export class Result {
@@ -292,6 +390,42 @@ export namespace main {
this.has_elevation = source["has_elevation"];
}
}
export class SpotQuery {
call: string;
band: string;
mode: string;
static createFrom(source: any = {}) {
return new SpotQuery(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.call = source["call"];
this.band = source["band"];
this.mode = source["mode"];
}
}
export class SpotStatus {
call: string;
band: string;
mode: string;
country?: string;
status: string;
static createFrom(source: any = {}) {
return new SpotStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.call = source["call"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class StartupStatus {
ok: boolean;
err: string;