Compare commits

...
4 Commits
Author SHA1 Message Date
rouggy 56c103140c chore: release v0.26.11 2026-08-24 11:02:12 +02:00
rouggy b60e8285a4 chore(changelog): open the 0.26.11 block
The Club Log pacing and the auto-call disarming were written after
v0.26.10 was tagged (8b7756b), so they were not in it.
2026-08-24 10:02:12 +02:00
rouggy 0f1424335e fix(autocall): disarm the stored preference, not just the loop
Withdrawing auto-call added a runtime guard and removed every control.
The stored preference was left alone, so 'enabled: true' still sits in
localStorage on the machines that had it on -- and any build without the
guard reads it and keys the transmitter for a feature that no longer has
a switch anywhere in the interface.

loadAutoCall now switches it off and writes it back the first time it
reads it, so the disarming survives a downgrade or a reinstall. Also
drops the dead auto-call state left behind in the settings modal.

A withdrawn feature that keys a radio has to be disarmed where it is
remembered, not only where it runs.
2026-08-24 09:56:31 +02:00
rouggy 506a1e6e4c fix(clublog): pace and cap the real-time deletes
Club Log wrote to this station about 167 requests in four minutes and a
coming IP block. That was one deletion of a couple of hundred rows going
through delete.php, which is a REAL-TIME endpoint: it exists for an
operator removing a contact they just logged wrongly, at human pace.

Today's earlier fix stops the requests that were pointless -- QSOs Club
Log never had. This one handles the rest, which are legitimate but still
a batch: one delete every 1.2 s, and a stop at 25 with a line saying to
finish on clublog.org. There is no bulk-delete API to move to, so going
slower and refusing to push a large deletion through a real-time endpoint
is the whole of the answer.

Costs the operator nothing: the withdrawals already run in the
background.
2026-08-23 19:46:42 +02:00
6 changed files with 55 additions and 5 deletions
+27
View File
@@ -6510,6 +6510,21 @@ func (a *App) deleteRemoteCopies(ids []int64) {
// withdrawals stop. Three is enough to tell a one-off from a blocked account. // withdrawals stop. Three is enough to tell a one-off from a blocked account.
const maxClublogRefusals = 3 const maxClublogRefusals = 3
// Club Log's delete endpoint is a REAL-TIME one: it exists for an operator
// removing a contact they just logged wrongly, at human pace. Club Log watches
// the rate and blocks the IP of anything that batches through it — they wrote
// to this station about 167 requests in four minutes, which was one deletion of
// a couple of hundred rows, not a pile-up.
//
// There is no bulk-delete API to move to, so the only honest answer is to go at
// the pace the endpoint is meant for and to stop rather than push a large
// deletion through it. Whoever needs to remove hundreds of QSOs from Club Log
// does it on their site, where the tool for it exists.
const (
clublogDeletePace = 1200 * time.Millisecond
maxClublogDeletes = 25
)
// clublogWasUploaded reports whether this QSO ever reached Club Log. "M" // clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
// (modified since upload) counts: the copy is there, it is merely out of date. // (modified since upload) counts: the copy is there, it is merely out of date.
func clublogWasUploaded(status string) bool { func clublogWasUploaded(status string) bool {
@@ -6523,6 +6538,7 @@ func clublogWasUploaded(status string) bool {
func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) { func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
started := time.Now() started := time.Now()
clublogRefused := 0 clublogRefused := 0
clublogSent := 0
for i := range rows { for i := range rows {
q := rows[i] q := rows[i]
id := q.ID id := q.ID
@@ -6553,6 +6569,17 @@ func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices,
if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) { if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign) applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
} else if doClublog { } else if doClublog {
if clublogSent >= maxClublogDeletes {
applog.Printf("extsvc: %d QSOs already withdrawn from Club Log — stopping there. Their delete endpoint is for one contact at a time; remove the rest on clublog.org, which has a tool for it.", clublogSent)
doClublog = false
continue
}
if clublogSent > 0 {
// Paced deliberately: see clublogDeletePace. This runs in the
// background, so the wait costs the operator nothing.
time.Sleep(clublogDeletePace)
}
clublogSent++
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil { if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
clublogRefused++ clublogRefused++
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err) applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
+12
View File
@@ -1,4 +1,16 @@
[ [
{
"version": "0.26.11",
"date": "",
"en": [
"Withdrawing deleted QSOs from Club Log is now paced and capped at 25 per deletion. Their delete endpoint is a real-time one, meant for an operator removing a contact they just mis-logged; Club Log watches the rate and blocks the IP of anything that batches through it. Past the cap OpsLog stops and says so — there is no bulk-delete API, and hundreds of removals belong on clublog.org, which has a tool for it.",
"Auto-call, withdrawn earlier, is now disarmed where it was remembered: a stored 'enabled' is switched off and written back the first time OpsLog reads it. The running guard already stopped this build from calling anyone, but the stored flag survived — and any build without that guard would key the transmitter for a feature with no switch left to turn it off."
],
"fr": [
"Le retrait des QSO supprimés chez Club Log est désormais cadencé et limité à 25 par suppression. Leur point d'entrée de suppression est temps réel, prévu pour un opérateur qui retire un contact qu'il vient de mal enregistrer ; Club Log surveille le rythme et bloque l'IP de ce qui passe des lots par là. Au-delà de la limite, OpsLog s'arrête et le dit — il n'existe pas d'API de suppression en masse, et des centaines de retraits se font sur clublog.org, qui a l'outil pour ça.",
"L'appel automatique, retiré précédemment, est maintenant désarmé là où il était mémorisé : un « activé » enregistré est éteint et réécrit dès la première lecture par OpsLog. Le garde-fou à l'exécution empêchait déjà cette version d'appeler qui que ce soit, mais l'indicateur enregistré survivait — et toute version sans ce garde-fou passait à l'émission pour une fonction dont il ne reste aucun interrupteur."
]
},
{ {
"version": "0.26.10", "version": "0.26.10",
"date": "", "date": "",
@@ -80,7 +80,6 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { loadAutoCall, autoCallKey, type AutoCallSettings, type AutoCallCriteria } from '@/lib/autocall';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'; import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
@@ -1933,7 +1932,6 @@ 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: [] });
// How a worked square is matched, and what counts as still wanted. // How a worked square is matched, and what counts as still wanted.
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] }); const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []); useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
const saveGridScope = async (next: any) => { const saveGridScope = async (next: any) => {
+14 -1
View File
@@ -72,13 +72,26 @@ export function loadAutoCall(): AutoCallSettings {
const raw = localStorage.getItem(AC_KEY); const raw = localStorage.getItem(AC_KEY);
if (!raw) return { ...defaultAutoCall }; if (!raw) return { ...defaultAutoCall };
const v = JSON.parse(raw); const v = JSON.parse(raw);
return { const out: AutoCallSettings = {
...defaultAutoCall, ...defaultAutoCall,
...v, ...v,
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) }, criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) }, watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
watch: Array.isArray(v?.watch) ? v.watch : [], watch: Array.isArray(v?.watch) ? v.watch : [],
}; };
// DISARMED ON SIGHT, and written back disabled.
//
// The runtime guard in App.tsx stops this build from calling anyone, but it
// leaves "enabled": true sitting in storage, where any build without the
// guard — an older one an operator reinstalls, a machine that upgrades
// later — reads it and keys the transmitter for a feature with no switch
// left to turn off. A withdrawn feature that keys a radio has to be
// disarmed where it is REMEMBERED, not only where it runs.
if (out.enabled) {
out.enabled = false;
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
}
return out;
} catch { return { ...defaultAutoCall }; } } catch { return { ...defaultAutoCall }; }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.10'; export const APP_VERSION = '0.26.11';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.10" appVersion = "0.26.11"
// 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.