diff --git a/app.go b/app.go index fc9f000..39feb82 100644 --- a/app.go +++ b/app.go @@ -379,6 +379,10 @@ type App struct { dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite catFlexSpots bool // push cluster spots to the FlexRadio panadapter + liveActMu sync.Mutex // guards the entry-strip activity reported for live status + liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) + liveBand string + liveMode string 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) diff --git a/docs/livestatus/tm74-status.php b/docs/livestatus/tm74-status.php index 43cb71d..1c6e1c6 100644 --- a/docs/livestatus/tm74-status.php +++ b/docs/livestatus/tm74-status.php @@ -22,22 +22,29 @@ $DB_USER = 'opslog'; $DB_PASS = 'CHANGE_ME'; $STALE_SECONDS = 120; // an operator is "active" if seen within this window +// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing +// `live_status` table (not yet created by OpsLog) just yields an empty list +// instead of a fatal "table doesn't exist". +mysqli_report(MYSQLI_REPORT_OFF); + $mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME); if ($mysqli->connect_errno) { http_response_code(500); exit('DB error'); } +// The table is created by OpsLog on first publish; tolerate it not existing yet. $rows = []; $sql = "SELECT operator, station, freq_hz, band, mode, updated_at FROM live_status WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY band, freq_hz"; -if ($stmt = $mysqli->prepare($sql)) { +if ($stmt = @$mysqli->prepare($sql)) { $stmt->bind_param('i', $STALE_SECONDS); - $stmt->execute(); - $res = $stmt->get_result(); - while ($r = $res->fetch_assoc()) $rows[] = $r; + @$stmt->execute(); + if ($res = $stmt->get_result()) { + while ($r = $res->fetch_assoc()) $rows[] = $r; + } } $station = $rows ? $rows[0]['station'] : 'OpsLog'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c094b3..0ee7119 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import { QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, GetAwardDefs, GetUIPref, + ReportLiveActivity, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; @@ -678,6 +679,12 @@ export default function App() { setMainPaneRight(valid(r) ? r : 'map2'); }, []); useEffect(() => { loadMainPanes(); }, [loadMainPanes]); + // Report the current entry-strip band/mode/freq to the backend so the live + // operator status (multi-op) has band/mode/freq even when the CAT is off. + useEffect(() => { + const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0; + ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {}); + }, [band, mode, freqMhz]); // Cluster filter sidebar visibility — shared by the Cluster tab and the // Main-view cluster pane (portable UI pref). Hiding it keeps the filters // active, it just reclaims the width. diff --git a/frontend/src/version.ts b/frontend/src/version.ts index ba4bd7e..9f9bc6a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // Single source of truth for the app version shown in the UI (header + About). // Bump this on a release (the release script updates it alongside telemetry.go). -export const APP_VERSION = '0.11.1'; +export const APP_VERSION = '0.11.2'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index a6c6dc7..932972e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -367,6 +367,8 @@ export function RenderEQSL(arg1:number,arg2:number):Promise; export function ReplaceAwardReferences(arg1:string,arg2:Array):Promise; +export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise; + export function RescanAwards():Promise; export function ResetAwardDefs():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 9909f65..31b6dc9 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -706,6 +706,10 @@ export function ReplaceAwardReferences(arg1, arg2) { return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2); } +export function ReportLiveActivity(arg1, arg2, arg3) { + return window['go']['main']['App']['ReportLiveActivity'](arg1, arg2, arg3); +} + export function RescanAwards() { return window['go']['main']['App']['RescanAwards'](); } diff --git a/livestatus.go b/livestatus.go index 4987740..dc512be 100644 --- a/livestatus.go +++ b/livestatus.go @@ -42,6 +42,7 @@ func (a *App) SetLiveStatusEnabled(on bool) error { return err } if on { + applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil) go a.publishLiveStatus() // show up right away } else { a.clearLiveStatus() @@ -52,16 +53,13 @@ func (a *App) SetLiveStatusEnabled(on bool) error { // 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.publishLiveStatus() // attempt immediately, don't wait the first tick t := time.NewTicker(15 * time.Second) defer t.Stop() - for { - select { - case <-a.ctx.Done(): - a.clearLiveStatus() - return - case <-t.C: - a.publishLiveStatus() - } + for range t.C { + a.publishLiveStatus() } } @@ -71,28 +69,47 @@ func (a *App) liveStatusActive() bool { } // liveStatusOperator returns this instance's operator id (the operator callsign, -// falling back to the station callsign for a single-op setup). +// falling back to the station callsign for a single-op setup). The callsign and +// operator live on the ACTIVE PROFILE (station_profiles table), NOT in the +// settings KV — read them there. func (a *App) liveStatusOperator() (op, station string) { - if a.settings == nil { + if a.profiles == nil { return "", "" } - o, _ := a.settings.Get(a.ctx, keyStationOperator) - s, _ := a.settings.Get(a.ctx, keyStationCallsign) - op = strings.ToUpper(strings.TrimSpace(o)) - station = strings.ToUpper(strings.TrimSpace(s)) + p, err := a.profiles.Active(a.ctx) + if err != nil { + return "", "" + } + station = strings.ToUpper(strings.TrimSpace(p.Callsign)) + op = strings.ToUpper(strings.TrimSpace(p.Operator)) if op == "" { op = station } return op, station } -// publishLiveStatus upserts this operator's current activity. Best effort. +// ReportLiveActivity is called by the UI with the current entry-strip freq/band/ +// mode, used as a fallback for live status when the CAT isn't connected. +func (a *App) ReportLiveActivity(freqHz int64, band, mode string) { + a.liveActMu.Lock() + a.liveFreqHz = freqHz + a.liveBand = strings.ToUpper(strings.TrimSpace(band)) + a.liveMode = strings.ToUpper(strings.TrimSpace(mode)) + a.liveActMu.Unlock() +} + +// publishLiveStatus upserts this operator's current activity. Best effort, with +// explicit logging so a silent no-op is diagnosable. func (a *App) publishLiveStatus() { - if !a.liveStatusActive() { - return + if a.logDb == nil || a.dbBackend != "mysql" { + return // not a MySQL logbook — nothing to do (silent, runs every 15s) + } + if !a.GetLiveStatusEnabled() { + return // disabled (silent) } op, station := a.liveStatusOperator() if op == "" { + applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)") return } var freqHz int64 @@ -103,8 +120,21 @@ func (a *App) publishLiveStatus() { freqHz, band, mode = st.FreqHz, st.Band, st.Mode } } + // Fall back to whatever the entry strip last reported (so band/mode/freq are + // published even when the CAT isn't connected). + a.liveActMu.Lock() + if freqHz == 0 { + freqHz = a.liveFreqHz + } + if band == "" { + band = a.liveBand + } + if mode == "" { + mode = a.liveMode + } + a.liveActMu.Unlock() if err := a.ensureLiveStatusTable(); err != nil { - applog.Printf("livestatus: ensure table: %v", err) + applog.Printf("livestatus: CREATE TABLE failed: %v", err) return } _, err := a.logDb.ExecContext(a.ctx, @@ -114,8 +144,10 @@ func (a *App) publishLiveStatus() { "band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()", op, station, freqHz, band, mode) if err != nil { - applog.Printf("livestatus: publish: %v", err) + applog.Printf("livestatus: INSERT failed: %v", err) + return } + applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode) } func (a *App) ensureLiveStatusTable() error { diff --git a/telemetry.go b/telemetry.go index b481ca2..fbfa0b9 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.11.1" + appVersion = "0.11.2" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.