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:
+86
-8
@@ -19,6 +19,23 @@ import (
|
||||
|
||||
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.
|
||||
func (a *App) GetLiveStatusEnabled() bool {
|
||||
if a.settings == nil {
|
||||
@@ -50,11 +67,44 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||
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
|
||||
// startup; cheap no-op when disabled or not on MySQL.
|
||||
func (a *App) liveStatusLoop() {
|
||||
defer func() { _ = recover() }() // never crash the app from here
|
||||
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
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -132,34 +182,62 @@ func (a *App) publishLiveStatus() {
|
||||
if mode == "" {
|
||||
mode = a.liveMode
|
||||
}
|
||||
lastQSO := a.liveLastQSOAt
|
||||
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 {
|
||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||
return
|
||||
}
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode)
|
||||
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+
|
||||
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode, online, lastQSOArg)
|
||||
if err != nil {
|
||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||
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 {
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
if _, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||
"station VARCHAR(32), "+
|
||||
"freq_hz BIGINT, "+
|
||||
"band VARCHAR(16), "+
|
||||
"mode VARCHAR(16), "+
|
||||
"updated_at DATETIME)")
|
||||
return err
|
||||
"online TINYINT DEFAULT 0, "+
|
||||
"last_qso_at DATETIME NULL, "+
|
||||
"updated_at DATETIME)"); err != nil {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user