Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b |
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }>>({});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user