Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 |
@@ -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
|
||||||
|
|||||||
+146
-38
@@ -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,21 +3888,39 @@ 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="inline-flex items-baseline gap-1">
|
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</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={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</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>
|
||||||
</span>
|
</div>
|
||||||
<span className="inline-flex items-baseline gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
|
||||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</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>
|
<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>
|
||||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</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>
|
</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}
|
||||||
<span>
|
{gridFilteredCount != null ? (
|
||||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
<span>
|
||||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
||||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
<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{' '}
|
||||||
|
<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>
|
||||||
<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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user