Compare commits

...
5 Commits
8 changed files with 123 additions and 22 deletions
+32 -6
View File
@@ -9455,6 +9455,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
} }
// Serialise the WHOLE operation — parse, callsign LOOKUP, dedup and insert —
// so simultaneous UDP QSOs are processed one at a time (a queue). A multi-stream
// MSHV finishing 4 QSOs at once used to fire 4 concurrent callsign lookups and DB
// writes, which the QRZ/HamQTH service (and a remote DB) rejected with "too many
// requests". Holding the lock across the lookup spaces them out; the UDP reader
// runs on its own goroutine, so this never blocks packet reception — callers just
// queue behind each other. (adifwatch shares this lock for the same reason.)
a.udpLogMu.Lock()
defer a.udpLogMu.Unlock()
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV // Pull the first record out of the payload. WSJT-X / JTDX / MSHV
// always send a single QSO per UDP packet (no header) but we tolerate // always send a single QSO per UDP packet (no header) but we tolerate
// either form via adif.Parse. // either form via adif.Parse.
@@ -9597,12 +9606,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// a minute (the two apps stamp their own time), so a minute-exact key // a minute (the two apps stamp their own time), so a minute-exact key
// missed it and the contact got duplicated. // missed it and the contact got duplicated.
// //
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same // The check + insert is covered by udpLogMu (taken at the top of this function):
// logged-QSO packet twice in quick succession (re-broadcast, or two // MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
// listeners), and without serialisation both goroutines read the dedup set // (re-broadcast, or two listeners), and without serialisation both goroutines
// BEFORE either inserts, both pass, and the QSO lands twice. // read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
a.udpLogMu.Lock()
defer a.udpLogMu.Unlock()
seen, err := a.qso.ExistingDedupeKeys(a.ctx) seen, err := a.qso.ExistingDedupeKeys(a.ctx)
if err == nil { if err == nil {
base := q.QSODate.UTC() base := q.QSODate.UTC()
@@ -9616,16 +9623,35 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
id, err := a.qso.Add(a.ctx, q) id, err := a.qso.Add(a.ctx, q)
if err != nil { if err != nil {
// DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO
// in the offline outbox rather than lose it, exactly like the manual log
// path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs
// from a multi-stream MSHV. Returns -1 so the caller treats it as "saved,
// waiting to sync", not a failure.
if db.IsConnLost(err) && a.queueOffline(q, err) {
return -1, nil
}
return 0, fmt.Errorf("insert qso: %w", err) return 0, fmt.Errorf("insert qso: %w", err)
} }
q.ID = id q.ID = id
a.noteLiveQSO() // multi-op: flip this operator back "online" a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q) a.materializeAwardRefs(q)
// Announce the log so UI widgets refresh AT ONCE (the Recent-QSOs grid, the
// ON-AIR badge and the "stations on air" widget). The manual log path always
// did this; the UDP/FT8 path did not, so a station running MSHV saw the top
// "on air" list lag ~15-45s behind the instant bottom badge.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
a.saveQSORecording(&q) a.saveQSORecording(&q)
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
} }
a.maybeAutoSendEQSL(q) a.maybeAutoSendEQSL(q)
// A successful write means the link is healthy again — flush anything parked.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
}
return id, nil return id, nil
} }
+30
View File
@@ -1,4 +1,34 @@
[ [
{
"version": "0.20.6",
"date": "2026-07-21",
"en": [
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
"Cluster self-spot pop-ups now show the band.",
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
"Update check now also runs when you open Help - About, and every 5 minutes."
],
"fr": [
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
]
},
{ {
"version": "0.20.5", "version": "0.20.5",
"date": "2026-07-20", "date": "2026-07-20",
+6 -2
View File
@@ -452,9 +452,13 @@ export default function App() {
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {}); const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
load(); load();
const id = window.setInterval(load, 5 * 1000); const id = window.setInterval(load, 5 * 1000);
// Small delay lets the async publishLiveStatus MySQL write land before we read. // Refresh the instant the backend actually publishes a row change (a QSO put
// someone on air, or a 5-min silence took them off) — so this widget tracks the
// bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
// fallback too (a small delay lets the async publish land) for older paths.
const offStatus = EventsOn('livestatus:updated', load);
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); }); const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
return () => { window.clearInterval(id); offLogged?.(); }; return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
}, [liveStationsOn]); }, [liveStationsOn]);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.5'; export const APP_VERSION = '0.20.6';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+19 -7
View File
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
} }
sort.Strings(names) sort.Strings(names)
for _, name := range names { // Fetch every applied migration name in ONE query rather than one round-trip
var dummy string // per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy) // connect time (each RTT × migration count) — the whole logbook connect could
if err == nil { // take tens of seconds. One SELECT + an in-memory set is effectively instant.
continue // already applied applied := map[string]bool{}
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
applied[n] = true
}
} }
if err != sql.ErrNoRows { rows.Close()
return fmt.Errorf("check migration %s: %w", name, err) } else {
return fmt.Errorf("read applied migrations: %w", err)
}
for _, name := range names {
if applied[name] {
continue // already applied
} }
content, err := migrationsFS.ReadFile("migrations/" + name) content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil { if err != nil {
+20 -5
View File
@@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if !validDBIdent(name) { if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
} }
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn()) conn, err := sql.Open("mysql", c.dsn())
if err != nil { if err != nil {
return nil, fmt.Errorf("open mysql: %w", err) return nil, fmt.Errorf("open mysql: %w", err)
@@ -157,9 +153,28 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// (surfacing as "Unknown database"). Idle connections are still recycled // (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections. // after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0) conn.SetConnMaxLifetime(0)
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
// exist yet (first-time setup) or the server is unreachable — do we pay for the
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
// startup against an existing DB this halves the connection round-trips, which
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
if err := conn.Ping(); err != nil { if err := conn.Ping(); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err) if perr := PingMySQL(c); perr != nil {
return nil, perr // creates the DB (or returns a clear "cannot create" error)
}
conn, err = sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
} }
// Set the dialect before migrating so the runner takes the MySQL path // Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path. // (per-statement, idempotent) rather than the SQLite transaction path.
+14
View File
@@ -6,9 +6,21 @@ import (
"strings" "strings"
"time" "time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog" "hamlog/internal/applog"
) )
// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
// the shared table actually changes (a row appeared or was removed) — so the
// "stations on air" widget updates the instant a QSO is published, matching the
// bottom ON-AIR badge instead of waiting for its poll.
func (a *App) emitLiveStatusChanged() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
}
}
// Live operator status — for multi-operator events on a SHARED MySQL logbook // Live operator status — for multi-operator events on a SHARED MySQL logbook
// (e.g. a special-event call like TM74FR with several ops on different bands). // (e.g. a special-event call like TM74FR with several ops on different bands).
// Each OpsLog instance heartbeats its current activity (operator call + station // Each OpsLog instance heartbeats its current activity (operator call + station
@@ -231,6 +243,7 @@ func (a *App) publishLiveStatus() {
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil { if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
applog.Printf("livestatus: offline DELETE failed: %v", err) applog.Printf("livestatus: offline DELETE failed: %v", err)
} }
a.emitLiveStatusChanged()
return return
} }
lastQSOArg := lastQSO.UTC() lastQSOArg := lastQSO.UTC()
@@ -246,6 +259,7 @@ func (a *App) publishLiveStatus() {
return return
} }
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode) applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
a.emitLiveStatusChanged()
} }
// LiveStation is one operator's live status for the multi-op "who's on air" widget. // LiveStation is one operator's live status for the multi-op "who's on air" widget.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.5" appVersion = "0.20.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.