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
// logged in the trailing 10 and 60 minutes.
type QSORate struct {
Last10 int `json:"last10"`
Last60 int `json:"last60"`
Last10 int `json:"last10"` // active operator, last 10 min
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.
// Cheap (scans only the most recent rows); polled by the header and refreshed on
// each qso:logged event.
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// for the active operator (their own performance) AND for all operators combined
// (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 {
if a.qso == nil {
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 := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator
}
}
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 {
op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(op) < 2 || len(all) < 2 {
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
+146 -38
View File
@@ -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<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
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
@@ -615,6 +637,7 @@ export default function App() {
const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
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
// 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
@@ -1096,32 +1119,26 @@ export default function App() {
// offline. Only shown when live-status publishing is enabled (Settings→General).
const [liveStatusOn, setLiveStatusOn] = useState(false);
const [onAir, setOnAir] = useState(false);
const lastQsoAtRef = useRef(0);
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
useEffect(() => {
const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend
const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW);
const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); });
// Seed from the DB at launch so a QSO logged just before starting OpsLog still
// counts (otherwise the badge showed offline until the next contact).
LiveLastQSOAgeSec().then((sec: number) => {
if (typeof sec === 'number' && sec >= 0) {
const at = Date.now() - sec * 1000;
if (at > lastQsoAtRef.current) lastQsoAtRef.current = at;
evalOnAir();
}
}).catch(() => {});
evalOnAir();
const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing
// Read the ON-AIR state straight from the backend (single source of truth:
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
const refresh = () => LiveLastQSOAgeSec()
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
.catch(() => {});
refresh();
const off = EventsOn('qso:logged', refresh);
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
return () => { off(); window.clearInterval(id); };
}, [liveStatusOn]);
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
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(() => {
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();
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
// trailing windows roll forward even when nothing new is logged.
@@ -3846,6 +3863,24 @@ export default function App() {
)}
</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>
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
@@ -3853,21 +3888,39 @@ export default function App() {
the last columns (profile / band map / compact) onto a 2nd row. */}
<div className="flex items-center gap-2">
{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')}>
{/* 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')} />
{/* Contest-style rate: QSOs/hour projected from each window
(10-min count ×6; the 60-min count is already per hour). Numbers
glow the brand accent when active, dim to muted when idle. */}
<span className="inline-flex items-baseline gap-1">
<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>
<span className="inline-flex items-baseline gap-1">
<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>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
{dbConn?.backend === 'mysql' ? (
<div className="flex flex-col gap-0.5 leading-none">
<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="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>
<span className="inline-flex items-baseline gap-1">
<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>
</>
)}
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
</div>
)}
@@ -4214,8 +4267,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')) && (
<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 && (
// relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the
@@ -4539,6 +4639,7 @@ export default function App() {
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4574,11 +4675,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
<span>
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>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
{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>
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>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
+14 -1
View File
@@ -49,6 +49,10 @@ type Props = {
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => 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
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
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[] =>
(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 gridRef = useRef<any>(null);
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(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
@@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
+2
View File
@@ -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',
+1 -1
View File
@@ -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.3';
// Author / credits, shown in Help -> About.
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 GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
+4
View File
@@ -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']();
}
+30
View File
@@ -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;
@@ -2381,6 +2407,8 @@ export namespace main {
export class QSORate {
last10: number;
last60: number;
team_last10: number;
team_last60: number;
static createFrom(source: any = {}) {
return new QSORate(source);
@@ -2390,6 +2418,8 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.last10 = source["last10"];
this.last60 = source["last60"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_last60"];
}
}
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")
}
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)
}
m := decodeExtras(extrasJSON.String)
@@ -806,7 +806,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
m[key] = value
}
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 {
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
}
// 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
// sees their OWN performance, not the cumulative rate; empty operator matches every
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format
// agnostic).
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) {
counts := make([]int, len(windows))
// 2000 rows covers a full hour for one operator even in a busy multi-op run
// (other operators' rows are discarded before counting).
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// start time falls within each trailing window from `now` — for a specific operator
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// `all`). The header rate meter shows both. It scans only recently inserted rows
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// format agnostic).
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
op = make([]int, len(windows))
all = make([]int, len(windows))
// 2000 rows covers a full hour even in a busy multi-op run.
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil {
return counts, err
return op, all, err
}
defer rows.Close()
now = now.UTC()
@@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
for rows.Next() {
var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil {
return counts, err
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue // a different operator's QSO — not part of my rate
return op, all, err
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue
}
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t)
for i, w := range windows {
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,
+85 -11
View File
@@ -1,6 +1,7 @@
package main
import (
"database/sql"
"fmt"
"strings"
"time"
@@ -87,12 +88,29 @@ func (a *App) seedLiveLastQSO() {
}
}
// 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 {
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
// the most recent of the in-memory stamp (this session's local logs, updated
// 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()
last := a.liveLastQSOAt
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() {
return -1
}
@@ -182,8 +200,8 @@ func (a *App) publishLiveStatus() {
if mode == "" {
mode = a.liveMode
}
lastQSO := a.liveLastQSOAt
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
// the log open but stops working shows offline after `liveOnlineWindow`; the
// next QSO flips them back on. never-logged (zero time) → offline.
@@ -200,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
@@ -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)
}
// 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 ("+
@@ -222,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") {
+61 -10
View File
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
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
// 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) {
a.relayAutoMu.Lock()
defer a.relayAutoMu.Unlock()
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
a.relayAutoLast = map[string]bool{}
}
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 {
if r.Relay < 1 {
continue
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
var want bool
switch r.Mode {
case "freq":
if freqHz <= 0 {
continue // no known frequency (CAT off/closing) → leave the relay as-is
}
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
continue // unconfigured range → leave the relay alone
continue // unconfigured range
}
lo, hi := r.FreqLoKHz, r.FreqHiKHz
if hi < lo {
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
}
want = khz >= lo && khz <= hi
case "band":
if band == "" {
continue // no known band → leave the relay as-is
}
if len(r.Bands) == 0 {
continue
}
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
default:
continue // "off"/empty → not managed
}
key := relayAutoKey(r.DeviceID, r.Relay)
if last, ok := a.relayAutoLast[key]; ok && last == want {
continue // no change → don't hammer the board
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
needLive = true
}
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
}
a.relayAutoLast[key] = want
a.relayAutoLast[key] = ac.want
changed = true
}
+1 -1
View File
@@ -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.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+19 -6
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
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
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
// free the single-instance mutex instead of bailing out immediately.
cmd := exec.Command(exe, "--post-update")
cmd.Dir = dir
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
// the new exe directly while we're still alive raced the mutex and often left
// 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 {
return fmt.Errorf("relaunch: %w", err)
return fmt.Errorf("schedule relaunch: %w", err)
}
if a.ctx != nil {
wruntime.Quit(a.ctx)