Compare commits

...
9 Commits
Author SHA1 Message Date
rouggy 828f99b8ac docs: add Discord server badge to the top of the README (EN/FR) 2026-07-21 17:15:18 +02:00
rouggy 9e5868b839 fix: raise MySQL pool 8->16 — 8 starved the instance's own live-sync polling (revision + grid refresh + live_status + chat), so the grid stopped showing other operators' QSOs; 16 avoids the stall and still fits ~15 ops under max_connections=300 2026-07-21 16:50:48 +02:00
rouggy 40d0ca57c3 diag: log slow QSO inserts (>2s) with call/band/mode; rotate the app log to .1 at 10MB instead of deleting it, so the previous session's diagnostics survive 2026-07-21 16:24:50 +02:00
rouggy aa537f9eea fix: log returns as soon as the QSO row is inserted; award_refs/recording/uploads/eQSL now run off the critical path (both manual and UDP) — a busy multi-op MySQL no longer freezes the entry form ~40s waiting on the award_refs UPDATE 2026-07-21 16:20:49 +02:00
rouggy ea1bd2a66d feat: sync the host CW keyer speed (wkWpm) to the FlexRadio's actual CW speed — including changes made on the radio/SmartSDR, not just the panel slider — so CW-length estimates (auto-call gap) match reality when using Flex CWX 2026-07-21 16:10:15 +02:00
rouggy 1f54656256 fix: <LOGQSO> logs immediately at its position instead of waiting the full CW-duration estimate first (buffered keyers keep sending; logging never interrupts them) — was delaying the log up to ~10s; only the final segment still waits, for auto-call timing 2026-07-21 16:01:15 +02:00
rouggy 54dd109288 docs: changelog entry for 0.20.7 (multi-op MySQL connection fix, By-Continent stats total) 2026-07-21 15:12:44 +02:00
rouggy 2c1d7235f0 fix: shrink MySQL connection pool (50->8) so a multi-op event doesn't exhaust the shared server's max_connections (Error 1040: Too many connections), which was silently failing the on-air widget/award_refs/grid reads and making the UI look frozen 2026-07-21 12:23:38 +02:00
rouggy adf9844d1b fix: stats By Continent donut totals now match the QSO count (bucket blank/unknown continents as 'Unknown' instead of dropping them; keep the Continents metric counting real continents only) 2026-07-21 12:10:00 +02:00
9 changed files with 152 additions and 76 deletions
+4
View File
@@ -1,5 +1,9 @@
# OpsLog
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
</a>
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
+4
View File
@@ -1,5 +1,9 @@
# OpsLog
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a>
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
+54 -33
View File
@@ -1934,7 +1934,13 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
// was redundant with the entry lookup and only slowed logging (a call not yet in
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
insT0 := time.Now()
id, err = a.qso.Add(a.ctx, q)
if d := time.Since(insT0); d > 2*time.Second {
// Surface a genuinely slow INSERT (DB lock / overloaded server) — this is the
// value we need when someone reports "logging took N seconds".
applog.Printf("log: SLOW qso insert took %s (call=%s band=%s mode=%s)", d.Round(time.Millisecond), q.Callsign, q.Band, q.Mode)
}
if err != nil && db.IsConnLost(err) {
// The database is UNREACHABLE (not a data error) — park the QSO in the
// offline outbox rather than lose it. Returns id = -1 so the UI can say
@@ -1948,25 +1954,36 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
if a.udp != nil {
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
}
// A successful write means the link is healthy again — if QSOs are still
// parked, get them in now rather than waiting for the next tick.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
}
// Everything below writes to (or reads from) the shared DB. Run it OFF the
// critical path: on a busy multi-op MySQL the award_refs UPDATE alone could
// block ~40s (and hit "Too many connections"), which used to freeze the log
// (form stuck, "…" on the button) until it finished. The QSO row is already
// safely inserted; these just enrich it, so they can lag a beat.
qc := q
go func() {
a.materializeAwardRefs(qc) // fills the award_refs column
a.saveQSORecording(&qc)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(qc)
if a.udp != nil {
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
}
// Nudge the grid again so the award_refs columns appear once materialised.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
// A successful write means the link is healthy — flush any parked QSOs.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
_, _ = a.replayOfflineQueue()
}
}()
}
return id, err
}
@@ -9634,24 +9651,28 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
return 0, fmt.Errorf("insert qso: %w", err)
}
q.ID = id
a.noteLiveQSO() // multi-op: flip this operator back "online"
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.
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
// refresh immediately, then run the DB-heavy enrichment off the critical path
// (same reasoning as the manual path award_refs on a busy shared MySQL blocked).
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
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() }()
}
qc := q
go func() {
a.materializeAwardRefs(qc)
a.saveQSORecording(&qc)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(qc)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
}
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
_, _ = a.replayOfflineQueue()
}
}()
return id, nil
}
+12
View File
@@ -1,4 +1,16 @@
[
{
"version": "0.20.7",
"date": "2026-07-21",
"en": [
"Multi-operator events no longer exhaust the shared MySQL server's connections: each instance now uses a small connection pool, so many operators can run against one database at once. Previously the server hit its connection limit and the 'stations on air' list, award columns and grid would silently stop updating. (All operators should update, and you may want to raise max_connections on the server.)",
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
],
"fr": [
"Les événements multi-opérateurs ne saturent plus les connexions du serveur MySQL partagé : chaque instance utilise maintenant un petit pool de connexions, donc de nombreux opérateurs peuvent travailler sur une même base en même temps. Avant, le serveur atteignait sa limite de connexions et la liste « stations on air », les colonnes d'award et la grille cessaient silencieusement de se mettre à jour. (Tous les opérateurs doivent mettre à jour, et il peut être utile d'augmenter max_connections sur le serveur.)",
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
]
},
{
"version": "0.20.6",
"date": "2026-07-21",
+29 -23
View File
@@ -2147,39 +2147,45 @@ export default function App() {
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
// Each split boundary (every part except the last) is one <LOGQSO>.
const parts = rawText.split(/<LOGQSO>/i);
// RESOLVE every segment up front — while the form still holds the callsign — so a
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
// variables correctly.
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
for (let p = 0; p < parts.length; p++) {
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
const resolved = resolveCW(parts[p]);
const resolved = parts[p];
const isLast = p === parts.length - 1;
const logAfter = !isLast; // a <LOGQSO> follows this segment
if (resolved) {
// Trailing word space so back-to-back sends don't run together in the keyer
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
// current WPM, so it scales automatically.
const keyed = resolved + ' ';
setWkSent(resolved);
if (isRig) {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so
// wait the ESTIMATED send duration before moving on / logging.
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600));
} else {
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Wait for the keyer to actually finish this segment before we log /
// continue. We'd like to watch busy rise then fall, but over a
// remote/serial-over-IP link that echo can lag tens of seconds, so cap
// the wait at the ESTIMATED send duration + slack: proceed when busy
// clears OR the estimate elapses, whichever comes first.
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
if (isLast) {
if (isRig) {
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
} else {
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
// over a remote/serial-over-IP link that echo can lag tens of seconds.
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
}
}
}
if (aborted()) return; // aborted while this segment was sending → don't log
// A <LOGQSO> token follows every part except the last → log the contact here.
if (p < parts.length - 1) void save();
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
}
}
// stopAutoCall cancels any running auto-call loop.
@@ -3669,7 +3675,7 @@ export default function App() {
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
@@ -4987,7 +4993,7 @@ export default function App() {
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
+12
View File
@@ -288,6 +288,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
// in step with the radio's ACTUAL CW speed — including when it's changed on the
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
const lastCwSpeed = useRef<number | null>(null);
useEffect(() => {
const w = st.cw_speed;
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
lastCwSpeed.current = w;
onCWSpeed?.(w);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]);
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
const peak = useRef<Record<string, { v: number; t: number }>>({});
+9 -4
View File
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
_ = os.Rename(oldLog, logPath)
}
}
// Truncate if the file grew past ~5MB so we don't accumulate logs
// forever. We keep one file — simple and adequate for diagnostics.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
_ = os.Remove(logPath)
// Rotate (don't delete) once the file grows past ~10MB: rename it to
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
// to erase exactly the diagnostics we needed when a user reported an issue from
// the session that just ended. One generation kept — enough, without unbounded growth.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
_ = os.Remove(logPath + ".1") // drop the older generation
if err := os.Rename(logPath, logPath+".1"); err != nil {
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
}
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
+20 -16
View File
@@ -129,6 +129,24 @@ func translateTextColumns(s string) string {
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
// tuneMySQLPool sizes the connection pool for a SHARED server. Two opposing needs:
// - A big per-instance pool (we shipped 50) let a handful of ops exhaust the
// server's global max_connections — "Error 1040: Too many connections".
// - Too SMALL a pool starves this instance's OWN concurrent UI queries — the live
// multi-op sync (revision poll every 2s + a 3-query grid refresh), live_status,
// chat, stats, cluster — so the grid stops showing other operators' QSOs. (8 was
// too tight and reintroduced this "deadlock-like stall".)
// 16 is the balance: plenty for the UI's bursts, and ~15 ops still fit under a
// raised max_connections=300 (advise operators to raise it for a big multi-op).
// Brisk idle reaping returns slots to the server; no max lifetime so a slow first
// migration on one connection isn't reaped mid-run (drops the selected database).
func tuneMySQLPool(conn *sql.DB) {
conn.SetMaxOpenConns(16)
conn.SetMaxIdleConns(6)
conn.SetConnMaxIdleTime(30 * time.Second)
conn.SetConnMaxLifetime(0)
}
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
@@ -141,18 +159,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
tuneMySQLPool(conn)
// 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
@@ -167,10 +174,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
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)
tuneMySQLPool(conn)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
+8
View File
@@ -392,8 +392,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++
}
// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
// so the continent breakdown totals the same as the QSO count (a handful of
// QSOs with no resolved continent made the donut read a few short).
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
contC[c]++
} else {
contC["Unknown"]++
}
if c := strings.TrimSpace(country.String); c != "" {
entityC[c]++
@@ -436,6 +441,9 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
if _, ok := contC["Unknown"]; ok {
s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
}
if !first.IsZero() {
s.FirstQSO = first.UTC().Format(time.RFC3339)
s.LastQSO = last.UTC().Format(time.RFC3339)