feat(cluster): configurable spot lifetime
Spots were bounded by COUNT alone, so on a quiet band a two-hour-old spot sat on the band map looking like something to chase. Settings → DX Cluster now takes a lifetime: presets at 5/10/15/30/60 minutes, or any value up to twelve hours, and 0 keeps the old behaviour. Spots are REMOVED from the shared list rather than hidden at render. The cluster list, every band map and the counts all read that one array, so pruning it once is what makes the setting mean the same thing in every view — the lesson already paid for when the band map ignored the cluster's filters. It also gives the memory back. A spot whose timestamp will not parse is never dropped: an unreadable stamp is a reason to distrust the clock, not to throw away the spot. Swept every 30 seconds — close enough that a 5-minute setting is honoured, and invisible next to the spot stream. Clamped in the backend as well as the UI.
This commit is contained in:
@@ -298,6 +298,7 @@ const (
|
|||||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||||
keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log
|
keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log
|
||||||
keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots
|
keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots
|
||||||
|
keyClusterSpotTTL = "cluster.spot_ttl_min" // drop spots older than this; 0 = keep them
|
||||||
|
|
||||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||||
|
|
||||||
@@ -17728,3 +17729,35 @@ func (a *App) SetKenwoodKeySpeed(wpm int) error {
|
|||||||
}
|
}
|
||||||
return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) })
|
return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spotTTLMax bounds the setting. Twelve hours is far past any operational use
|
||||||
|
// and exists only so a mistyped value cannot mean "never".
|
||||||
|
const spotTTLMax = 720
|
||||||
|
|
||||||
|
// GetSpotTTLMinutes returns how long a spot stays in the list, in minutes.
|
||||||
|
// 0 means spots are kept until the count cap pushes them out, which is what
|
||||||
|
// OpsLog always did.
|
||||||
|
func (a *App) GetSpotTTLMinutes() int {
|
||||||
|
n, _ := strconv.Atoi(a.settingOr(keyClusterSpotTTL, ""))
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if n > spotTTLMax {
|
||||||
|
return spotTTLMax
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpotTTLMinutes sets it. Clamped here as well as in the UI: the value
|
||||||
|
// shapes what an operator sees on the band map, and a stale or bypassed
|
||||||
|
// frontend must not be able to widen it past the ceiling.
|
||||||
|
func (a *App) SetSpotTTLMinutes(min int) error {
|
||||||
|
if min < 0 {
|
||||||
|
min = 0
|
||||||
|
}
|
||||||
|
if min > spotTTLMax {
|
||||||
|
min = spotTTLMax
|
||||||
|
}
|
||||||
|
a.setSetting(keyClusterSpotTTL, strconv.Itoa(min))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -3,10 +3,12 @@
|
|||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked."
|
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.",
|
||||||
|
"DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite."
|
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.",
|
||||||
|
"Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+26
-1
@@ -91,7 +91,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
import { GetRowColors } from '../wailsjs/go/main/App';
|
import { GetRowColors, GetSpotTTLMinutes } from '../wailsjs/go/main/App';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||||
@@ -1928,6 +1928,31 @@ export default function App() {
|
|||||||
// settings dialog closes, which is the only place it changes.
|
// settings dialog closes, which is the only place it changes.
|
||||||
const [rowColors, setRowColors] = useState<any>(null);
|
const [rowColors, setRowColors] = useState<any>(null);
|
||||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||||
|
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
|
||||||
|
// than filtered at render: the cluster list, every band map and the counts all
|
||||||
|
// read the same array, so pruning it once is what makes the setting mean the
|
||||||
|
// same thing everywhere — and it gives the memory back.
|
||||||
|
const [spotTTLMin, setSpotTTLMin] = useState(0);
|
||||||
|
useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out
|
||||||
|
const sweep = () => {
|
||||||
|
const cutoff = Date.now() - spotTTLMin * 60_000;
|
||||||
|
setSpots((arr) => {
|
||||||
|
const kept = arr.filter((sp) => {
|
||||||
|
const t = Date.parse((sp as any).received_at ?? '');
|
||||||
|
return isNaN(t) || t >= cutoff; // an unparseable stamp is never a reason to drop a spot
|
||||||
|
});
|
||||||
|
return kept.length === arr.length ? arr : kept;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
sweep();
|
||||||
|
// Half a minute: fine enough that a 5-minute setting is honoured closely,
|
||||||
|
// coarse enough to be invisible next to the spot stream itself.
|
||||||
|
const id = window.setInterval(sweep, 30_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [spotTTLMin]);
|
||||||
|
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||||
[qsos],
|
[qsos],
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus,
|
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -1556,6 +1556,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// feed up or down — so the write has to go where those live.
|
// feed up or down — so the write has to go where those live.
|
||||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||||
const [chaseGrids, setChaseGrids] = useState(false);
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
|
const [spotTTL, setSpotTTL] = useState(0);
|
||||||
|
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||||
const [gridStat, setGridStat] = useState<any>(null);
|
const [gridStat, setGridStat] = useState<any>(null);
|
||||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||||
const saveBandOpen = async (next: any) => {
|
const saveBandOpen = async (next: any) => {
|
||||||
@@ -1566,6 +1568,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||||
|
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||||
})();
|
})();
|
||||||
// Poll the feed while the panel is open: a live count is the only thing that
|
// Poll the feed while the panel is open: a live count is the only thing that
|
||||||
// distinguishes "connected" from "connected and receiving nothing".
|
// distinguishes "connected" from "connected and receiving nothing".
|
||||||
@@ -4237,6 +4240,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{/* Grid chasing. Here rather than in the filter panel because it is set
|
{/* Grid chasing. Here rather than in the filter panel because it is set
|
||||||
up once: it decides whether locators learnt from decodes are KEPT
|
up once: it decides whether locators learnt from decodes are KEPT
|
||||||
across restarts, not what the list shows right now. */}
|
across restarts, not what the list shows right now. */}
|
||||||
|
{/* Spot lifetime. Applies to the cluster list AND every band map: the
|
||||||
|
spots are removed from the shared list rather than hidden, so the
|
||||||
|
setting cannot mean one thing in one view and another elsewhere. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm">{t('clu.spotTtl')}</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{[0, 5, 10, 15, 30, 60].map((v) => (
|
||||||
|
<button key={v} type="button"
|
||||||
|
onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
|
||||||
|
className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{v === 0 ? t('clu.spotTtlNever') : `${v}′`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Raw text in local state: binding the input straight to the
|
||||||
|
clamped number makes an empty field impossible to type into. */}
|
||||||
|
<Input className="h-8 w-20" value={spotTTLText}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
setSpotTTLText(raw);
|
||||||
|
const n = Math.min(720, parseInt(raw, 10) || 0);
|
||||||
|
setSpotTTL(n);
|
||||||
|
SetSpotTTLMinutes(n).catch(() => {});
|
||||||
|
}} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ const en: Dict = {
|
|||||||
'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)',
|
'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)',
|
||||||
'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
|
'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
|
||||||
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
|
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
|
||||||
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
|
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotTtlHint': 'minutes — spots older than this are removed from the list and the band maps. 0 keeps them.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||||
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
|
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
|
||||||
// Backup panel
|
// Backup panel
|
||||||
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
||||||
|
|||||||
Vendored
+4
@@ -506,6 +506,8 @@ export function GetSlotStats():Promise<qso.SlotStats>;
|
|||||||
|
|
||||||
export function GetSolarData():Promise<solar.Data>;
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
|
export function GetSpotTTLMinutes():Promise<number>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
@@ -1000,6 +1002,8 @@ export function SetPassphrase(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|||||||
@@ -954,6 +954,10 @@ export function GetSolarData() {
|
|||||||
return window['go']['main']['App']['GetSolarData']();
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSpotTTLMinutes() {
|
||||||
|
return window['go']['main']['App']['GetSpotTTLMinutes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -1942,6 +1946,10 @@ export function SetScpEnabled(arg1) {
|
|||||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetSpotTTLMinutes(arg1) {
|
||||||
|
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1) {
|
export function SetTelemetryEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user