update
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
@@ -60,6 +61,8 @@ const (
|
|||||||
keyRotatorHost = "rotator.host"
|
keyRotatorHost = "rotator.host"
|
||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
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
|
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||||
@@ -141,6 +144,7 @@ type App struct {
|
|||||||
cache *lookup.Cache
|
cache *lookup.Cache
|
||||||
cat *cat.Manager
|
cat *cat.Manager
|
||||||
dxcc *dxcc.Manager
|
dxcc *dxcc.Manager
|
||||||
|
cluster *cluster.Manager
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string
|
dbPath string
|
||||||
}
|
}
|
||||||
@@ -223,6 +227,26 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
a.reloadCAT()
|
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)
|
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)
|
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 ---
|
// --- Lookup bindings ---
|
||||||
|
|
||||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||||
@@ -1090,3 +1141,307 @@ func boolStr(b bool) string {
|
|||||||
}
|
}
|
||||||
return "0"
|
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
|
||||||
|
}
|
||||||
|
|||||||
+542
-6
@@ -6,7 +6,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO,
|
AddQSO, ListQSO, CountQSO,
|
||||||
OpenADIFFile, ImportADIF,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus,
|
GetStartupStatus,
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
RefreshCtyDat,
|
RefreshCtyDat,
|
||||||
RotatorGoTo, RotatorStop,
|
RotatorGoTo, RotatorStop,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
|
ListClusterServers, ClusterSpotStatuses,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
||||||
@@ -27,6 +29,8 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
|
import { BandMap } from '@/components/BandMap';
|
||||||
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
|
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
|
||||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||||
|
|
||||||
@@ -81,6 +85,9 @@ function fmtFreq(hz?: number): string {
|
|||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
return (hz / 1_000_000).toFixed(4);
|
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 {
|
function fmtHMSUTC(d: Date): string {
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
||||||
@@ -334,6 +341,59 @@ export default function App() {
|
|||||||
const [filterMode, setFilterMode] = useState('');
|
const [filterMode, setFilterMode] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState('recent');
|
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 ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
||||||
@@ -348,6 +408,7 @@ export default function App() {
|
|||||||
|
|
||||||
// === ADIF ===
|
// === ADIF ===
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||||
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||||
@@ -495,6 +556,64 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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() {
|
async function save() {
|
||||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
@@ -692,6 +811,25 @@ export default function App() {
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} 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() {
|
async function runImport() {
|
||||||
const path = pendingImportPath;
|
const path = pendingImportPath;
|
||||||
if (!path || importing) return;
|
if (!path || importing) return;
|
||||||
@@ -714,7 +852,7 @@ export default function App() {
|
|||||||
const menus: Menu[] = useMemo(() => [
|
const menus: Menu[] = useMemo(() => [
|
||||||
{ name: 'file', label: 'File', items: [
|
{ name: 'file', label: 'File', items: [
|
||||||
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
{ 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: 'separator' },
|
||||||
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -743,11 +881,12 @@ export default function App() {
|
|||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About HamLog', action: 'help.about', disabled: true },
|
{ type: 'item', label: 'About HamLog', action: 'help.about', disabled: true },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, ctyRefreshing]);
|
], [total, selectedId, ctyRefreshing, exporting]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'file.import': importAdif(); break;
|
case 'file.import': importAdif(); break;
|
||||||
|
case 'file.export': exportAdif(); break;
|
||||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); 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="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 className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
|
||||||
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1263,8 +1411,8 @@ export default function App() {
|
|||||||
onChange={updateDetails}
|
onChange={updateDetails}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ===== LOWER: tabs+table | call history ===== */}
|
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
||||||
<div className="grid grid-cols-[1fr_360px] gap-2.5 p-2.5 flex-1 min-h-0">
|
<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">
|
<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">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0">
|
||||||
<TabsList className="px-3 shrink-0">
|
<TabsList className="px-3 shrink-0">
|
||||||
@@ -1412,7 +1560,372 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<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" />
|
<Hash className="size-10 opacity-30" />
|
||||||
<div className="text-base font-semibold text-foreground/70">{t[0].toUpperCase() + t.slice(1)}</div>
|
<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>
|
</section>
|
||||||
|
|
||||||
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
|
<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>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { spotStatusKey } from '@/lib/spot';
|
||||||
|
|
||||||
|
// BandMap — vertical spectrum panel. Layout follows Log4OM's well-loved
|
||||||
|
// design: a kHz scale on the left, callsign labels stacked vertically on
|
||||||
|
// the right (one per line, no overlap), connected to their actual
|
||||||
|
// frequency on the scale by diagonal "leader" lines. Wheel-scroll for
|
||||||
|
// long spot lists, Ctrl+wheel to zoom.
|
||||||
|
|
||||||
|
interface Spot {
|
||||||
|
source_id?: number;
|
||||||
|
source_name?: string;
|
||||||
|
dx_call: string;
|
||||||
|
freq_khz: number;
|
||||||
|
freq_hz: number;
|
||||||
|
band?: string;
|
||||||
|
comment?: string;
|
||||||
|
spotter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotStatusEntry = { status: string; country?: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
band: string;
|
||||||
|
spots: Spot[];
|
||||||
|
spotStatus: Record<string, SpotStatusEntry>;
|
||||||
|
currentFreqHz: number;
|
||||||
|
onSpotClick: (s: Spot) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible kHz range per band — covers IARU R1 plus a small pad so spots
|
||||||
|
// right at the edge are still drawn.
|
||||||
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
|
'160m': [1800, 2000],
|
||||||
|
'80m': [3500, 3800],
|
||||||
|
'60m': [5350, 5450],
|
||||||
|
'40m': [7000, 7200],
|
||||||
|
'30m': [10100, 10150],
|
||||||
|
'20m': [14000, 14350],
|
||||||
|
'17m': [18068, 18168],
|
||||||
|
'15m': [21000, 21450],
|
||||||
|
'12m': [24890, 24990],
|
||||||
|
'10m': [28000, 29700],
|
||||||
|
'6m': [50000, 50500],
|
||||||
|
'4m': [70000, 70500],
|
||||||
|
'2m': [144000, 146000],
|
||||||
|
'70cm': [430000, 440000],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
|
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
||||||
|
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
||||||
|
'60m': [[5350, 5450, 'fill-amber-50']],
|
||||||
|
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
||||||
|
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
||||||
|
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
||||||
|
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
||||||
|
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
||||||
|
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
||||||
|
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
||||||
|
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusColor(s: string): { fg: string; line: string } {
|
||||||
|
// fg is the label text colour; line is the SVG stroke. Both follow the
|
||||||
|
// same NEW / NEW BAND / NEW SLOT / WORKED palette as the spot table.
|
||||||
|
switch (s) {
|
||||||
|
case 'new': return { fg: 'text-rose-700', line: 'stroke-rose-500' };
|
||||||
|
case 'new-band': return { fg: 'text-amber-700', line: 'stroke-amber-500' };
|
||||||
|
case 'new-slot': return { fg: 'text-yellow-700', line: 'stroke-yellow-600' };
|
||||||
|
case 'worked': return { fg: 'text-muted-foreground', line: 'stroke-border' };
|
||||||
|
default: return { fg: 'text-emerald-700', line: 'stroke-emerald-600' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZOOMS = [1, 2, 4, 8, 16];
|
||||||
|
const SCALE_W = 56; // px — left freq scale column
|
||||||
|
const LINE_H = 18; // px — per-callsign row height
|
||||||
|
const LABEL_PAD_LEFT = 24; // px — diagonal line lands here, before the text
|
||||||
|
|
||||||
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||||
|
const range = BAND_RANGES[band];
|
||||||
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
|
const [zoomIdx, setZoomIdx] = useState(0);
|
||||||
|
const [center, setCenter] = useState<number | null>(null);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
|
// Track the visible container height so we can stretch the scale.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => setContainerH(el.clientHeight));
|
||||||
|
ro.observe(el);
|
||||||
|
setContainerH(el.clientHeight);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Window geometry.
|
||||||
|
const zoom = ZOOMS[zoomIdx];
|
||||||
|
const fallback: [number, number] = range ?? [0, 1];
|
||||||
|
const [bandLo, bandHi] = fallback;
|
||||||
|
const visSpan = (bandHi - bandLo) / zoom;
|
||||||
|
const c0 = center ?? (currentFreqHz > 0 ? currentFreqHz / 1000 : (bandLo + (bandHi - bandLo) / 2));
|
||||||
|
const c = clampCenter(c0, fallback, zoom);
|
||||||
|
const lo = c - visSpan / 2;
|
||||||
|
const hi = c + visSpan / 2;
|
||||||
|
const span = hi - lo;
|
||||||
|
|
||||||
|
// Filtered + sorted spots (highest freq first → top of the column).
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
if (!range) return [];
|
||||||
|
return spots
|
||||||
|
.filter((s) => s.freq_khz >= lo && s.freq_khz <= hi)
|
||||||
|
.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||||
|
}, [spots, lo, hi, range]);
|
||||||
|
|
||||||
|
// Total content height: stretch so every label has its own row, but
|
||||||
|
// never shrink below the visible container so the scale fills the box
|
||||||
|
// when there are few spots.
|
||||||
|
const totalH = Math.max(containerH, visible.length * LINE_H + 16);
|
||||||
|
|
||||||
|
// Ctrl+wheel = zoom, regular wheel = native scroll (default browser).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!range) return;
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setZoomIdx((z) => Math.max(0, Math.min(ZOOMS.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||||
|
<div className="text-sm font-semibold mb-1">Band map</div>
|
||||||
|
Not configured for {band || '—'}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick step adapts to visible kHz span so labels stay legible.
|
||||||
|
let step = 100;
|
||||||
|
if (span <= 1500) step = 50;
|
||||||
|
if (span <= 800) step = 25;
|
||||||
|
if (span <= 300) step = 10;
|
||||||
|
if (span <= 100) step = 5;
|
||||||
|
if (span <= 40) step = 2;
|
||||||
|
if (span <= 20) step = 1;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
|
||||||
|
|
||||||
|
// Y-axis convention: HIGH frequency at top, LOW at bottom (matches a
|
||||||
|
// physical receiver dial). freqToY maps a kHz to pixel-Y in totalH.
|
||||||
|
const freqToY = (kHz: number) => (1 - (kHz - lo) / span) * totalH;
|
||||||
|
|
||||||
|
function recenterOnRig() {
|
||||||
|
if (currentFreqHz > 0) setCenter(clampCenter(currentFreqHz / 1000, range, zoom));
|
||||||
|
else setCenter(null);
|
||||||
|
// Also scroll to keep the rig pointer in view.
|
||||||
|
if (scrollerRef.current && currentFreqHz > 0) {
|
||||||
|
const y = freqToY(currentFreqHz / 1000);
|
||||||
|
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||||
|
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||||
|
const rigY = freqToY(currentKHz);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||||
|
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
|
||||||
|
<span className="flex-1">Map · {band}</span>
|
||||||
|
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
||||||
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
|
title="Zoom out">
|
||||||
|
<Minus className="size-3" />
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[10px] w-7 text-center">{zoom}×</span>
|
||||||
|
<button type="button" onClick={() => setZoomIdx((z) => Math.min(ZOOMS.length - 1, z + 1))} disabled={zoomIdx === ZOOMS.length - 1}
|
||||||
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
|
title="Zoom in">
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={recenterOnRig}
|
||||||
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
|
title="Center on current rig frequency">
|
||||||
|
<Crosshair className="size-3" />
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
|
title="Hide band map">
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
|
||||||
|
<div ref={innerRef} className="relative" style={{ height: totalH }}>
|
||||||
|
{/* Scale column background — full height, segments stretched */}
|
||||||
|
<svg
|
||||||
|
className="absolute top-0 left-0 pointer-events-none"
|
||||||
|
width={SCALE_W}
|
||||||
|
height={totalH}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{segments.map(([s, e, cls], i) => {
|
||||||
|
if (e < lo || s > hi) return null;
|
||||||
|
const y1 = freqToY(Math.min(e, hi));
|
||||||
|
const y2 = freqToY(Math.max(s, lo));
|
||||||
|
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||||
|
})}
|
||||||
|
{/* Scale border */}
|
||||||
|
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Tick marks + labels on scale */}
|
||||||
|
{ticks.map((t) => {
|
||||||
|
const y = freqToY(t);
|
||||||
|
const major = t % (step * 5) === 0;
|
||||||
|
return (
|
||||||
|
<div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
|
||||||
|
<div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
|
||||||
|
{major && (
|
||||||
|
<span className="absolute left-1 text-[10px] font-mono text-muted-foreground/90 bg-card/80 px-0.5">
|
||||||
|
{t.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* SVG layer for leader lines + rig pointer */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
width="100%"
|
||||||
|
height={totalH}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{visible.map((s, i) => {
|
||||||
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
|
const st = spotStatus[k]?.status ?? '';
|
||||||
|
const color = statusColor(st);
|
||||||
|
const fy = freqToY(s.freq_khz);
|
||||||
|
const ly = i * LINE_H + LINE_H / 2 + 8;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`l-${i}-${s.dx_call}`}
|
||||||
|
x1={SCALE_W}
|
||||||
|
y1={fy}
|
||||||
|
x2={SCALE_W + LABEL_PAD_LEFT}
|
||||||
|
y2={ly}
|
||||||
|
className={color.line}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showRigPointer && (
|
||||||
|
<>
|
||||||
|
<polygon
|
||||||
|
points={`${SCALE_W - 1},${rigY - 4} ${SCALE_W + 5},${rigY} ${SCALE_W - 1},${rigY + 4}`}
|
||||||
|
className="fill-primary"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={SCALE_W + 5}
|
||||||
|
y1={rigY}
|
||||||
|
x2="100%"
|
||||||
|
y2={rigY}
|
||||||
|
className="stroke-primary/40"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Callsign label stack — one per line, sorted by freq desc */}
|
||||||
|
<div className="absolute" style={{ left: SCALE_W + LABEL_PAD_LEFT, top: 8, right: 0 }}>
|
||||||
|
{visible.map((s, i) => {
|
||||||
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
|
const st = spotStatus[k]?.status ?? '';
|
||||||
|
const color = statusColor(st);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${s.freq_khz}-${s.dx_call}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSpotClick(s)}
|
||||||
|
style={{ height: LINE_H, lineHeight: `${LINE_H}px` }}
|
||||||
|
className={cn(
|
||||||
|
'block w-full text-left px-1 font-mono text-[11px] font-bold hover:bg-accent/30 transition-colors whitespace-nowrap',
|
||||||
|
color.fg,
|
||||||
|
)}
|
||||||
|
title={`${s.dx_call} · ${s.freq_khz.toFixed(1)} kHz${s.comment ? ' · ' + s.comment : ''}${s.spotter ? ' · de ' + s.spotter : ''}`}
|
||||||
|
>
|
||||||
|
{s.dx_call}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
|
scroll · ctrl+wheel = zoom · ◎ = recenter
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCenter(c: number, [lo, hi]: [number, number], zoom: number): number {
|
||||||
|
const halfSpan = (hi - lo) / zoom / 2;
|
||||||
|
return Math.max(lo + halfSpan, Math.min(hi - halfSpan, c));
|
||||||
|
}
|
||||||
@@ -11,10 +11,14 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
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 {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
@@ -35,6 +39,8 @@ type ListsSettings = ListsSettingsForm;
|
|||||||
type ModePreset = ModePresetForm;
|
type ModePreset = ModePresetForm;
|
||||||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
type RotatorSettings = Omit<mainModels.RotatorSettings, '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'>;
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
|
|
||||||
const emptyProfile = (): Profile => ({
|
const emptyProfile = (): Profile => ({
|
||||||
@@ -94,7 +100,7 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
||||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
{ 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: 'Backup / Export', id: 'backup', disabled: true },
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', 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 [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
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[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||||
// the panel as a plain function, not as a JSX element, so any useState
|
// 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);
|
setActiveProfile(ap as Profile);
|
||||||
setLists(ls);
|
setLists(ls);
|
||||||
await reloadProfiles();
|
await reloadProfiles();
|
||||||
|
await reloadClusterServers();
|
||||||
setBandsText((ls.bands ?? []).join('\n'));
|
setBandsText((ls.bands ?? []).join('\n'));
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotator(r);
|
||||||
@@ -367,6 +392,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
|
await SetClusterAutoConnect(clusterAutoConnect);
|
||||||
|
|
||||||
setMsg('Settings saved.');
|
setMsg('Settings saved.');
|
||||||
onSaved();
|
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).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
station: StationPanel,
|
station: StationPanel,
|
||||||
@@ -973,7 +1144,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
lookup: LookupPanel,
|
lookup: LookupPanel,
|
||||||
'lists-bands': BandsPanel,
|
'lists-bands': BandsPanel,
|
||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
cluster: () => <ComingSoon id="cluster" icon={Wifi} />,
|
cluster: ClusterPanel,
|
||||||
backup: () => <ComingSoon id="backup" icon={Database} />,
|
backup: () => <ComingSoon id="backup" icon={Database} />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
@@ -1028,3 +1199,72 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</Dialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
Vendored
+31
-2
@@ -1,10 +1,11 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {qso} from '../models';
|
import {qso} from '../models';
|
||||||
import {profile} from '../models';
|
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {profile} from '../models';
|
||||||
import {adif} from '../models';
|
import {adif} from '../models';
|
||||||
|
import {cat} from '../models';
|
||||||
|
import {cluster} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
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 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 CountQSO():Promise<number>;
|
||||||
|
|
||||||
export function DeleteAllQSO():Promise<number>;
|
export function DeleteAllQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DeleteProfile(arg1:number):Promise<void>;
|
export function DeleteProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DeleteQSO(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 DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetCATSettings():Promise<main.CATSettings>;
|
export function GetCATSettings():Promise<main.CATSettings>;
|
||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
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 GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
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 ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
|
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
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 RotatorStop():Promise<void>;
|
||||||
|
|
||||||
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
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 SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):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 SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):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 SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export function ClearLookupCache() {
|
|||||||
return window['go']['main']['App']['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() {
|
export function CountQSO() {
|
||||||
return window['go']['main']['App']['CountQSO']();
|
return window['go']['main']['App']['CountQSO']();
|
||||||
}
|
}
|
||||||
@@ -22,6 +34,10 @@ export function DeleteAllQSO() {
|
|||||||
return window['go']['main']['App']['DeleteAllQSO']();
|
return window['go']['main']['App']['DeleteAllQSO']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteClusterServer(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteClusterServer'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteProfile(arg1) {
|
export function DeleteProfile(arg1) {
|
||||||
return window['go']['main']['App']['DeleteProfile'](arg1);
|
return window['go']['main']['App']['DeleteProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -30,10 +46,22 @@ export function DeleteQSO(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['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() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -46,6 +74,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['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() {
|
export function GetCtyDatInfo() {
|
||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
@@ -78,6 +114,10 @@ export function ImportADIF(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListClusterServers() {
|
||||||
|
return window['go']['main']['App']['ListClusterServers']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListProfiles() {
|
export function ListProfiles() {
|
||||||
return window['go']['main']['App']['ListProfiles']();
|
return window['go']['main']['App']['ListProfiles']();
|
||||||
}
|
}
|
||||||
@@ -114,10 +154,18 @@ export function RotatorStop() {
|
|||||||
return window['go']['main']['App']['RotatorStop']();
|
return window['go']['main']['App']['RotatorStop']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFFile() {
|
||||||
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveCATSettings(arg1) {
|
export function SaveCATSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveClusterServer(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -138,6 +186,10 @@ export function SaveStationSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendClusterCommand(arg1) {
|
||||||
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCATFrequency(arg1) {
|
export function SetCATFrequency(arg1) {
|
||||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
}
|
}
|
||||||
@@ -146,6 +198,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetClusterAutoConnect(arg1) {
|
||||||
|
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCompactMode(arg1) {
|
export function SetCompactMode(arg1) {
|
||||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
export namespace adif {
|
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 {
|
export class ImportResult {
|
||||||
total: number;
|
total: number;
|
||||||
imported: 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 namespace lookup {
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
@@ -292,6 +390,42 @@ export namespace main {
|
|||||||
this.has_elevation = source["has_elevation"];
|
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 {
|
export class StartupStatus {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportResult summarises an ADIF export for the UI.
|
||||||
|
type ExportResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
SizeKB int64 `json:"size_kb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporter streams every QSO in a repo to an ADIF (.adi) file.
|
||||||
|
type Exporter struct {
|
||||||
|
Repo *qso.Repo
|
||||||
|
|
||||||
|
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||||
|
AppName string
|
||||||
|
AppVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||||
|
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
count, err := e.Export(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
return ExportResult{Path: path, Count: count}, err
|
||||||
|
}
|
||||||
|
info, _ := f.Stat()
|
||||||
|
return ExportResult{
|
||||||
|
Path: path,
|
||||||
|
Count: count,
|
||||||
|
SizeKB: info.Size() / 1024,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export writes a complete ADIF document (header + records + EOF) to w.
|
||||||
|
// Returns the number of QSOs successfully written.
|
||||||
|
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||||
|
bw := bufio.NewWriterSize(w, 64*1024)
|
||||||
|
defer bw.Flush()
|
||||||
|
|
||||||
|
app := strings.TrimSpace(e.AppName)
|
||||||
|
if app == "" {
|
||||||
|
app = "HamLog"
|
||||||
|
}
|
||||||
|
ver := strings.TrimSpace(e.AppVersion)
|
||||||
|
now := time.Now().UTC().Format("20060102 150405")
|
||||||
|
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
||||||
|
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
|
||||||
|
if ver != "" {
|
||||||
|
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||||
|
writeRecord(bw, q)
|
||||||
|
count++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||||
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
|
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||||
|
// --- Core ---
|
||||||
|
writeField(bw, "CALL", q.Callsign)
|
||||||
|
|
||||||
|
if !q.QSODate.IsZero() {
|
||||||
|
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||||
|
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||||
|
}
|
||||||
|
if !q.QSODateOff.IsZero() {
|
||||||
|
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||||
|
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
|
}
|
||||||
|
writeField(bw, "BAND", q.Band)
|
||||||
|
writeField(bw, "BAND_RX", q.BandRX)
|
||||||
|
|
||||||
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
|
writeField(bw, "MODE", mode)
|
||||||
|
writeField(bw, "SUBMODE", submode)
|
||||||
|
|
||||||
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
|
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
|
}
|
||||||
|
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||||
|
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeField(bw, "RST_SENT", q.RSTSent)
|
||||||
|
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
||||||
|
|
||||||
|
// --- Contacted ---
|
||||||
|
writeField(bw, "NAME", q.Name)
|
||||||
|
writeField(bw, "QTH", q.QTH)
|
||||||
|
writeField(bw, "ADDRESS", q.Address)
|
||||||
|
writeField(bw, "EMAIL", q.Email)
|
||||||
|
writeField(bw, "WEB", q.Web)
|
||||||
|
writeField(bw, "GRIDSQUARE", q.Grid)
|
||||||
|
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
||||||
|
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
||||||
|
writeField(bw, "COUNTRY", q.Country)
|
||||||
|
writeField(bw, "STATE", q.State)
|
||||||
|
writeField(bw, "CNTY", q.County)
|
||||||
|
writeIntPtr(bw, "DXCC", q.DXCC)
|
||||||
|
writeField(bw, "CONT", q.Continent)
|
||||||
|
writeIntPtr(bw, "CQZ", q.CQZ)
|
||||||
|
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
||||||
|
writeField(bw, "IOTA", q.IOTA)
|
||||||
|
writeField(bw, "SOTA_REF", q.SOTARef)
|
||||||
|
writeField(bw, "POTA_REF", q.POTARef)
|
||||||
|
writeIntPtr(bw, "AGE", q.Age)
|
||||||
|
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
||||||
|
writeFloatPtr(bw, "LON", q.Lon, 6)
|
||||||
|
writeField(bw, "RIG", q.Rig)
|
||||||
|
writeField(bw, "ANT", q.Ant)
|
||||||
|
|
||||||
|
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||||
|
writeField(bw, "QSL_SENT", q.QSLSent)
|
||||||
|
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
||||||
|
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
||||||
|
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
||||||
|
writeField(bw, "QSL_VIA", q.QSLVia)
|
||||||
|
writeField(bw, "QSLMSG", q.QSLMsg)
|
||||||
|
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||||
|
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
||||||
|
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||||
|
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
||||||
|
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||||
|
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
||||||
|
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||||
|
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
||||||
|
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||||
|
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||||
|
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||||
|
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||||
|
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||||
|
|
||||||
|
// --- Contest ---
|
||||||
|
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||||
|
writeIntPtr(bw, "SRX", q.SRX)
|
||||||
|
writeIntPtr(bw, "STX", q.STX)
|
||||||
|
writeField(bw, "SRX_STRING", q.SRXString)
|
||||||
|
writeField(bw, "STX_STRING", q.STXString)
|
||||||
|
writeField(bw, "CHECK", q.Check)
|
||||||
|
writeField(bw, "PRECEDENCE", q.Precedence)
|
||||||
|
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
||||||
|
|
||||||
|
// --- Satellite / propagation ---
|
||||||
|
writeField(bw, "PROP_MODE", q.PropMode)
|
||||||
|
writeField(bw, "SAT_NAME", q.SatName)
|
||||||
|
writeField(bw, "SAT_MODE", q.SatMode)
|
||||||
|
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
||||||
|
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
||||||
|
writeField(bw, "ANT_PATH", q.AntPath)
|
||||||
|
|
||||||
|
// --- My station / operator ---
|
||||||
|
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
||||||
|
writeField(bw, "OPERATOR", q.Operator)
|
||||||
|
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
||||||
|
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||||
|
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
||||||
|
writeField(bw, "MY_STATE", q.MyState)
|
||||||
|
writeField(bw, "MY_CNTY", q.MyCounty)
|
||||||
|
writeField(bw, "MY_IOTA", q.MyIOTA)
|
||||||
|
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
||||||
|
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
||||||
|
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
||||||
|
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
||||||
|
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
||||||
|
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
||||||
|
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
||||||
|
writeField(bw, "MY_STREET", q.MyStreet)
|
||||||
|
writeField(bw, "MY_CITY", q.MyCity)
|
||||||
|
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
||||||
|
writeField(bw, "MY_RIG", q.MyRig)
|
||||||
|
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
||||||
|
|
||||||
|
// --- Misc ---
|
||||||
|
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
||||||
|
writeField(bw, "COMMENT", q.Comment)
|
||||||
|
writeField(bw, "NOTES", q.Notes)
|
||||||
|
|
||||||
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
|
for k, v := range q.Extras {
|
||||||
|
writeField(bw, strings.ToUpper(k), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
bw.WriteString("<EOR>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeField writes one `<TAG:length>value` pair, no-op when value is empty.
|
||||||
|
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
||||||
|
// already a UTF-8 byte string.
|
||||||
|
func writeField(bw *bufio.Writer, tag, v string) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(*p)
|
||||||
|
writeField(bw, tag, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFloatPtr(bw *bufio.Writer, tag string, p *float64, decimals int) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := strconv.FormatFloat(*p, 'f', decimals, 64)
|
||||||
|
writeField(bw, tag, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentMode maps a "specific" mode (the ones we promote on import) back
|
||||||
|
// to its ADIF parent. Symmetric with promotableSubmodes in import.go.
|
||||||
|
var parentMode = map[string]string{
|
||||||
|
"FT2": "MFSK", "FT4": "MFSK", "JS8": "MFSK", "MSK144": "MFSK",
|
||||||
|
"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
|
||||||
|
"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
|
||||||
|
"OLIVIA": "MFSK",
|
||||||
|
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||||
|
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
|
||||||
|
"FREEDV": "DIGITALVOICE",
|
||||||
|
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||||
|
"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
|
||||||
|
"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
|
||||||
|
"HELL80": "HELL", "FMHELL": "HELL",
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeForExport returns the (MODE, SUBMODE) pair to write. If we promoted
|
||||||
|
// on import (Mode=FT4 Submode=""), we re-derive the parent so the file
|
||||||
|
// is import-compatible with strict ADIF tools.
|
||||||
|
func modeForExport(mode, submode string) (string, string) {
|
||||||
|
if submode != "" {
|
||||||
|
// Already a (parent, child) pair — pass through unchanged.
|
||||||
|
return mode, submode
|
||||||
|
}
|
||||||
|
if parent, ok := parentMode[mode]; ok {
|
||||||
|
return parent, mode
|
||||||
|
}
|
||||||
|
return mode, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// Package cluster provides a multi-server DX cluster client (telnet) —
|
||||||
|
// connects concurrently to several AR-Cluster / CC-Cluster / DXSpider
|
||||||
|
// nodes, logs in with the operator's callsign, optionally sends an init
|
||||||
|
// command list, and streams DX spots back to the UI via a callback.
|
||||||
|
//
|
||||||
|
// Spot parsing is tolerant of the dozens of slight format variations
|
||||||
|
// between cluster flavours (the prompt, the trailing locator, the time
|
||||||
|
// format). Anything that doesn't match the spot regex is treated as
|
||||||
|
// banner/chat noise and ignored, not surfaced as an error.
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerConfig is the persisted shape of one cluster node. Mirrors the
|
||||||
|
// columns of the cluster_servers table; the frontend SettingsPanel
|
||||||
|
// pushes one of these per row.
|
||||||
|
type ServerConfig struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
LoginOverride string `json:"login_override"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
InitCommands string `json:"init_commands"` // newline-separated, sent post-login
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot is a single DX spot as parsed from the cluster stream.
|
||||||
|
type Spot struct {
|
||||||
|
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||||
|
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||||
|
Spotter string `json:"spotter"` // DE field
|
||||||
|
DXCall string `json:"dx_call"` // the DX station heard
|
||||||
|
FreqKHz float64 `json:"freq_khz"`
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||||
|
TimeUTC string `json:"time_utc,omitempty"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State enumerates the per-server lifecycle.
|
||||||
|
type State string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateDisconnected State = "disconnected"
|
||||||
|
StateConnecting State = "connecting"
|
||||||
|
StateConnected State = "connected"
|
||||||
|
StateReconnecting State = "reconnecting"
|
||||||
|
StateError State = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerStatus is one row of the runtime status table — one entry per
|
||||||
|
// active session.
|
||||||
|
type ServerStatus struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
State State `json:"state"`
|
||||||
|
Login string `json:"login,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ConnectedAt time.Time `json:"connected_at,omitempty"`
|
||||||
|
SpotsCount int `json:"spots_count,omitempty"`
|
||||||
|
Retries int `json:"retries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// session is one telnet connection bound to a single server config.
|
||||||
|
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||||
|
// forget: it tells the manager something changed; the frontend fetches
|
||||||
|
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||||
|
type session struct {
|
||||||
|
cfg ServerConfig
|
||||||
|
login string
|
||||||
|
onSpot func(Spot)
|
||||||
|
onStatus func()
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
status ServerStatus
|
||||||
|
conn net.Conn
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
spotsCnt int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager owns N sessions, one per enabled server. Safe for concurrent
|
||||||
|
// use from any goroutine; I/O is on per-session background goroutines.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[int64]*session
|
||||||
|
onSpot func(Spot)
|
||||||
|
onStatus func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager builds an empty manager. emitSpot is called for each parsed
|
||||||
|
// spot (with the source server filled in). emitStatusChanged is called
|
||||||
|
// whenever ANY server's status changes — the frontend then re-fetches
|
||||||
|
// the aggregate Status() via a Wails binding.
|
||||||
|
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
sessions: make(map[int64]*session),
|
||||||
|
onSpot: emitSpot,
|
||||||
|
onStatus: emitStatusChanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns a snapshot of every running session's status.
|
||||||
|
func (m *Manager) Status() []ServerStatus {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]ServerStatus, 0, len(m.sessions))
|
||||||
|
for _, s := range m.sessions {
|
||||||
|
out = append(out, s.snapshot())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServer launches a session for cfg. login is the resolved callsign
|
||||||
|
// to send (empty = anonymous). If a session for the same ID is already
|
||||||
|
// running it is restarted with the new config.
|
||||||
|
func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
||||||
|
m.StopServer(cfg.ID)
|
||||||
|
if !cfg.Enabled || cfg.Host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := &session{
|
||||||
|
cfg: cfg,
|
||||||
|
login: login,
|
||||||
|
onSpot: m.onSpot,
|
||||||
|
onStatus: m.emitStatus,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
status: ServerStatus{
|
||||||
|
ServerID: cfg.ID, Name: cfg.Name, Host: cfg.Host, Port: cfg.Port,
|
||||||
|
Login: login, State: StateConnecting,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[cfg.ID] = s
|
||||||
|
m.mu.Unlock()
|
||||||
|
s.emitStatus()
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopServer terminates the session for the given ID (if any) and waits
|
||||||
|
// for its goroutine to exit.
|
||||||
|
func (m *Manager) StopServer(id int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
s, ok := m.sessions[id]
|
||||||
|
if ok {
|
||||||
|
delete(m.sessions, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stop()
|
||||||
|
m.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll closes every running session.
|
||||||
|
func (m *Manager) StopAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
all := make([]*session, 0, len(m.sessions))
|
||||||
|
for _, s := range m.sessions {
|
||||||
|
all = append(all, s)
|
||||||
|
}
|
||||||
|
m.sessions = make(map[int64]*session)
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, s := range all {
|
||||||
|
s.stop()
|
||||||
|
}
|
||||||
|
m.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCommand writes raw text (a CRLF is appended) to the session for
|
||||||
|
// the given server ID. Used for "show last 30", "set/skimmer", etc.
|
||||||
|
func (m *Manager) SendCommand(serverID int64, cmd string) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
s, ok := m.sessions[serverID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no active session for server %d", serverID)
|
||||||
|
}
|
||||||
|
return s.send(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emitStatus() {
|
||||||
|
if m.onStatus != nil {
|
||||||
|
m.onStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- session ----------
|
||||||
|
|
||||||
|
func (s *session) snapshot() ServerStatus {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) emitStatus() {
|
||||||
|
if s.onStatus != nil {
|
||||||
|
s.onStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) send(cmd string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
conn := s.conn
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("session %q not connected", s.cfg.Name)
|
||||||
|
}
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
stop, done := s.stopCh, s.doneCh
|
||||||
|
conn := s.conn
|
||||||
|
s.stopCh, s.doneCh, s.conn = nil, nil, nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the per-session supervisor: keeps trying to connect until
|
||||||
|
// Stop is called. Backoff caps at 60s, resets after a 30s healthy link.
|
||||||
|
func (s *session) run() {
|
||||||
|
defer close(s.doneCh)
|
||||||
|
backoff := []time.Duration{2, 5, 10, 30, 60}
|
||||||
|
attempt := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
connectedAt, err := s.runOnce()
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if !connectedAt.IsZero() && time.Since(connectedAt) > 30*time.Second {
|
||||||
|
attempt = 0
|
||||||
|
}
|
||||||
|
idx := attempt
|
||||||
|
if idx >= len(backoff) {
|
||||||
|
idx = len(backoff) - 1
|
||||||
|
}
|
||||||
|
delay := backoff[idx] * time.Second
|
||||||
|
attempt++
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.State = StateReconnecting
|
||||||
|
if err != nil {
|
||||||
|
s.status.Error = err.Error()
|
||||||
|
}
|
||||||
|
s.status.Retries = attempt
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.emitStatus()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||||
|
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||||
|
// and the error that ended the session (nil if stopCh).
|
||||||
|
func (s *session) runOnce() (time.Time, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conn = conn
|
||||||
|
s.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.conn == conn {
|
||||||
|
s.conn = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider
|
||||||
|
// nodes accept the callsign without re-prompting.
|
||||||
|
loginSent := false
|
||||||
|
if s.login != "" {
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
if !loginSent {
|
||||||
|
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init commands: fire 1s after login goes through. Each command on
|
||||||
|
// its own line; blank lines and "//" comments are skipped.
|
||||||
|
initFired := false
|
||||||
|
fireInitCommands := func() {
|
||||||
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initFired = true
|
||||||
|
go func() {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_, _ = conn.Write([]byte(line + "\r\n"))
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedAt time.Time
|
||||||
|
rd := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return connectedAt, nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||||
|
line, err := rd.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return connectedAt, fmt.Errorf("read: %w", err)
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login on explicit prompt.
|
||||||
|
if !loginSent && s.login != "" && isLoginPrompt(line) {
|
||||||
|
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||||
|
loginSent = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Password on prompt (rare).
|
||||||
|
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
|
||||||
|
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark connected once we've sent login OR seen a welcome banner.
|
||||||
|
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
|
||||||
|
connectedAt = time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.State = StateConnected
|
||||||
|
s.status.ConnectedAt = connectedAt
|
||||||
|
s.status.Error = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.emitStatus()
|
||||||
|
fireInitCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
if spot, ok := parseSpot(line); ok {
|
||||||
|
spot.SourceID = s.cfg.ID
|
||||||
|
spot.SourceName = s.cfg.Name
|
||||||
|
s.mu.Lock()
|
||||||
|
s.spotsCnt++
|
||||||
|
s.status.SpotsCount = s.spotsCnt
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.onSpot != nil {
|
||||||
|
s.onSpot(spot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- parsing ----------
|
||||||
|
|
||||||
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
|
var spotRE = regexp.MustCompile(
|
||||||
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseSpot(line string) (Spot, bool) {
|
||||||
|
m := spotRE.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
freqKHz, err := strconv.ParseFloat(m[2], 64)
|
||||||
|
if err != nil {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
freqHz := int64(freqKHz*1000 + 0.5)
|
||||||
|
return Spot{
|
||||||
|
Spotter: strings.ToUpper(m[1]),
|
||||||
|
FreqKHz: freqKHz,
|
||||||
|
FreqHz: freqHz,
|
||||||
|
Band: bandFromHz(freqHz),
|
||||||
|
DXCall: strings.ToUpper(m[3]),
|
||||||
|
Comment: strings.TrimSpace(m[4]),
|
||||||
|
TimeUTC: m[5],
|
||||||
|
Locator: strings.ToUpper(m[6]),
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
Raw: line,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoginPrompt(s string) bool {
|
||||||
|
low := strings.ToLower(s)
|
||||||
|
return strings.Contains(low, "login:") ||
|
||||||
|
strings.Contains(low, "please enter your call") ||
|
||||||
|
strings.Contains(low, "your call:") ||
|
||||||
|
strings.HasSuffix(strings.TrimSpace(low), "callsign:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPasswordPrompt(s string) bool {
|
||||||
|
low := strings.ToLower(s)
|
||||||
|
return strings.Contains(low, "password:") || strings.Contains(low, "pwd:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWelcome(s string) bool {
|
||||||
|
low := strings.ToLower(s)
|
||||||
|
return strings.Contains(low, "welcome") ||
|
||||||
|
strings.Contains(low, "logged in") ||
|
||||||
|
strings.Contains(low, "started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bandFromHz(hz int64) string {
|
||||||
|
mhz := float64(hz) / 1_000_000
|
||||||
|
switch {
|
||||||
|
case mhz >= 1.8 && mhz <= 2.0:
|
||||||
|
return "160m"
|
||||||
|
case mhz >= 3.5 && mhz <= 4.0:
|
||||||
|
return "80m"
|
||||||
|
case mhz >= 5.3 && mhz <= 5.5:
|
||||||
|
return "60m"
|
||||||
|
case mhz >= 7.0 && mhz <= 7.3:
|
||||||
|
return "40m"
|
||||||
|
case mhz >= 10.1 && mhz <= 10.15:
|
||||||
|
return "30m"
|
||||||
|
case mhz >= 14.0 && mhz <= 14.35:
|
||||||
|
return "20m"
|
||||||
|
case mhz >= 18.068 && mhz <= 18.168:
|
||||||
|
return "17m"
|
||||||
|
case mhz >= 21.0 && mhz <= 21.45:
|
||||||
|
return "15m"
|
||||||
|
case mhz >= 24.89 && mhz <= 24.99:
|
||||||
|
return "12m"
|
||||||
|
case mhz >= 28.0 && mhz <= 29.7:
|
||||||
|
return "10m"
|
||||||
|
case mhz >= 50.0 && mhz <= 54.0:
|
||||||
|
return "6m"
|
||||||
|
case mhz >= 70.0 && mhz <= 70.5:
|
||||||
|
return "4m"
|
||||||
|
case mhz >= 144.0 && mhz <= 148.0:
|
||||||
|
return "2m"
|
||||||
|
case mhz >= 222.0 && mhz <= 225.0:
|
||||||
|
return "1.25m"
|
||||||
|
case mhz >= 420.0 && mhz <= 450.0:
|
||||||
|
return "70cm"
|
||||||
|
case mhz >= 902.0 && mhz <= 928.0:
|
||||||
|
return "33cm"
|
||||||
|
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||||
|
return "23cm"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseSpot(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line string
|
||||||
|
wantCall string
|
||||||
|
wantKHz float64
|
||||||
|
wantBand string
|
||||||
|
wantLoc string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||||
|
"W1AW", 14195.5, "20m", "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||||
|
"3DA0RU", 7074.0, "40m", "JN18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`DX de N1MM: 14010.0 K1JT 599 NJ 1234Z FN20`,
|
||||||
|
"K1JT", 14010.0, "20m", "FN20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`DX de YO3JW 3573.0 EA1ABC CQ 2010Z IN73`,
|
||||||
|
"EA1ABC", 3573.0, "80m", "IN73",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s, ok := parseSpot(c.line)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%q: parse failed", c.line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.DXCall != c.wantCall {
|
||||||
|
t.Errorf("%q: call=%q want %q", c.line, s.DXCall, c.wantCall)
|
||||||
|
}
|
||||||
|
if s.FreqKHz != c.wantKHz {
|
||||||
|
t.Errorf("%q: kHz=%v want %v", c.line, s.FreqKHz, c.wantKHz)
|
||||||
|
}
|
||||||
|
if s.Band != c.wantBand {
|
||||||
|
t.Errorf("%q: band=%q want %q", c.line, s.Band, c.wantBand)
|
||||||
|
}
|
||||||
|
if s.Locator != c.wantLoc {
|
||||||
|
t.Errorf("%q: loc=%q want %q", c.line, s.Locator, c.wantLoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSpotRejectsNoise(t *testing.T) {
|
||||||
|
noise := []string{
|
||||||
|
"Welcome to DXCluster",
|
||||||
|
"login:",
|
||||||
|
"WX bulletin from G4ABC: heavy rain",
|
||||||
|
"To ALL de F1XYZ: anyone using XYZ contest log?",
|
||||||
|
"",
|
||||||
|
"sh/dx 10",
|
||||||
|
}
|
||||||
|
for _, line := range noise {
|
||||||
|
if _, ok := parseSpot(line); ok {
|
||||||
|
t.Errorf("noise line parsed as spot: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Multi-cluster support: the user can keep several DX cluster nodes saved
|
||||||
|
-- and connect to them concurrently. The first enabled row (by sort_order)
|
||||||
|
-- is the "master" — commands typed in the UI are sent through it.
|
||||||
|
CREATE TABLE cluster_servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL, -- "VE7CC", "F4BPO home", …
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 7300,
|
||||||
|
login_override TEXT NOT NULL DEFAULT '', -- empty = use active profile callsign
|
||||||
|
password TEXT NOT NULL DEFAULT '', -- some nodes require one
|
||||||
|
init_commands TEXT NOT NULL DEFAULT '', -- newline-separated, sent after login
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cluster_servers_enabled ON cluster_servers(enabled, sort_order);
|
||||||
|
|
||||||
|
-- UI options for the Cluster tab. Stored in settings (key/value) as
|
||||||
|
-- usual, no migration needed.
|
||||||
+21
-24
@@ -140,8 +140,11 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
return Result{}, lastErr
|
return Result{}, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// fillFromDXCC fills in country/continent/zones/lat/lon from the cty.dat
|
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
|
||||||
// resolver when the provider returned them empty. Provider data wins.
|
// the cty.dat resolver. Default behaviour is "fill empty fields only";
|
||||||
|
// for slashed callsigns (IT9/DK6XZ, DL/F4NIE…) we OVERRIDE because the
|
||||||
|
// provider returned the home-call's entity, which is wrong for portable
|
||||||
|
// operations. The provider keeps Name/QTH/Address (still useful for QSL).
|
||||||
// Returns true if any field was filled.
|
// Returns true if any field was filled.
|
||||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||||
if dxcc == nil {
|
if dxcc == nil {
|
||||||
@@ -151,29 +154,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
slashed := strings.ContainsRune(r.Callsign, '/')
|
||||||
|
shouldStr := func(existing string) bool { return existing == "" || slashed }
|
||||||
|
shouldInt := func(existing int) bool { return existing == 0 || slashed }
|
||||||
|
shouldF := func(existing float64) bool { return existing == 0 || slashed }
|
||||||
filled := false
|
filled := false
|
||||||
if r.Country == "" && country != "" {
|
if country != "" && shouldStr(r.Country) { r.Country = country; filled = true }
|
||||||
r.Country = country
|
if cont != "" && shouldStr(r.Continent) { r.Continent = cont; filled = true }
|
||||||
filled = true
|
if cqz != 0 && shouldInt(r.CQZ) { r.CQZ = cqz; filled = true }
|
||||||
}
|
if ituz != 0 && shouldInt(r.ITUZ) { r.ITUZ = ituz; filled = true }
|
||||||
if r.Continent == "" && cont != "" {
|
if lat != 0 && shouldF(r.Lat) { r.Lat = lat; filled = true }
|
||||||
r.Continent = cont
|
if lon != 0 && shouldF(r.Lon) { r.Lon = lon; filled = true }
|
||||||
filled = true
|
// QRZ's DXCC number is the home call's — wrong for portable ops.
|
||||||
}
|
// cty.dat has no DXCC# (only Clublog does), so clear it: the UI
|
||||||
if r.CQZ == 0 && cqz != 0 {
|
// will fall back to callsign-level worked-before until we ship a
|
||||||
r.CQZ = cqz
|
// proper entity-name → DXCC# mapping.
|
||||||
filled = true
|
if slashed && r.DXCC != 0 {
|
||||||
}
|
r.DXCC = 0
|
||||||
if r.ITUZ == 0 && ituz != 0 {
|
|
||||||
r.ITUZ = ituz
|
|
||||||
filled = true
|
|
||||||
}
|
|
||||||
if r.Lat == 0 && lat != 0 {
|
|
||||||
r.Lat = lat
|
|
||||||
filled = true
|
|
||||||
}
|
|
||||||
if r.Lon == 0 && lon != 0 {
|
|
||||||
r.Lon = lon
|
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
return filled
|
return filled
|
||||||
|
|||||||
@@ -800,6 +800,78 @@ func sortStrings(s []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IterateAll streams every QSO in the database through fn, ordered by
|
||||||
|
// qso_date ascending so an ADIF export is chronological. Constant memory
|
||||||
|
// regardless of table size — the alternative (loading all 25k+ rows into
|
||||||
|
// a slice) wastes ~20MB for no good reason.
|
||||||
|
func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT `+selectCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query qso: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
q, err := scanQSO(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(q); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntitySlot bundles every (band, mode) tuple ever worked for a given
|
||||||
|
// DXCC entity name. Used by the cluster spot colouring code to decide
|
||||||
|
// NEW / NEW SLOT / WORKED in constant time after one batched query.
|
||||||
|
type EntitySlot struct {
|
||||||
|
Country string
|
||||||
|
Bands map[string]struct{} // bands worked, any mode
|
||||||
|
Slots map[string]map[string]struct{} // band → modes worked
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntitySlotMap returns slot data for every QSO grouped by lowercase
|
||||||
|
// country name (cty.dat-style key). Cheap on a 25k-row table: one
|
||||||
|
// scan, no joins. Callers can compare a spot's entity to this map to
|
||||||
|
// decide if it's NEW / NEW SLOT / WORKED.
|
||||||
|
func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT lower(country), lower(band), upper(mode) FROM qso
|
||||||
|
WHERE country IS NOT NULL AND country != ''
|
||||||
|
AND band IS NOT NULL AND band != ''
|
||||||
|
AND mode IS NOT NULL AND mode != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]*EntitySlot, 256)
|
||||||
|
for rows.Next() {
|
||||||
|
var country, band, mode string
|
||||||
|
if err := rows.Scan(&country, &band, &mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e, ok := out[country]
|
||||||
|
if !ok {
|
||||||
|
e = &EntitySlot{
|
||||||
|
Country: country,
|
||||||
|
Bands: make(map[string]struct{}),
|
||||||
|
Slots: make(map[string]map[string]struct{}),
|
||||||
|
}
|
||||||
|
out[country] = e
|
||||||
|
}
|
||||||
|
e.Bands[band] = struct{}{}
|
||||||
|
bandSlots, ok := e.Slots[band]
|
||||||
|
if !ok {
|
||||||
|
bandSlots = make(map[string]struct{})
|
||||||
|
e.Slots[band] = bandSlots
|
||||||
|
}
|
||||||
|
bandSlots[mode] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Count returns the total number of QSOs in the database.
|
// Count returns the total number of QSOs in the database.
|
||||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
|
|||||||
Reference in New Issue
Block a user