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:
@@ -505,6 +505,7 @@ type App struct {
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand 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
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
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 {
|
||||
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"
|
||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
a.saveQSORecording(&q)
|
||||
@@ -9287,6 +9289,7 @@ 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.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
|
||||
@@ -1895,6 +1895,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
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
|
||||
// `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
|
||||
|
||||
+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