diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1d01ad..a2704c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, - Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, + Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -13,7 +13,7 @@ import { GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, - GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, + GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, @@ -210,6 +210,16 @@ function bandForMHz(mhz: number): string { return ''; } +// modeAccent maps a mode to a theme-aware colour for the live-stations widget: +// CW gold, phone green, digital blue, unknown muted. +function modeAccent(mode?: string): string { + const m = (mode || '').toUpperCase(); + if (/CW/.test(m)) return 'var(--chart-3)'; + if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)'; + if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)'; + return 'var(--muted-foreground)'; +} + // rstCategory buckets a mode into the report family used for its RST list. type RSTLists = { phone: string[]; cw: string[]; digital: string[] }; function rstCategory(mode: string): keyof RSTLists { @@ -410,6 +420,18 @@ export default function App() { // click reverts the UI and the click looks like it did nothing. const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); + // Multi-op "who's on air" widget: every operator's live status from the shared + // MySQL logbook (freq/mode/version). Only polled on a MySQL logbook. + type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number }; + const [liveStations, setLiveStations] = useState([]); + const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1'); + useEffect(() => { + if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; } + const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {}); + load(); + const id = window.setInterval(load, 15 * 1000); + return () => window.clearInterval(id); + }, [dbConn]); // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. @@ -3840,6 +3862,24 @@ export default function App() { )} )} + {/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */} + {dbConn?.backend === 'mysql' && ( + + )} {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header @@ -4208,8 +4248,55 @@ export default function App() { {/* Reserved free space to the right. The WinKeyer CW keyer and/or the Digital Voice Keyer take this slot when enabled (Log4OM-style); otherwise it shows the QRZ profile photo. */} - {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && ( + {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
+ {/* Multi-op "who's on air" widget: every operator on the shared logbook, + their freq/mode (colour-coded) and OpsLog version. */} + {showLiveStations && dbConn?.backend === 'mysql' && ( +
+
+
+ + {t('live.stationsTitle')} +
+ {liveStations.filter((s) => s.online).length}/{liveStations.length} + +
+
+ {liveStations.length === 0 ? ( +

{t('live.stationsEmpty')}

+ ) : liveStations.map((s, i) => { + const mc = modeAccent(s.mode); + return ( +
+ +
+
+ {s.operator} + {s.version && v{s.version}} +
+
+ + {s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'} + + {s.mode && ( + {s.mode} + )} + {s.band && {s.band}} +
+
+
+ ); + })} +
+
+
+ )} {chatShown && ( // relative + absolute inner: the chat takes the row height (set by the // entry strip) WITHOUT its message list growing the row, like the diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 3633f8e..c1acf12 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -17,6 +17,7 @@ const en: Dict = { 'live.onAir': 'On air', 'live.offline': 'Offline', 'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)', 'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes', + 'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide', 'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.", 'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…', @@ -335,6 +336,7 @@ const fr: Dict = { 'live.onAir': 'On air', 'live.offline': 'Hors ligne', 'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)", 'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes', + 'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer', 'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes', 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 57c1c6f..b6440e8 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.20.1'; +export const APP_VERSION = '0.20.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 db1627e..cb5d99b 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -354,6 +354,8 @@ export function GetIcomState():Promise; export function GetListsSettings():Promise; +export function GetLiveStations():Promise>; + export function GetLiveStatusEnabled():Promise; export function GetLoTWUsersStatus():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 89f2130..0aa9deb 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -666,6 +666,10 @@ export function GetListsSettings() { return window['go']['main']['App']['GetListsSettings'](); } +export function GetLiveStations() { + return window['go']['main']['App']['GetLiveStations'](); +} + export function GetLiveStatusEnabled() { return window['go']['main']['App']['GetLiveStatusEnabled'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 027e6a6..e1e1be4 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2054,6 +2054,32 @@ export namespace main { return a; } } + export class LiveStation { + operator: string; + station: string; + freq_hz: number; + band: string; + mode: string; + online: boolean; + version: string; + age_sec: number; + + static createFrom(source: any = {}) { + return new LiveStation(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.operator = source["operator"]; + this.station = source["station"]; + this.freq_hz = source["freq_hz"]; + this.band = source["band"]; + this.mode = source["mode"]; + this.online = source["online"]; + this.version = source["version"]; + this.age_sec = source["age_sec"]; + } + } export class LoTWUsersStatus { count: number; updated?: string; diff --git a/livestatus.go b/livestatus.go index 2e0588a..1958df6 100644 --- a/livestatus.go +++ b/livestatus.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "fmt" "strings" "time" @@ -217,12 +218,12 @@ func (a *App) publishLiveStatus() { return } _, err := a.logDb.ExecContext(a.ctx, - "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ + "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, 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), online=VALUES(online), "+ + "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+ "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", - op, station, freqHz, band, mode, online, lastQSOArg) + op, station, freqHz, band, mode, online, appVersion, lastQSOArg) if err != nil { applog.Printf("livestatus: INSERT failed: %v", err) return @@ -230,6 +231,60 @@ func (a *App) publishLiveStatus() { applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) } +// LiveStation is one operator's live status for the multi-op "who's on air" widget. +type LiveStation struct { + Operator string `json:"operator"` + Station string `json:"station"` + FreqHz int64 `json:"freq_hz"` + Band string `json:"band"` + Mode string `json:"mode"` + Online bool `json:"online"` // logged a QSO in the last 5 min + Version string `json:"version"` // that operator's OpsLog version + AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed) +} + +// GetLiveStations returns every operator's live status from the shared MySQL +// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale +// (OpsLog closed without clearing its row) are dropped. Online stations first. +func (a *App) GetLiveStations() []LiveStation { + if a.logDb == nil || a.dbBackend != "mysql" { + return nil + } + if err := a.ensureLiveStatusTable(); err != nil { + return nil + } + rows, err := a.logDb.QueryContext(a.ctx, + "SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+ + "COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+ + "TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+ + "FROM live_status ORDER BY online DESC, operator") + if err != nil { + applog.Printf("livestatus: list failed: %v", err) + return nil + } + defer rows.Close() + out := []LiveStation{} + for rows.Next() { + var s LiveStation + var online int + var age sql.NullInt64 + if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil { + continue + } + // Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the + // heartbeat is every 15 s, so > 3 min means it's gone. + if age.Valid && age.Int64 > 180 { + continue + } + s.Online = online == 1 + if age.Valid { + s.AgeSec = int(age.Int64) + } + out = append(out, s) + } + return out +} + func (a *App) ensureLiveStatusTable() error { if _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS live_status ("+ @@ -239,15 +294,17 @@ func (a *App) ensureLiveStatusTable() error { "band VARCHAR(16), "+ "mode VARCHAR(16), "+ "online TINYINT DEFAULT 0, "+ + "version VARCHAR(32), "+ "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. + // Add newer 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 version VARCHAR(32)", "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") { diff --git a/telemetry.go b/telemetry.go index f756154..ac14b06 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.20.1" + appVersion = "0.20.2" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.