update
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/lookup"
|
||||
@@ -60,6 +61,8 @@ const (
|
||||
keyRotatorHost = "rotator.host"
|
||||
keyRotatorPort = "rotator.port"
|
||||
keyRotatorHasElevation = "rotator.has_elevation"
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
)
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -141,6 +144,7 @@ type App struct {
|
||||
cache *lookup.Cache
|
||||
cat *cat.Manager
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
}
|
||||
@@ -223,6 +227,26 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
// DX Cluster (multi-server): spot callback pushes individual spots,
|
||||
// status callback signals "something changed" so the frontend can
|
||||
// fetch the aggregate via GetClusterStatus.
|
||||
a.cluster = cluster.NewManager(
|
||||
func(s cluster.Spot) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
||||
}
|
||||
},
|
||||
func() {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
|
||||
}
|
||||
},
|
||||
)
|
||||
if cs, _ := a.clusterAutoConnect(); cs {
|
||||
a.startAllEnabledClusters()
|
||||
}
|
||||
|
||||
fmt.Println("HamLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -492,6 +516,33 @@ func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, e
|
||||
return im.ImportFile(a.ctx, path)
|
||||
}
|
||||
|
||||
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
||||
// HamLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
|
||||
func (a *App) SaveADIFFile() (string, error) {
|
||||
suggested := "HamLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
|
||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "Export ADIF",
|
||||
DefaultFilename: suggested,
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||||
// Streams from DB so memory stays flat even with 100k+ records.
|
||||
func (a *App) ExportADIF(path string) (adif.ExportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if path == "" {
|
||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||
}
|
||||
ex := &adif.Exporter{Repo: a.qso, AppName: "HamLog", AppVersion: "0.1"}
|
||||
return ex.ExportFile(a.ctx, path)
|
||||
}
|
||||
|
||||
// --- Lookup bindings ---
|
||||
|
||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||
@@ -1090,3 +1141,307 @@ func boolStr(b bool) string {
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// --- DX Cluster bindings (multi-server) ---
|
||||
|
||||
// resolveClusterLogin returns the login callsign for a server: explicit
|
||||
// override on the row, else the active profile's callsign.
|
||||
func (a *App) resolveClusterLogin(override string) string {
|
||||
if override != "" {
|
||||
return strings.ToUpper(strings.TrimSpace(override))
|
||||
}
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
return strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clusterAutoConnect reads the global "auto-connect on startup" toggle.
|
||||
// Stored in settings (key/value) since it's a single bool, not per-row.
|
||||
func (a *App) clusterAutoConnect() (bool, error) {
|
||||
if a.settings == nil {
|
||||
return false, fmt.Errorf("db not initialized")
|
||||
}
|
||||
v, err := a.settings.Get(a.ctx, keyClusterAutoConnect)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v == "1", nil
|
||||
}
|
||||
|
||||
// startAllEnabledClusters opens a session for every enabled server.
|
||||
func (a *App) startAllEnabledClusters() {
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
fmt.Println("HamLog: list cluster servers:", err)
|
||||
return
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.Enabled {
|
||||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listClusterServers reads the cluster_servers table ordered for display
|
||||
// (sort_order asc, id asc). The first row with Enabled=true is the master.
|
||||
func (a *App) listClusterServers() ([]cluster.ServerConfig, error) {
|
||||
if a.db == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
rows, err := a.db.QueryContext(a.ctx, `
|
||||
SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order
|
||||
FROM cluster_servers
|
||||
ORDER BY sort_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []cluster.ServerConfig
|
||||
for rows.Next() {
|
||||
var s cluster.ServerConfig
|
||||
var enabled int
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride,
|
||||
&s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Enabled = enabled == 1
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListClusterServers returns all saved cluster nodes.
|
||||
func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) {
|
||||
return a.listClusterServers()
|
||||
}
|
||||
|
||||
// SaveClusterServer upserts one row. id=0 inserts a new server. Restarts
|
||||
// the session if the row was already running (so config edits take effect
|
||||
// immediately).
|
||||
func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) {
|
||||
if a.db == nil {
|
||||
return cluster.ServerConfig{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return cluster.ServerConfig{}, fmt.Errorf("server name required")
|
||||
}
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 7300
|
||||
}
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
enabled := 0
|
||||
if s.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
if s.ID == 0 {
|
||||
res, err := a.db.ExecContext(a.ctx, `
|
||||
INSERT INTO cluster_servers
|
||||
(name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now)
|
||||
if err != nil {
|
||||
return cluster.ServerConfig{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
s.ID = id
|
||||
} else {
|
||||
_, err := a.db.ExecContext(a.ctx, `
|
||||
UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?,
|
||||
init_commands=?, enabled=?, sort_order=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID)
|
||||
if err != nil {
|
||||
return cluster.ServerConfig{}, err
|
||||
}
|
||||
}
|
||||
// Apply runtime change: stop and restart if enabled, else just stop.
|
||||
a.cluster.StopServer(s.ID)
|
||||
if s.Enabled {
|
||||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// DeleteClusterServer drops a row and closes its session.
|
||||
func (a *App) DeleteClusterServer(id int64) error {
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.cluster.StopServer(id)
|
||||
_, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetClusterAutoConnect persists the global auto-connect toggle.
|
||||
func (a *App) SetClusterAutoConnect(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on))
|
||||
}
|
||||
|
||||
// GetClusterAutoConnect reads the persisted toggle.
|
||||
func (a *App) GetClusterAutoConnect() (bool, error) {
|
||||
return a.clusterAutoConnect()
|
||||
}
|
||||
|
||||
// ConnectClusterServer opens a session for one specific saved server.
|
||||
func (a *App) ConnectClusterServer(id int64) error {
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.ID == id {
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("server %q is disabled — enable it first", s.Name)
|
||||
}
|
||||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no saved server with id %d", id)
|
||||
}
|
||||
|
||||
// DisconnectClusterServer closes the session for one server.
|
||||
func (a *App) DisconnectClusterServer(id int64) error {
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
a.cluster.StopServer(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConnectAllClusters opens sessions for every enabled server.
|
||||
func (a *App) ConnectAllClusters() error {
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
a.startAllEnabledClusters()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisconnectAllClusters closes every running session.
|
||||
func (a *App) DisconnectAllClusters() error {
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
a.cluster.StopAll()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendClusterCommand writes `cmd` to the **master** cluster — the first
|
||||
// enabled server by sort_order. Returns an error if the master is not
|
||||
// currently connected (the UI should grey the input out in that case).
|
||||
func (a *App) SendClusterCommand(cmd string) error {
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("empty command")
|
||||
}
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.Enabled {
|
||||
return a.cluster.SendCommand(s.ID, cmd)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||
// the UI on mount and to hydrate after a `cluster:state` event.
|
||||
func (a *App) GetClusterStatus() []cluster.ServerStatus {
|
||||
if a.cluster == nil {
|
||||
return nil
|
||||
}
|
||||
return a.cluster.Status()
|
||||
}
|
||||
|
||||
// SpotQuery is one (call, band, mode) tuple sent for status colouring.
|
||||
type SpotQuery struct {
|
||||
Call string `json:"call"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// SpotStatus is the per-tuple result. Status is one of:
|
||||
//
|
||||
// "new" — entity never worked
|
||||
// "new-band" — entity worked but never on this band
|
||||
// "new-slot" — entity worked on this band but not in this mode
|
||||
// "worked" — exact band+mode already in the log
|
||||
// "" — couldn't resolve the entity (no cty.dat match)
|
||||
type SpotStatus struct {
|
||||
Call string `json:"call"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
|
||||
// / WORKED). One cty.dat lookup + one DB scan, regardless of batch size.
|
||||
//
|
||||
// Mode handling: when the caller passes an empty Mode (cluster comment
|
||||
// was ambiguous and the frontend couldn't infer) we degrade gracefully
|
||||
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
|
||||
// just because we don't know the mode.
|
||||
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
out := make([]SpotStatus, len(spots))
|
||||
if a.qso == nil {
|
||||
return out
|
||||
}
|
||||
entities, err := a.qso.EntitySlotMap(a.ctx)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for i, q := range spots {
|
||||
out[i] = SpotStatus{
|
||||
Call: q.Call,
|
||||
Band: strings.ToLower(q.Band),
|
||||
Mode: strings.ToUpper(q.Mode),
|
||||
}
|
||||
if a.dxcc == nil {
|
||||
continue
|
||||
}
|
||||
m, ok := a.dxcc.Lookup(q.Call)
|
||||
if !ok || m.Entity == nil {
|
||||
continue
|
||||
}
|
||||
country := strings.ToLower(m.Entity.Name)
|
||||
out[i].Country = m.Entity.Name
|
||||
e, worked := entities[country]
|
||||
if !worked {
|
||||
out[i].Status = "new"
|
||||
continue
|
||||
}
|
||||
if _, b := e.Bands[out[i].Band]; !b {
|
||||
out[i].Status = "new-band"
|
||||
continue
|
||||
}
|
||||
// Without a mode we can't distinguish "new slot" from "worked";
|
||||
// the safer default is "worked" so we never falsely claim "new".
|
||||
if out[i].Mode == "" {
|
||||
out[i].Status = "worked"
|
||||
continue
|
||||
}
|
||||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
||||
out[i].Status = "new-slot"
|
||||
continue
|
||||
}
|
||||
out[i].Status = "worked"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user