feat: live operator status goes offline after 5 min idle, online on log

The multi-op live status heartbeated every 15 s, so an operator who left the
log open but stopped working still showed on air. Now it publishes an explicit
online flag: online when a new contact was logged within the last 5 minutes,
offline otherwise. live_status gains `online` + `last_qso_at` columns (added to
existing tables via ALTER). Logging a QSO (manual, UDP, ADIF monitor) stamps the
time and republishes at once, so they flip back online instantly.

At launch the last-QSO time is seeded from the DB (Repo.LastQSOTime), so an
operator who worked someone just before starting OpsLog is shown online right
away rather than offline until their next contact. LiveLastQSOAgeSec exposes it
to the UI badge.
This commit is contained in:
2026-07-19 18:14:43 +02:00
parent 4ab4f70349
commit 901e967b53
3 changed files with 115 additions and 8 deletions
+3
View File
@@ -505,6 +505,7 @@ type App struct {
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string liveBand string
liveMode string liveMode string
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
awardSnapMu sync.Mutex // guards the award QSO snapshot awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none) awardSnapRev string // logbook revision the snapshot was built at ("" = none)
@@ -1871,6 +1872,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if err == nil { if err == nil {
q.ID = id q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
a.noteLiveQSO() // multi-op: flip this operator back "online"
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT). // Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
wruntime.EventsEmit(a.ctx, "qso:logged", id) wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q) a.saveQSORecording(&q)
@@ -9287,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
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.saveQSORecording(&q) a.saveQSORecording(&q)
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
+26
View File
@@ -1895,6 +1895,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err return n, err
} }
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
// operator (highest id wins) — used to seed the live "on air" state at launch so an
// operator who just worked someone before (re)starting OpsLog shows online right
// away instead of waiting for their next QSO. Empty operator matches every QSO.
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return time.Time{}, false
}
defer rows.Close()
opFilter := strings.ToUpper(strings.TrimSpace(operator))
for rows.Next() {
var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil {
return time.Time{}, false
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue
}
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
return t, true
}
}
return time.Time{}, false
}
// RecentRate counts QSOs whose start time falls within each trailing window from // RecentRate counts QSOs whose start time falls within each trailing window from
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty // `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op // (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
+85 -7
View File
@@ -19,6 +19,23 @@ import (
const keyLiveStatusEnabled = "livestatus.enabled" const keyLiveStatusEnabled = "livestatus.enabled"
// liveOnlineWindow is how long after the last logged contact an operator still
// counts as "on air". Leaving the log open without working anyone flips them
// offline once this elapses; logging a new QSO flips them back online.
const liveOnlineWindow = 5 * time.Minute
// noteLiveQSO records that this operator just logged a new contact and pushes the
// live status right away, so they flip back to online the instant they work
// someone. Called from the logging paths (manual entry, UDP auto-log).
func (a *App) noteLiveQSO() {
a.liveActMu.Lock()
a.liveLastQSOAt = time.Now()
a.liveActMu.Unlock()
if a.liveStatusActive() {
go a.publishLiveStatus()
}
}
// GetLiveStatusEnabled reports whether this operator publishes live status. // GetLiveStatusEnabled reports whether this operator publishes live status.
func (a *App) GetLiveStatusEnabled() bool { func (a *App) GetLiveStatusEnabled() bool {
if a.settings == nil { if a.settings == nil {
@@ -50,11 +67,44 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
return nil return nil
} }
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
// instead of offline until their next QSO.
func (a *App) seedLiveLastQSO() {
if a.qso == nil {
return
}
op, _ := a.liveStatusOperator()
if op == "" {
return
}
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
a.liveActMu.Lock()
if a.liveLastQSOAt.IsZero() {
a.liveLastQSOAt = t
}
a.liveActMu.Unlock()
}
}
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
// none is known — the UI uses it to seed the "on air" badge at launch.
func (a *App) LiveLastQSOAgeSec() int {
a.liveActMu.Lock()
last := a.liveLastQSOAt
a.liveActMu.Unlock()
if last.IsZero() {
return -1
}
return int(time.Since(last).Seconds())
}
// liveStatusLoop heartbeats the current activity while enabled. Started once at // liveStatusLoop heartbeats the current activity while enabled. Started once at
// startup; cheap no-op when disabled or not on MySQL. // startup; cheap no-op when disabled or not on MySQL.
func (a *App) liveStatusLoop() { func (a *App) liveStatusLoop() {
defer func() { _ = recover() }() // never crash the app from here defer func() { _ = recover() }() // never crash the app from here
applog.Printf("livestatus: loop started") applog.Printf("livestatus: loop started")
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
a.publishLiveStatus() // attempt immediately, don't wait the first tick a.publishLiveStatus() // attempt immediately, don't wait the first tick
t := time.NewTicker(15 * time.Second) t := time.NewTicker(15 * time.Second)
defer t.Stop() defer t.Stop()
@@ -132,34 +182,62 @@ func (a *App) publishLiveStatus() {
if mode == "" { if mode == "" {
mode = a.liveMode mode = a.liveMode
} }
lastQSO := a.liveLastQSOAt
a.liveActMu.Unlock() a.liveActMu.Unlock()
// Online = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the
// next QSO flips them back on. never-logged (zero time) → offline.
online := 0
var lastQSOArg any
if !lastQSO.IsZero() {
lastQSOArg = lastQSO.UTC()
if time.Since(lastQSO) < liveOnlineWindow {
online = 1
}
}
if err := a.ensureLiveStatusTable(); err != nil { if err := a.ensureLiveStatusTable(); err != nil {
applog.Printf("livestatus: CREATE TABLE failed: %v", err) applog.Printf("livestatus: CREATE TABLE failed: %v", err)
return return
} }
_, err := a.logDb.ExecContext(a.ctx, _, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+ "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()", "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+
op, station, freqHz, band, mode) "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode, online, lastQSOArg)
if err != nil { if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err) applog.Printf("livestatus: INSERT failed: %v", err)
return return
} }
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode) applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online)
} }
func (a *App) ensureLiveStatusTable() error { func (a *App) ensureLiveStatusTable() error {
_, err := a.logDb.ExecContext(a.ctx, if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS live_status ("+ "CREATE TABLE IF NOT EXISTS live_status ("+
"operator VARCHAR(32) PRIMARY KEY, "+ "operator VARCHAR(32) PRIMARY KEY, "+
"station VARCHAR(32), "+ "station VARCHAR(32), "+
"freq_hz BIGINT, "+ "freq_hz BIGINT, "+
"band VARCHAR(16), "+ "band VARCHAR(16), "+
"mode VARCHAR(16), "+ "mode VARCHAR(16), "+
"updated_at DATETIME)") "online TINYINT DEFAULT 0, "+
"last_qso_at DATETIME NULL, "+
"updated_at DATETIME)"); err != nil {
return err return err
}
// Add the online/last_qso_at columns to a table created by an older build.
// MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and
// ignore the duplicate-column error when they already exist.
for _, ddl := range []string{
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
} {
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
applog.Printf("livestatus: %q: %v", ddl, err)
}
}
return nil
} }
// clearLiveStatus removes this operator's row (on disable / shutdown). // clearLiveStatus removes this operator's row (on disable / shutdown).