192 lines
6.3 KiB
Go
192 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/db"
|
|
"hamlog/internal/offlineq"
|
|
"hamlog/internal/qso"
|
|
)
|
|
|
|
// ── Offline safety net ────────────────────────────────────────────────
|
|
//
|
|
// With the shared MySQL logbook, the network sits on the critical path of every
|
|
// write: lose it and you can't log at all. Rather than a fragile "fall back to
|
|
// SQLite" (which would leave you with a partial log and needs a restart), we
|
|
// keep the architecture exactly as it is and add a NET:
|
|
//
|
|
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
|
|
// DB back → replayed into the logbook, idempotently, file archived
|
|
//
|
|
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
|
|
// keeps it small. Accepted trade-off: while offline you don't see other ops'
|
|
// QSOs, and worked-before doesn't know about your own pending ones (they're
|
|
// listed separately in the UI instead).
|
|
|
|
// offlineReplayEvery is how often we re-probe the database while QSOs are
|
|
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
|
|
const offlineReplayEvery = 15 * time.Second
|
|
|
|
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
|
|
type OfflineStatus struct {
|
|
Offline bool `json:"offline"` // last write failed because the DB was unreachable
|
|
Pending int `json:"pending"` // QSOs sitting in the outbox
|
|
Path string `json:"path"` // where the outbox physically is
|
|
}
|
|
|
|
// queueOffline parks a QSO that couldn't reach the database. Returns false when
|
|
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
|
|
// outbox itself failed — in which case the caller MUST surface the original error
|
|
// rather than pretend the QSO was saved.
|
|
func (a *App) queueOffline(q qso.QSO, cause error) bool {
|
|
if a.offlineQ == nil || !db.IsMySQL() {
|
|
return false
|
|
}
|
|
qid, err := a.offlineQ.Append(q)
|
|
if err != nil {
|
|
// The net itself tore: do NOT claim success.
|
|
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
|
|
return false
|
|
}
|
|
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
|
|
a.offlineMode = true
|
|
a.emitOfflineStatus()
|
|
return true
|
|
}
|
|
|
|
// emitOfflineStatus pushes the banner state to the UI.
|
|
func (a *App) emitOfflineStatus() {
|
|
if a.ctx == nil || a.offlineQ == nil {
|
|
return
|
|
}
|
|
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
|
|
}
|
|
|
|
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
|
|
func (a *App) GetOfflineStatus() OfflineStatus {
|
|
if a.offlineQ == nil {
|
|
return OfflineStatus{}
|
|
}
|
|
n := a.offlineQ.Count()
|
|
return OfflineStatus{
|
|
Offline: a.offlineMode && n > 0,
|
|
Pending: n,
|
|
Path: a.offlineQ.Path(),
|
|
}
|
|
}
|
|
|
|
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
|
|
// what they logged while the database was down instead of flying blind.
|
|
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
|
|
if a.offlineQ == nil {
|
|
return nil, nil
|
|
}
|
|
return a.offlineQ.Pending()
|
|
}
|
|
|
|
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
|
|
// waiting for the next tick.
|
|
func (a *App) RetryOfflineSync() (int, error) {
|
|
return a.replayOfflineQueue()
|
|
}
|
|
|
|
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
|
|
// them the moment it answers.
|
|
func (a *App) offlineReplayLoop() {
|
|
t := time.NewTicker(offlineReplayEvery)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
|
|
continue
|
|
}
|
|
if a.logDb == nil {
|
|
continue
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
err := a.logDb.PingContext(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
continue // still down — keep waiting, the QSOs are safe on disk
|
|
}
|
|
if n, rerr := a.replayOfflineQueue(); rerr != nil {
|
|
applog.Printf("offline: replay failed: %v", rerr)
|
|
} else if n > 0 {
|
|
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
|
|
// parked QSO carries a queue id, and one already present in the log is skipped —
|
|
// so a crash between "inserted" and "removed from the file" can never duplicate a
|
|
// contact. The file is archived BEFORE being cleared.
|
|
func (a *App) replayOfflineQueue() (int, error) {
|
|
if a.offlineQ == nil || a.qso == nil {
|
|
return 0, nil
|
|
}
|
|
pending, err := a.offlineQ.Pending()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(pending) == 0 {
|
|
a.offlineMode = false
|
|
a.emitOfflineStatus()
|
|
return 0, nil
|
|
}
|
|
|
|
imported := 0
|
|
var failed []qso.QSO
|
|
for _, q := range pending {
|
|
qid := ""
|
|
if q.Extras != nil {
|
|
qid = q.Extras[qso.OfflineQueueKey]
|
|
}
|
|
// Idempotency guard: already replayed on an earlier (interrupted) pass?
|
|
if qid != "" {
|
|
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
|
|
imported++ // it IS in the log — treat as done, drop from the outbox
|
|
continue
|
|
} else if e != nil && db.IsConnLost(e) {
|
|
failed = append(failed, q) // DB went away again mid-replay
|
|
continue
|
|
}
|
|
}
|
|
if _, e := a.qso.Add(a.ctx, q); e != nil {
|
|
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
|
|
failed = append(failed, q)
|
|
continue
|
|
}
|
|
imported++
|
|
}
|
|
|
|
// Archive the outbox as it was, then keep only what still failed.
|
|
if imported > 0 {
|
|
if dst, e := a.offlineQ.Archive(); e != nil {
|
|
applog.Printf("offline: archive failed: %v", e)
|
|
} else if dst != "" {
|
|
applog.Printf("offline: outbox archived to %s", dst)
|
|
}
|
|
}
|
|
if e := a.offlineQ.Rewrite(failed); e != nil {
|
|
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
|
|
}
|
|
|
|
a.offlineMode = len(failed) > 0
|
|
a.emitOfflineStatus()
|
|
if imported > 0 && a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
|
|
}
|
|
return imported, nil
|
|
}
|
|
|
|
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
|
|
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
|
|
// very thing this design avoids.
|
|
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
|