diff --git a/app.go b/app.go index 0801f1c..79d069a 100644 --- a/app.go +++ b/app.go @@ -312,6 +312,7 @@ const ( keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots keyClusterSpotTTL = "cluster.spot_ttl_min" // drop spots older than this; 0 = keep them + keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on @@ -20294,6 +20295,50 @@ func (a *App) SetKenwoodKeySpeed(wpm int) error { // and exists only so a mistyped value cannot mean "never". const spotTTLMax = 720 +// The spot list is a ring buffer, and its size decided the lifetime far more +// often than the lifetime setting did: on a busy evening a thousand spots +// arrive in a couple of minutes, so a fifteen-minute lifetime never got the +// chance to expire anything. Hence a setting. +// +// The ceiling is a real limit, not a round number. Every spot is matched against +// the worked index and the alert rules, and each one is a row the cluster grid +// and every open band map re-render; past ten thousand that work starts to show +// on the very evenings the list is worth having. +const ( + spotMaxDefault = 1000 + spotMaxCeiling = 10000 + spotMaxFloor = 100 +) + +// GetSpotMax returns how many spots the list holds. +func (a *App) GetSpotMax() int { + n, _ := strconv.Atoi(a.settingOr(keyClusterSpotMax, "")) + if n <= 0 { + return spotMaxDefault + } + return clampSpotMax(n) +} + +// SetSpotMax sets it. Clamped here as well as in the UI, for the same reason as +// the lifetime: the value decides how much work arrives on every spot, and a +// stale frontend must not be able to widen it past the ceiling. +func (a *App) SetSpotMax(n int) error { + a.setSetting(keyClusterSpotMax, strconv.Itoa(clampSpotMax(n))) + return nil +} + +func clampSpotMax(n int) int { + if n < spotMaxFloor { + return spotMaxFloor + } + if n > spotMaxCeiling { + return spotMaxCeiling + } + return n +} + + + // 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. diff --git a/changelog.json b/changelog.json index bfb8aea..69870f3 100644 --- a/changelog.json +++ b/changelog.json @@ -11,7 +11,8 @@ "Yaesu console: the NAR button (narrow IF filter) is shown only when the radio answers the command, and says what it is. A button that did nothing when pressed read as a fault in the rig.", "The frequency readout now steps the Hz digits under the mouse wheel too, not just the kHz ones — 100, 10 and 1 Hz. Zero-beating a CW signal is a few tens of Hz, and it was the one move the display would not make.", "The split-pile-up chaser only appears in CW: the marker comes from a skimmer decoding a report, so on SSB or FT8 there is nothing for it to chase.", - "Cluster: a single click now only fills the callsign, and a DOUBLE click works the spot — QSY, mode and the rest. Running down the list used to drag the radio along with every line looked at." + "Cluster: a single click now only fills the callsign, and a DOUBLE click works the spot — QSY, mode and the rest. Running down the list used to drag the radio along with every line looked at.", + "Cluster: how many spots the list keeps is now a setting (Preferences → Cluster, beside the spot lifetime). It was fixed at a thousand, and on a busy evening that fills in a couple of minutes — so the cap, not the lifetime, decided when a spot disappeared and a 15-minute lifetime never expired anything." ], "fr": [ "Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.", @@ -22,7 +23,8 @@ "Console Yaesu : le bouton NAR (filtre FI étroit) n'apparaît que si la radio répond à la commande, et indique ce qu'il fait. Un bouton sans effet passait pour une panne de la radio.", "L'affichage de fréquence fait maintenant défiler aussi les chiffres des Hz à la molette, pas seulement ceux des kHz — 100, 10 et 1 Hz. Se caler au zéro-beat sur un signal CW se joue à quelques dizaines de Hz, et c'était le seul geste que l'affichage refusait.", "Le chasseur de pile-up en split n'apparaît qu'en CW : le marqueur vient d'un skimmer qui décode un report, donc en SSB ou en FT8 il n'a rien à chasser.", - "Cluster : un clic simple ne remplit plus que l'indicatif, et le DOUBLE clic travaille le spot — QSY, mode et le reste. Parcourir la liste entraînait la radio à chaque ligne regardée." + "Cluster : un clic simple ne remplit plus que l'indicatif, et le DOUBLE clic travaille le spot — QSY, mode et le reste. Parcourir la liste entraînait la radio à chaque ligne regardée.", + "Cluster : le nombre de spots conservés devient un réglage (Préférences → Cluster, à côté de la durée de vie). Il était fixé à mille, et un soir chargé remplit ça en quelques minutes — c'était donc le plafond, et non la durée de vie, qui décidait de la disparition d'un spot, et une durée de 15 minutes n'expirait jamais rien." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f411797..8bd2f11 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -95,7 +95,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; -import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App'; +import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App'; import { applyMatrixColors } from '@/lib/matrixColors'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; @@ -1663,7 +1663,14 @@ export default function App() { const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]); // Ring buffer — only keep the last N spots; cluster firehose can be heavy. const [spots, setSpots] = useState([]); - const SPOTS_CAP = 1000; + // How many spots the list holds. A setting rather than a constant: at a + // thousand, a busy evening filled the buffer in a couple of minutes, so the + // spot LIFETIME never had anything left to expire — the cap was deciding the + // lifetime. Kept in a ref as well: the append path runs inside a state updater + // that must not close over a stale value. + const [spotsCap, setSpotsCap] = useState(1000); + const spotsCapRef = useRef(1000); + useEffect(() => { spotsCapRef.current = spotsCap; }, [spotsCap]); // Cluster filter selections persist across restarts (writeUiPref → localStorage // + DB, so they also travel with a copied data/ folder). Loaders read the cache // synchronously at first render; a single effect below writes them back. @@ -1977,7 +1984,7 @@ export default function App() { useEffect(() => { setSpotStatus((prev) => { const keys = Object.keys(prev); - if (keys.length <= SPOTS_CAP * 2) return prev; + if (keys.length <= spotsCapRef.current * 2) return prev; const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz))); // Decoded stations count as live too. They share this cache, and pruning // to the cluster spots alone would evict every one of them — on a busy @@ -2488,6 +2495,9 @@ export default function App() { // same thing everywhere — and it gives the memory back. const [spotTTLMin, setSpotTTLMin] = useState(0); useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]); + // Same beat as the lifetime: re-read when Preferences closes, since the two + // settings decide between them how long a spot survives. + useEffect(() => { GetSpotMax().then((n: number) => { if (n > 0) setSpotsCap(n); }).catch(() => {}); }, [showSettings]); useEffect(() => { if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out const sweep = () => { @@ -3478,7 +3488,8 @@ export default function App() { const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k); next = [sp, ...filtered]; } - return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next; + const cap = spotsCapRef.current; + return next.length > cap ? next.slice(0, cap) : next; }); }; const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c3c598b..c9437ef 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -58,7 +58,7 @@ import { GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, - GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, + GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -1942,6 +1942,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [chaseNew, setChaseNew] = useState(false); const [spotTTL, setSpotTTL] = useState(0); const [spotTTLText, setSpotTTLText] = useState('0'); + const [spotMaxText, setSpotMaxText] = useState('1000'); const [gridStat, setGridStat] = useState(null); const [pskrStatus, setPskrStatus] = useState(null); const saveBandOpen = async (next: any) => { @@ -1955,6 +1956,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } + try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ } })(); // Poll the feed while the panel is open: a live count is the only thing that // distinguishes "connected" from "connected and receiving nothing". @@ -4973,6 +4975,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan }} /> {t('clu.spotTtlHint')} + {/* The list size, beside the lifetime because between them they decide + the same thing: whichever runs out first removes the spot. */} +
+ {t('clu.spotMax')} +
+ {[500, 1000, 2500, 5000].map((v) => ( + + ))} +
+ { + const raw = e.target.value.replace(/[^0-9]/g, ''); + setSpotMaxText(raw); + const n = parseInt(raw, 10); + if (Number.isFinite(n) && n > 0) SetSpotMax(Math.min(10000, n)).catch(() => {}); + }} /> + {t('clu.spotMaxHint')} +
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 182df1a..92634c0 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -313,7 +313,7 @@ const en: Dict = { 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.', 'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.', - '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', + '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.spotMax': 'Spots kept', 'clu.spotMaxHint': 'The list is a ring buffer: past this, the oldest spot drops out. It is usually this, not the lifetime, that decides — a thousand spots arrive in a few minutes on a busy evening.', '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.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.', 'clu.macroLabel': 'Button', 'clu.macroCmd': 'Command', '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.', @@ -795,7 +795,7 @@ const fr: Dict = { 'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille n’a plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.', 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.', - 'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotTtlHint': 'minutes — les spots plus vieux que ça sont retirés de la liste et des bandmaps. 0 les conserve.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture', + 'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotMax': 'Nombre de spots conservés', 'clu.spotMaxHint': "La liste est un tampon circulaire : au-delà, le plus ancien sort. C'est souvent lui, et non la durée de vie, qui décide — mille spots arrivent en quelques minutes un soir chargé.", 'clu.spotTtlHint': 'minutes — les spots plus vieux que ça sont retirés de la liste et des bandmaps. 0 les conserve.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture', 'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 62ecd01..b1e83cb 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -563,6 +563,8 @@ export function GetSolarData():Promise; export function GetSpotColors():Promise; +export function GetSpotMax():Promise; + export function GetSpotTTLMinutes():Promise; export function GetStartupStatus():Promise; @@ -1149,6 +1151,8 @@ export function SetPassphrase(arg1:string):Promise; export function SetScpEnabled(arg1:boolean):Promise; +export function SetSpotMax(arg1:number):Promise; + export function SetSpotTTLMinutes(arg1:number):Promise; export function SetTelemetryEnabled(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3052e5d..f67b24d 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1066,6 +1066,10 @@ export function GetSpotColors() { return window['go']['main']['App']['GetSpotColors'](); } +export function GetSpotMax() { + return window['go']['main']['App']['GetSpotMax'](); +} + export function GetSpotTTLMinutes() { return window['go']['main']['App']['GetSpotTTLMinutes'](); } @@ -2238,6 +2242,10 @@ export function SetScpEnabled(arg1) { return window['go']['main']['App']['SetScpEnabled'](arg1); } +export function SetSpotMax(arg1) { + return window['go']['main']['App']['SetSpotMax'](arg1); +} + export function SetSpotTTLMinutes(arg1) { return window['go']['main']['App']['SetSpotTTLMinutes'](arg1); }