From a8b762266738e75ec8c8739ceec9cac6efb44ffb Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 28 May 2026 08:48:41 +0200 Subject: [PATCH] update --- app.go | 355 ++++++++++++ frontend/src/App.tsx | 548 +++++++++++++++++- frontend/src/components/BandMap.tsx | 323 +++++++++++ frontend/src/components/SettingsModal.tsx | 246 +++++++- frontend/src/lib/spot.ts | 68 +++ frontend/wailsjs/go/main/App.d.ts | 33 +- frontend/wailsjs/go/main/App.js | 56 ++ frontend/wailsjs/go/models.ts | 134 +++++ internal/adif/export.go | 266 +++++++++ internal/cluster/cluster.go | 505 ++++++++++++++++ internal/cluster/cluster_test.go | 65 +++ .../db/migrations/0007_cluster_servers.sql | 21 + internal/lookup/lookup.go | 45 +- internal/qso/qso.go | 72 +++ 14 files changed, 2702 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/BandMap.tsx create mode 100644 frontend/src/lib/spot.ts create mode 100644 internal/adif/export.go create mode 100644 internal/cluster/cluster.go create mode 100644 internal/cluster/cluster_test.go create mode 100644 internal/db/migrations/0007_cluster_servers.sql diff --git a/app.go b/app.go index 3b21460..71551e1 100644 --- a/app.go +++ b/app.go @@ -14,6 +14,7 @@ import ( "hamlog/internal/adif" "hamlog/internal/cat" + "hamlog/internal/cluster" "hamlog/internal/db" "hamlog/internal/dxcc" "hamlog/internal/lookup" @@ -60,6 +61,8 @@ const ( keyRotatorHost = "rotator.host" keyRotatorPort = "rotator.port" keyRotatorHasElevation = "rotator.has_elevation" + + keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start ) // CATSettings is the user-tweakable rig-control configuration. Stored as @@ -141,6 +144,7 @@ type App struct { cache *lookup.Cache cat *cat.Manager dxcc *dxcc.Manager + cluster *cluster.Manager startupErr string // captured for surfacing to the frontend dbPath string } @@ -223,6 +227,26 @@ func (a *App) startup(ctx context.Context) { } }) a.reloadCAT() + + // DX Cluster (multi-server): spot callback pushes individual spots, + // status callback signals "something changed" so the frontend can + // fetch the aggregate via GetClusterStatus. + a.cluster = cluster.NewManager( + func(s cluster.Spot) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cluster:spot", s) + } + }, + func() { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status()) + } + }, + ) + if cs, _ := a.clusterAutoConnect(); cs { + a.startAllEnabledClusters() + } + fmt.Println("HamLog: db ready at", a.dbPath) } @@ -492,6 +516,33 @@ func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, e return im.ImportFile(a.ctx, path) } +// SaveADIFFile shows a native Save-As dialog suggesting a timestamped +// HamLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled. +func (a *App) SaveADIFFile() (string, error) { + suggested := "HamLog_" + time.Now().UTC().Format("20060102_150405") + ".adi" + return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "Export ADIF", + DefaultFilename: suggested, + Filters: []wruntime.FileFilter{ + {DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + }, + }) +} + +// ExportADIF writes every QSO to the given file path in ADIF 3.1 format. +// Streams from DB so memory stays flat even with 100k+ records. +func (a *App) ExportADIF(path string) (adif.ExportResult, error) { + if a.qso == nil { + return adif.ExportResult{}, fmt.Errorf("db not initialized") + } + if path == "" { + return adif.ExportResult{}, fmt.Errorf("empty path") + } + ex := &adif.Exporter{Repo: a.qso, AppName: "HamLog", AppVersion: "0.1"} + return ex.ExportFile(a.ctx, path) +} + // --- Lookup bindings --- // LookupCallsign returns the cached or freshly-fetched info for a callsign. @@ -1090,3 +1141,307 @@ func boolStr(b bool) string { } return "0" } + +// --- DX Cluster bindings (multi-server) --- + +// resolveClusterLogin returns the login callsign for a server: explicit +// override on the row, else the active profile's callsign. +func (a *App) resolveClusterLogin(override string) string { + if override != "" { + return strings.ToUpper(strings.TrimSpace(override)) + } + if a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + return strings.ToUpper(strings.TrimSpace(p.Callsign)) + } + } + return "" +} + +// clusterAutoConnect reads the global "auto-connect on startup" toggle. +// Stored in settings (key/value) since it's a single bool, not per-row. +func (a *App) clusterAutoConnect() (bool, error) { + if a.settings == nil { + return false, fmt.Errorf("db not initialized") + } + v, err := a.settings.Get(a.ctx, keyClusterAutoConnect) + if err != nil { + return false, err + } + return v == "1", nil +} + +// startAllEnabledClusters opens a session for every enabled server. +func (a *App) startAllEnabledClusters() { + servers, err := a.listClusterServers() + if err != nil { + fmt.Println("HamLog: list cluster servers:", err) + return + } + for _, s := range servers { + if s.Enabled { + a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) + } + } +} + +// listClusterServers reads the cluster_servers table ordered for display +// (sort_order asc, id asc). The first row with Enabled=true is the master. +func (a *App) listClusterServers() ([]cluster.ServerConfig, error) { + if a.db == nil { + return nil, fmt.Errorf("db not initialized") + } + rows, err := a.db.QueryContext(a.ctx, ` + SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order + FROM cluster_servers + ORDER BY sort_order ASC, id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []cluster.ServerConfig + for rows.Next() { + var s cluster.ServerConfig + var enabled int + if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride, + &s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil { + return nil, err + } + s.Enabled = enabled == 1 + out = append(out, s) + } + return out, rows.Err() +} + +// ListClusterServers returns all saved cluster nodes. +func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) { + return a.listClusterServers() +} + +// SaveClusterServer upserts one row. id=0 inserts a new server. Restarts +// the session if the row was already running (so config edits take effect +// immediately). +func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) { + if a.db == nil { + return cluster.ServerConfig{}, fmt.Errorf("db not initialized") + } + if strings.TrimSpace(s.Name) == "" { + return cluster.ServerConfig{}, fmt.Errorf("server name required") + } + if s.Port <= 0 || s.Port > 65535 { + s.Port = 7300 + } + now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + enabled := 0 + if s.Enabled { + enabled = 1 + } + if s.ID == 0 { + res, err := a.db.ExecContext(a.ctx, ` + INSERT INTO cluster_servers + (name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now) + if err != nil { + return cluster.ServerConfig{}, err + } + id, _ := res.LastInsertId() + s.ID = id + } else { + _, err := a.db.ExecContext(a.ctx, ` + UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?, + init_commands=?, enabled=?, sort_order=?, updated_at=? + WHERE id=?`, + s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID) + if err != nil { + return cluster.ServerConfig{}, err + } + } + // Apply runtime change: stop and restart if enabled, else just stop. + a.cluster.StopServer(s.ID) + if s.Enabled { + a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) + } + return s, nil +} + +// DeleteClusterServer drops a row and closes its session. +func (a *App) DeleteClusterServer(id int64) error { + if a.db == nil { + return fmt.Errorf("db not initialized") + } + a.cluster.StopServer(id) + _, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id) + return err +} + +// SetClusterAutoConnect persists the global auto-connect toggle. +func (a *App) SetClusterAutoConnect(on bool) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on)) +} + +// GetClusterAutoConnect reads the persisted toggle. +func (a *App) GetClusterAutoConnect() (bool, error) { + return a.clusterAutoConnect() +} + +// ConnectClusterServer opens a session for one specific saved server. +func (a *App) ConnectClusterServer(id int64) error { + if a.cluster == nil { + return fmt.Errorf("cluster not initialized") + } + servers, err := a.listClusterServers() + if err != nil { + return err + } + for _, s := range servers { + if s.ID == id { + if !s.Enabled { + return fmt.Errorf("server %q is disabled — enable it first", s.Name) + } + a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) + return nil + } + } + return fmt.Errorf("no saved server with id %d", id) +} + +// DisconnectClusterServer closes the session for one server. +func (a *App) DisconnectClusterServer(id int64) error { + if a.cluster == nil { + return fmt.Errorf("cluster not initialized") + } + a.cluster.StopServer(id) + return nil +} + +// ConnectAllClusters opens sessions for every enabled server. +func (a *App) ConnectAllClusters() error { + if a.cluster == nil { + return fmt.Errorf("cluster not initialized") + } + a.startAllEnabledClusters() + return nil +} + +// DisconnectAllClusters closes every running session. +func (a *App) DisconnectAllClusters() error { + if a.cluster == nil { + return fmt.Errorf("cluster not initialized") + } + a.cluster.StopAll() + return nil +} + +// SendClusterCommand writes `cmd` to the **master** cluster — the first +// enabled server by sort_order. Returns an error if the master is not +// currently connected (the UI should grey the input out in that case). +func (a *App) SendClusterCommand(cmd string) error { + if a.cluster == nil { + return fmt.Errorf("cluster not initialized") + } + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return fmt.Errorf("empty command") + } + servers, err := a.listClusterServers() + if err != nil { + return err + } + for _, s := range servers { + if s.Enabled { + return a.cluster.SendCommand(s.ID, cmd) + } + } + return fmt.Errorf("no enabled cluster server to send to") +} + +// GetClusterStatus returns a snapshot of every active session. Used by +// the UI on mount and to hydrate after a `cluster:state` event. +func (a *App) GetClusterStatus() []cluster.ServerStatus { + if a.cluster == nil { + return nil + } + return a.cluster.Status() +} + +// SpotQuery is one (call, band, mode) tuple sent for status colouring. +type SpotQuery struct { + Call string `json:"call"` + Band string `json:"band"` + Mode string `json:"mode"` +} + +// SpotStatus is the per-tuple result. Status is one of: +// +// "new" — entity never worked +// "new-band" — entity worked but never on this band +// "new-slot" — entity worked on this band but not in this mode +// "worked" — exact band+mode already in the log +// "" — couldn't resolve the entity (no cty.dat match) +type SpotStatus struct { + Call string `json:"call"` + Band string `json:"band"` + Mode string `json:"mode"` + Country string `json:"country,omitempty"` + Status string `json:"status"` +} + +// ClusterSpotStatuses takes a batch of spots and returns slot status for +// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT +// / WORKED). One cty.dat lookup + one DB scan, regardless of batch size. +// +// Mode handling: when the caller passes an empty Mode (cluster comment +// was ambiguous and the frontend couldn't infer) we degrade gracefully +// to band-only — saying "worked" rather than wrongly flagging "new-slot" +// just because we don't know the mode. +func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { + out := make([]SpotStatus, len(spots)) + if a.qso == nil { + return out + } + entities, err := a.qso.EntitySlotMap(a.ctx) + if err != nil { + return out + } + for i, q := range spots { + out[i] = SpotStatus{ + Call: q.Call, + Band: strings.ToLower(q.Band), + Mode: strings.ToUpper(q.Mode), + } + if a.dxcc == nil { + continue + } + m, ok := a.dxcc.Lookup(q.Call) + if !ok || m.Entity == nil { + continue + } + country := strings.ToLower(m.Entity.Name) + out[i].Country = m.Entity.Name + e, worked := entities[country] + if !worked { + out[i].Status = "new" + continue + } + if _, b := e.Bands[out[i].Band]; !b { + out[i].Status = "new-band" + continue + } + // Without a mode we can't distinguish "new slot" from "worked"; + // the safer default is "worked" so we never falsely claim "new". + if out[i].Mode == "" { + out[i].Status = "worked" + continue + } + if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { + out[i].Status = "new-slot" + continue + } + out[i].Status = "worked" + } + return out +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efeadf9..a366cc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); + 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([]); + const SPOTS_CAP = 1000; + const [clusterFilterSource, setClusterFilterSource] = useState(''); + const [clusterGroup, setClusterGroup] = useState(true); + const [clusterCmd, setClusterCmd] = useState(''); + // Multi-band filter: empty set = all bands. The user toggles chips. + const [clusterBands, setClusterBands] = useState>(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>(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>({}); + // === Modals === const [editingQSO, setEditingQSO] = useState(null); const [deletingQSO, setDeletingQSO] = useState(null); @@ -348,6 +408,7 @@ export default function App() { // === ADIF === const [importing, setImporting] = useState(false); + const [exporting, setExporting] = useState(false); const [importResult, setImportResult] = useState(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(); + 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() {
{total.toLocaleString('en-US')}
Total QSOs
+ + + {clusterServerStatuses.length === 0 && ( + + No active sessions — configure clusters in Settings → DX Cluster. + + )} + {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 ( + + {isMaster && } + {s.name} + {s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''} + + ); + })} +
+ {spots.length} live +
+ + {/* Row 2: filters */} +
+ setClusterSearch(e.target.value.toUpperCase())} + /> + Bands: + {bands.map((b) => { + const on = clusterBands.has(b); + return ( + + ); + })} + {clusterBands.size > 0 && ( + + )} +
+ + +
+ Status: + {([ + { 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 ( + + ); + })} +
+ + +
+ +
+ {(() => { + // 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(); + 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 ( +
+ +
+ {clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'} +
+
+ {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).'} +
+
+ ); + } + 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 ( + + + + {headers.map((h, i) => { + const sortable = h.key !== null; + const active = sortable && clusterSort.key === h.key; + return ( + + ); + })} + + + + {rendered.map((s, i) => ( + { + // 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} + > + + + + + + + + + + + ))} + +
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 && ( + + {clusterSort.dir === 'asc' ? '▲' : '▼'} + + )} +
1 ? `Seen ${s.repeats}× across active clusters` : undefined} + >{s.time_utc || ''}{s.dx_call}{s.freq_khz.toFixed(1)}{s.band || '—'}{(() => { + const m = inferSpotMode(s.comment ?? '', s.freq_hz); + if (!m) return ; + return {m}; + })()}{cleanSpotter(s.spotter)}{s.source_name}{s.locator || ''}{s.comment}
+ ); + })()} +
+ + {/* Command input — sends to the master server. */} +
+ → master + setClusterCmd(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && clusterCmd.trim()) { + SendClusterCommand(clusterCmd.trim()) + .then(() => setClusterCmd('')) + .catch((err) => setError(String(err?.message ?? err))); + } + }} + /> + +
+
{/* /left column */} + {/* BandMap moved to a global side panel below — toggle is + now in the topbar, visible on every tab. */} + + + {(['main','awards','propagation'] as const).map((t) => (
{t[0].toUpperCase() + t.slice(1)}
@@ -1423,6 +1936,29 @@ export default function App() { + {showBandMap && ( +
+ 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)} + /> +
+ )}
} diff --git a/frontend/src/components/BandMap.tsx b/frontend/src/components/BandMap.tsx new file mode 100644 index 0000000..c2075ce --- /dev/null +++ b/frontend/src/components/BandMap.tsx @@ -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; + 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 = { + '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 = { + '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(null); + const scrollerRef = useRef(null); + const innerRef = useRef(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 ( +
+
Band map
+ Not configured for {band || '—'}. +
+ ); + } + + // 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 ( +
+
+ Map · {band} + + {zoom}× + + + {onClose && ( + + )} +
+ +
+
+ {/* Scale column background — full height, segments stretched */} + + {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 ; + })} + {/* Scale border */} + + + + {/* Tick marks + labels on scale */} + {ticks.map((t) => { + const y = freqToY(t); + const major = t % (step * 5) === 0; + return ( +
+
+ {major && ( + + {t.toLocaleString()} + + )} +
+ ); + })} + + {/* SVG layer for leader lines + rig pointer */} + + {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 ( + + ); + })} + {showRigPointer && ( + <> + + + + )} + + + {/* Callsign label stack — one per line, sorted by freq desc */} +
+ {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 ( + + ); + })} +
+
+
+
+ scroll · ctrl+wheel = zoom · ◎ = recenter +
+
+ ); +} + +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)); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 4343eec..7ec9da7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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; type RotatorSettings = Omit; +type ClusterServer = Omit; +type ClusterServerStatus = Omit; type Profile = Omit; 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([]); + const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); + const [clusterStatuses, setClusterStatuses] = useState([]); + const [editingServer, setEditingServer] = useState(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([]); // 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 ( + <> + +
+
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + {sorted.length === 0 && ( + + )} + +
NameHost:portStatusActions
+ clusterToggleEnabled(s, !!c)} + /> + + {s.name} + {isMaster && s.enabled && ( + MASTER + )} + {s.host}:{s.port} + + {state.toUpperCase()} + {st?.retries ? ` #${st.retries}` : ''} + + +
+ + + + +
+
No cluster nodes saved yet.
+
+ +
+ + + + +
+

+ Free public nodes: dxc.k0xm.net:7300,{' '} + dx.maritimecontestclub.net:7300,{' '} + w8avi.net:7300. +

+
+ + {editingServer && ( + 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 JSX.Element> = { station: StationPanel, @@ -973,7 +1144,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { lookup: LookupPanel, 'lists-bands': BandsPanel, 'lists-modes': ModesPanel, - cluster: () => , + cluster: ClusterPanel, backup: () => , awards: () => , cat: CATPanel, @@ -1028,3 +1199,72 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { ); } + +// 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; + onCancel: () => void; + onSave: (s: Omit) => void | Promise; +} + +function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) { + const [s, setS] = useState(value); + const update = (patch: Partial) => setS((cur) => ({ ...cur, ...patch })); + return ( + { if (!o) onCancel(); }}> + + + {s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'} + + Telnet endpoint + optional login override and init commands. Init commands are sent one per line, 0.5s apart, right after login. + + +
+
+ + update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" /> +
+
+ + update({ host: e.target.value })} placeholder="dxc.k0xm.net" /> +
+
+ + update({ port: parseInt(e.target.value) || 7300 })} /> +
+
+ + update({ login_override: e.target.value })} placeholder="Active profile if empty" /> +
+
+ + update({ password: e.target.value })} autoComplete="off" /> +
+
+ +