Compare commits

...
8 Commits
Author SHA1 Message Date
rouggy 5f044b959e chore: release v0.20.3 2026-07-20 01:25:28 +02:00
rouggy 68a49be8c1 feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active
operator, accent) and Team (all operators, foreground) — each with the 10/60-min
QSOs/hour; single-op keeps the one-line display.

Live-stations widget: only stations currently on air are listed (offline ones
are hidden rather than greyed).

Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters
(the funnel icons) — the grid reports its displayed row count, so filtering a
column shows how many QSOs remain instead of always the full total.
2026-07-20 01:08:17 +02:00
rouggy 8eb82d6cdb feat: QSO rate meter — per-operator AND team totals
GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
2026-07-20 01:08:17 +02:00
rouggy d327db3f57 fix: auto-update relaunch — clear Mark-of-the-Web + wait for exit
The new build downloaded and OpsLog quit, but never came back. Two Windows
causes: the freshly written exe carried the internet Zone.Identifier mark, so
SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly,
since we launch it programmatically — and silently blocked the launch; and
starting the new exe while the old one was still exiting raced the single-
instance mutex.

Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is
done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're
fully gone and the mutex is free) before Start-Process'ing the new exe.
2026-07-19 19:53:19 +02:00
rouggy 59e6570f17 chore: release v0.20.2 2026-07-19 19:11:06 +02:00
rouggy 82a2c6cb7f fix: relay auto-control no longer toggles on every launch/close
Closing OpsLog dropped the CAT, whose frequency went to 0; the auto-control
read that as out of range and switched the relay OFF, then ON again at the next
launch. Now a rule is skipped entirely when the frequency/band is unknown (CAT
off), so relays keep their state across a close. And on the first evaluation
after launch/save it reads the boards' LIVE state and only switches a relay
that isn't already in the wanted position — no more clunk when it's already
correct.
2026-07-19 18:35:18 +02:00
rouggy 24eaf597fd fix: ON AIR badge correct at launch + faster to appear
At launch nothing is in memory yet, so the badge showed offline even with a
recent QSO. liveLastQSOTime is now authoritative: it takes the most recent of
the in-memory stamp AND the DB (this operator's last QSO — covers a contact
from the shared logbook or one logged before launch), used by both the
published status and the badge. The badge polls the backend every 5 s (down
from 15) and on each qso:logged, so it shows on air within a few seconds and
flips online instantly on a new contact.
2026-07-19 18:35:18 +02:00
rouggy 14a22ddb66 fix: SetExtra used the wrong column name (extras -> extras_json)
The targeted extras write used SET extras, but the column is extras_json, so
sending an OpsLog QSL card (and the recording stamps) failed with an unknown-
column error. Use the real column name.
2026-07-19 18:35:18 +02:00
13 changed files with 397 additions and 100 deletions
+11 -11
View File
@@ -4518,31 +4518,31 @@ func (a *App) GetOperators() ([]string, error) {
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were // QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes. // logged in the trailing 10 and 60 minutes.
type QSORate struct { type QSORate struct {
Last10 int `json:"last10"` Last10 int `json:"last10"` // active operator, last 10 min
Last60 int `json:"last60"` Last60 int `json:"last60"` // active operator, last 60 min
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
} }
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes. // GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// Cheap (scans only the most recent rows); polled by the header and refreshed on // for the active operator (their own performance) AND for all operators combined
// each qso:logged event. // (the team/station rate). Cheap (one scan of the most recent rows); polled by the
// header and refreshed on each qso:logged event.
func (a *App) GetQSORate() QSORate { func (a *App) GetQSORate() QSORate {
if a.qso == nil { if a.qso == nil {
return QSORate{} return QSORate{}
} }
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
// so each op sees their own performance, not the cumulative station rate. An
// empty operator (single-op / station owner) matches all their QSOs.
operator := "" operator := ""
if a.profiles != nil { if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil { if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator operator = p.Operator
} }
} }
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 { if err != nil || len(op) < 2 || len(all) < 2 {
return QSORate{} return QSORate{}
} }
return QSORate{Last10: counts[0], Last60: counts[1]} return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
} }
// GetContestRuns lists the (contest, year) pairs actually present in the log, so // GetContestRuns lists the (contest, year) pairs actually present in the log, so
+133 -25
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, 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'; } from 'lucide-react';
import { import {
@@ -13,7 +13,7 @@ import {
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -210,6 +210,16 @@ function bandForMHz(mhz: number): string {
return ''; 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. // rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] }; type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists { 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. // 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 agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); 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<LiveStation[]>([]);
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 // 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 // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface. // in Preferences > Hardware > CAT interface.
@@ -615,6 +637,7 @@ export default function App() {
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' }); const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null); const [matchCount, setMatchCount] = useState<number | null>(null);
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
// The selected tab is remembered across restarts. Only the always-present tabs // The selected tab is remembered across restarts. Only the always-present tabs
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
// a feature or CAT backend that isn't known this early, and restoring one that // a feature or CAT backend that isn't known this early, and restoring one that
@@ -1096,32 +1119,26 @@ export default function App() {
// offline. Only shown when live-status publishing is enabled (Settings→General). // offline. Only shown when live-status publishing is enabled (Settings→General).
const [liveStatusOn, setLiveStatusOn] = useState(false); const [liveStatusOn, setLiveStatusOn] = useState(false);
const [onAir, setOnAir] = useState(false); const [onAir, setOnAir] = useState(false);
const lastQsoAtRef = useRef(0);
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]); useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
useEffect(() => { useEffect(() => {
const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend // Read the ON-AIR state straight from the backend (single source of truth:
const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW); // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); }); // it + refresh on each logged QSO — no fragile frontend timestamp to drift.
// Seed from the DB at launch so a QSO logged just before starting OpsLog still const refresh = () => LiveLastQSOAgeSec()
// counts (otherwise the badge showed offline until the next contact). .then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
LiveLastQSOAgeSec().then((sec: number) => { .catch(() => {});
if (typeof sec === 'number' && sec >= 0) { refresh();
const at = Date.now() - sec * 1000; const off = EventsOn('qso:logged', refresh);
if (at > lastQsoAtRef.current) lastQsoAtRef.current = at; const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
evalOnAir();
}
}).catch(() => {});
evalOnAir();
const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing
return () => { off(); window.clearInterval(id); }; return () => { off(); window.clearInterval(id); };
}, [liveStatusOn]); }, [liveStatusOn]);
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]); useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 }); const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
useEffect(() => { useEffect(() => {
if (!showQsoRate) return; if (!showQsoRate) return;
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); }; const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
load(); load();
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
// trailing windows roll forward even when nothing new is logged. // trailing windows roll forward even when nothing new is logged.
@@ -3846,6 +3863,24 @@ export default function App() {
)} )}
</button> </button>
)} )}
{/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
{dbConn?.backend === 'mysql' && (
<button
type="button"
onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
: 'border-border text-muted-foreground hover:bg-muted')}
>
<Radio className="size-4" />
{liveStations.filter((s) => s.online).length > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
{(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
</span>
)}
</button>
)}
</div> </div>
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
@@ -3853,12 +3888,28 @@ export default function App() {
the last columns (profile / band map / compact) onto a 2nd row. */} the last columns (profile / band map / compact) onto a 2nd row. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showQsoRate && ( {showQsoRate && (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap" <div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}> title={t('rate.title')}>
{/* Contest-style rate: QSOs/hour projected from each window (10-min
count ×6; the 60-min count is already per hour). On a shared MySQL
logbook it shows both OP (the active operator, accent) and TEAM (all
operators, muted); single-op shows one line. */}
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} /> <Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
{/* Contest-style rate: QSOs/hour projected from each window {dbConn?.backend === 'mysql' ? (
(10-min count ×6; the 60-min count is already per hour). Numbers <div className="flex flex-col gap-0.5 leading-none">
glow the brand accent when active, dim to muted when idle. */} <div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
</div>
) : (
<>
<span className="inline-flex items-baseline gap-1"> <span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span>
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span> <span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
@@ -3867,7 +3918,9 @@ export default function App() {
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span>
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span> <span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
</span> </span>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span> </>
)}
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
</div> </div>
)} )}
@@ -4214,8 +4267,55 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} 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')) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch"> <div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{/* 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' && (
<div className="w-[248px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
<Radio className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
{liveStations.filter((s) => s.online).length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.filter((s) => s.online).map((s, i) => {
const mc = modeAccent(s.mode);
return (
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
title={s.online ? t('live.onAir') : t('live.offline')} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
</span>
{s.mode && (
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
)}
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{chatShown && ( {chatShown && (
// relative + absolute inner: the chat takes the row height (set by the // relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the // entry strip) WITHOUT its message list growing the row, like the
@@ -4539,6 +4639,7 @@ export default function App() {
rows={qsosWithAwards as any} rows={qsosWithAwards as any}
total={total} total={total}
awardCols={awardCols} awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4574,11 +4675,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }} onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button> >clear</button>
) : null} ) : null}
{gridFilteredCount != null ? (
<span>
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
</span>
) : (
<span> <span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '} Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span> <span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span> </span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && ( {qsos.length >= qsoLimit && qsos.length < total && (
+14 -1
View File
@@ -49,6 +49,10 @@ type Props = {
onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void; onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void; onDelete?: (ids: number[]) => void;
// Reports how many rows the grid shows after its COLUMN filters (the funnel
// icons), or null when no column filter is active — so the parent's "Showing X
// of Y" can reflect them. Fired on filter change and when the data updates.
onFilteredCountChange?: (count: number | null) => void;
// One column per defined award; the cell shows the reference this QSO counts // One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default. // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -360,6 +364,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
} }
}); });
} }
// Report the post-column-filter row count (funnel filters) to the parent, or
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
@@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady} onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState} onColumnResized={saveColumnState}
onColumnMoved={saveColumnState} onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState} onColumnPinned={saveColumnState}
+2
View File
@@ -17,6 +17,7 @@ const en: Dict = {
'live.onAir': 'On air', 'live.offline': 'Offline', '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.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.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.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…', 'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
@@ -335,6 +336,7 @@ const fr: Dict = {
'live.onAir': 'On air', 'live.offline': 'Hors ligne', '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.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.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', '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)', 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // 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.3';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -354,6 +354,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLiveStatusEnabled():Promise<boolean>; export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>; export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
+4
View File
@@ -666,6 +666,10 @@ export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings'](); return window['go']['main']['App']['GetListsSettings']();
} }
export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLiveStatusEnabled() { export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled'](); return window['go']['main']['App']['GetLiveStatusEnabled']();
} }
+30
View File
@@ -2054,6 +2054,32 @@ export namespace main {
return a; 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 { export class LoTWUsersStatus {
count: number; count: number;
updated?: string; updated?: string;
@@ -2381,6 +2407,8 @@ export namespace main {
export class QSORate { export class QSORate {
last10: number; last10: number;
last60: number; last60: number;
team_last10: number;
team_last60: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSORate(source); return new QSORate(source);
@@ -2390,6 +2418,8 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.last10 = source["last10"]; this.last10 = source["last10"];
this.last60 = source["last60"]; this.last60 = source["last60"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_last60"];
} }
} }
export class RelayAutoRule { export class RelayAutoRule {
+21 -21
View File
@@ -793,7 +793,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
return fmt.Errorf("missing id or key") return fmt.Errorf("missing id or key")
} }
var extrasJSON sql.NullString var extrasJSON sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil { if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
return fmt.Errorf("load extras: %w", err) return fmt.Errorf("load extras: %w", err)
} }
m := decodeExtras(extrasJSON.String) m := decodeExtras(extrasJSON.String)
@@ -806,7 +806,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
m[key] = value m[key] = value
} }
if _, err := r.db.ExecContext(ctx, if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`, `UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
encodeExtras(m), db.NowISO(), id); err != nil { encodeExtras(m), db.NowISO(), id); err != nil {
return fmt.Errorf("set extra %s: %w", key, err) return fmt.Errorf("set extra %s: %w", key, err)
} }
@@ -1921,21 +1921,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo
return time.Time{}, false return time.Time{}, false
} }
// RecentRate counts QSOs whose start time falls within each trailing window from // RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty // start time falls within each trailing window from `now` — for a specific operator
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op // (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// sees their OWN performance, not the cumulative rate; empty operator matches every // `all`). The header rate meter shows both. It scans only recently inserted rows
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since // (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large // it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format // format agnostic).
// agnostic). func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) { op = make([]int, len(windows))
counts := make([]int, len(windows)) all = make([]int, len(windows))
// 2000 rows covers a full hour for one operator even in a busy multi-op run // 2000 rows covers a full hour even in a busy multi-op run.
// (other operators' rows are discarded before counting).
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil { if err != nil {
return counts, err return op, all, err
} }
defer rows.Close() defer rows.Close()
now = now.UTC() now = now.UTC()
@@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
for rows.Next() { for rows.Next() {
var oper, dateStr sql.NullString var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil { if err := rows.Scan(&oper, &dateStr); err != nil {
return counts, err return op, all, err
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue // a different operator's QSO — not part of my rate
} }
t := parseTimeLoose(dateStr.String).UTC() t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) { if t.IsZero() || t.After(now) {
continue continue
} }
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t) age := now.Sub(t)
for i, w := range windows { for i, w := range windows {
if age <= w { if age <= w {
counts[i]++ all[i]++
if mine {
op[i]++
} }
} }
} }
return counts, rows.Err() }
return op, all, rows.Err()
} }
// ExistingDedupeKeys returns a set of every QSO key currently in the DB, // ExistingDedupeKeys returns a set of every QSO key currently in the DB,
+85 -11
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -87,12 +88,29 @@ func (a *App) seedLiveLastQSO() {
} }
} }
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if // liveLastQSOTime is the authoritative "last contact" instant for this operator:
// none is known — the UI uses it to seed the "on air" badge at launch. // the most recent of the in-memory stamp (this session's local logs, updated
func (a *App) LiveLastQSOAgeSec() int { // instantly) AND the DB (a contact that arrived via the SHARED logbook from another
// station, or one logged before launch). Used by both the published status and the
// UI badge so on-air/offline is right in every multi-op case.
func (a *App) liveLastQSOTime() time.Time {
a.liveActMu.Lock() a.liveActMu.Lock()
last := a.liveLastQSOAt last := a.liveLastQSOAt
a.liveActMu.Unlock() a.liveActMu.Unlock()
if a.qso != nil {
if op, _ := a.liveStatusOperator(); op != "" {
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
last = t
}
}
}
return last
}
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
// none is known — the UI polls it for the "on air" badge.
func (a *App) LiveLastQSOAgeSec() int {
last := a.liveLastQSOTime()
if last.IsZero() { if last.IsZero() {
return -1 return -1
} }
@@ -182,8 +200,8 @@ func (a *App) publishLiveStatus() {
if mode == "" { if mode == "" {
mode = a.liveMode mode = a.liveMode
} }
lastQSO := a.liveLastQSOAt
a.liveActMu.Unlock() a.liveActMu.Unlock()
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
// Online = a new contact was logged within the window. An operator who leaves // Online = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the // the log open but stops working shows offline after `liveOnlineWindow`; the
// next QSO flips them back on. never-logged (zero time) → offline. // next QSO flips them back on. never-logged (zero time) → offline.
@@ -200,12 +218,12 @@ func (a *App) publishLiveStatus() {
return return
} }
_, err := a.logDb.ExecContext(a.ctx, _, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+ "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, 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), 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()", "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 { if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err) applog.Printf("livestatus: INSERT failed: %v", err)
return return
@@ -213,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) 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 { func (a *App) ensureLiveStatusTable() error {
if _, 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 ("+
@@ -222,15 +294,17 @@ func (a *App) ensureLiveStatusTable() error {
"band VARCHAR(16), "+ "band VARCHAR(16), "+
"mode VARCHAR(16), "+ "mode VARCHAR(16), "+
"online TINYINT DEFAULT 0, "+ "online TINYINT DEFAULT 0, "+
"version VARCHAR(32), "+
"last_qso_at DATETIME NULL, "+ "last_qso_at DATETIME NULL, "+
"updated_at DATETIME)"); err != nil { "updated_at DATETIME)"); err != nil {
return err return err
} }
// Add the online/last_qso_at columns to a table created by an older build. // Add newer columns to a table created by an older build. MySQL has no portable
// MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and // "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
// ignore the duplicate-column error when they already exist. // column error when they already exist.
for _, ddl := range []string{ for _, ddl := range []string{
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0", "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", "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") { if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
+61 -10
View File
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
return false return false
} }
// relayAction is one relay's computed desired state for this evaluation.
type relayAction struct {
dev string
relay int
want bool
}
// applyRelayAuto evaluates every rule against the current frequency/band and // applyRelayAuto evaluates every rule against the current frequency/band and
// switches only the relays whose desired state changed since the last apply. // switches only the relays that are NOT already in the wanted position. Two things
// it deliberately does NOT do, which used to make the relay clunk on every
// launch/close:
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
// the frequency drops to 0; reading that as "out of range" and switching the
// relay off — then back on at the next launch — was the whole bug.
// - Never commands a relay already in the right position: on the first evaluation
// after launch/save it reads the boards' LIVE state, so a relay that's already
// correct is left untouched instead of being re-sent.
func (a *App) applyRelayAuto(freqHz int64, band string) { func (a *App) applyRelayAuto(freqHz int64, band string) {
a.relayAutoMu.Lock() a.relayAutoMu.Lock()
defer a.relayAutoMu.Unlock() defer a.relayAutoMu.Unlock()
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
a.relayAutoLast = map[string]bool{} a.relayAutoLast = map[string]bool{}
} }
khz := float64(freqHz) / 1000.0 khz := float64(freqHz) / 1000.0
band = strings.TrimSpace(band)
changed := false // Compute desired states, skipping rules whose input is unknown right now.
var acts []relayAction
needLive := false
for _, r := range cfg.Rules { for _, r := range cfg.Rules {
if r.Relay < 1 { if r.Relay < 1 {
continue continue
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
var want bool var want bool
switch r.Mode { switch r.Mode {
case "freq": case "freq":
if freqHz <= 0 {
continue // no known frequency (CAT off/closing) → leave the relay as-is
}
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 { if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
continue // unconfigured range → leave the relay alone continue // unconfigured range
} }
lo, hi := r.FreqLoKHz, r.FreqHiKHz lo, hi := r.FreqLoKHz, r.FreqHiKHz
if hi < lo { if hi < lo {
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
} }
want = khz >= lo && khz <= hi want = khz >= lo && khz <= hi
case "band": case "band":
if band == "" {
continue // no known band → leave the relay as-is
}
if len(r.Bands) == 0 { if len(r.Bands) == 0 {
continue continue
} }
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
default: default:
continue // "off"/empty → not managed continue // "off"/empty → not managed
} }
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
key := relayAutoKey(r.DeviceID, r.Relay) if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
if last, ok := a.relayAutoLast[key]; ok && last == want { needLive = true
continue // no change → don't hammer the board
} }
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil { }
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err) if len(acts) == 0 {
return
}
// First evaluation after launch/save: read the boards' LIVE relay states once
// so we don't re-command a relay that's already in the wanted position.
var live map[string]bool
if needLive {
live = map[string]bool{}
for _, ds := range a.GetStationStatus() {
for _, rl := range ds.Relays {
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
}
}
}
changed := false
for _, ac := range acts {
key := relayAutoKey(ac.dev, ac.relay)
cur, known := a.relayAutoLast[key]
if !known && live != nil {
cur, known = live[key]
}
if known && cur == ac.want {
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
continue
}
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
continue // don't cache a failed write — retry next change continue // don't cache a failed write — retry next change
} }
a.relayAutoLast[key] = want a.relayAutoLast[key] = ac.want
changed = true changed = true
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.1" appVersion = "0.20.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+19 -6
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime" wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -159,14 +160,26 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
_ = os.Rename(oldExe, exe) // roll back _ = os.Rename(oldExe, exe) // roll back
return fmt.Errorf("install new exe: %w", err) return fmt.Errorf("install new exe: %w", err)
} }
applog.Printf("update: installed new exe, relaunching") // Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never shows,
// and the launch is silently blocked. This is exactly why the relaunch failed.
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
// Relaunch with a flag so the fresh instance waits for THIS one to exit and // Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// free the single-instance mutex instead of bailing out immediately. // (so the single-instance mutex is free) and THEN starts the new exe. Launching
cmd := exec.Command(exe, "--post-update") // the new exe directly while we're still alive raced the mutex and often left
cmd.Dir = dir // nothing running; waiting for our own exit first makes the restart reliable,
// and the launcher outlives us.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch: %w", err) return fmt.Errorf("schedule relaunch: %w", err)
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.Quit(a.ctx) wruntime.Quit(a.ctx)