From 3ed9f29d9aecabd6259c89b3b1dd1424bc8ca6c3 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 18 Jul 2026 18:20:06 +0200 Subject: [PATCH] feat: never drop cluster spots + theme-aware spot badges + FR settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: replace the bounded cluster-event channel (which dropped spots under RBN bursts once enrichment/UI fell behind) with an unbounded clusterQueue (slice + sync.Cond). The socket-read goroutine still never blocks, but a burst now grows the backlog and drains instead of losing spots. Head compaction keeps a sustained burst from growing the backing array without bound. ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country, Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so they read correctly on every theme. Replace the heavy full-cell band/mode fills with small rounded pills (cellChip). Only NEW MODE pills the mode cell — NEW SLOT (band and mode each worked, just not together) no longer highlights the mode cell, which wrongly implied the mode was new. App: stage incoming spots ~50ms and resolve their slot status before inserting, so a row paints with its badge already on instead of flashing plain text then flipping to a pill. Self-spot toast still fires per spot. i18n: translate the General settings that were still English — telemetry and live-status toggles, the Main view pane pickers and their options. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.go | 110 ++++++++++++++++++---- frontend/src/App.tsx | 81 +++++++++++++++- frontend/src/components/ClusterGrid.tsx | 56 ++++++----- frontend/src/components/SettingsModal.tsx | 37 ++++---- frontend/src/lib/i18n.tsx | 22 +++++ 5 files changed, 240 insertions(+), 66 deletions(-) diff --git a/app.go b/app.go index 8b71bfe..af3d8fc 100644 --- a/app.go +++ b/app.go @@ -435,8 +435,8 @@ type App struct { // to run inline in the read loop, so a single slow step stopped draining the // TCP socket and the whole feed fell behind the node (visible as the grid // lagging telnet). The read loop now just enqueues here; one worker does the work. - clusterEventCh chan clusterEvent - clusterDropped int64 // spots/lines dropped when the queue was full (atomic) + // Unbounded FIFO: a burst grows the backlog instead of dropping spots. + clusterEvents *clusterQueue // wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never // queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL). // Loaded once, appended to on each log, rebuilt after bulk changes. @@ -906,10 +906,11 @@ func (a *App) startup(ctx context.Context) { // renders the row with all metadata already filled (no flicker of // empty Country / Cont columns while the batch status fetch runs). // Cluster events are processed OFF the socket-read goroutine (see clusterEvent / - // clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100 - // burst never fill it; a full queue drops-and-counts rather than block the read - // loop, which was the actual cause of the feed falling behind telnet. - a.clusterEventCh = make(chan clusterEvent, 8192) + // clusterEventWorker). The queue is UNBOUNDED so no spot is ever dropped: an + // SH/DX or RBN burst grows the backlog and drains once enrichment catches up, + // while the read loop still never blocks (that was the cause of the feed falling + // behind telnet). + a.clusterEvents = newClusterQueue() go a.clusterEventWorker() a.cluster = cluster.NewManager( @@ -5902,20 +5903,85 @@ type clusterEvent struct { line *cluster.Line } -// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on -// the cluster session's socket-read goroutine: blocking here would stop draining -// the TCP socket, the node's send buffer would fill, and the feed would fall -// behind — the exact bug this indirection fixes. If the queue is full (processing -// can't keep up), drop and count rather than stall the whole feed. -func (a *App) enqueueClusterEvent(ev clusterEvent) { - select { - case a.clusterEventCh <- ev: - default: - n := atomic.AddInt64(&a.clusterDropped, 1) - if n == 1 || n%100 == 0 { - applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n) - } +// clusterQueue is an unbounded FIFO between the socket-read goroutines (producers, +// which must never block or the TCP feed stalls) and the single clusterEventWorker +// (consumer). Unlike a bounded channel it NEVER drops an event: a burst just grows +// the backlog, which drains once enrichment catches up. +type clusterQueue struct { + mu sync.Mutex + cond *sync.Cond + buf []clusterEvent + head int // index of the next event to pop (waste reclaimed by compaction) + closed bool +} + +func newClusterQueue() *clusterQueue { + q := &clusterQueue{} + q.cond = sync.NewCond(&q.mu) + return q +} + +// push appends an event and wakes the worker. Amortised O(1); never blocks, never +// drops. +func (q *clusterQueue) push(ev clusterEvent) { + q.mu.Lock() + if q.closed { + q.mu.Unlock() + return } + q.buf = append(q.buf, ev) + q.mu.Unlock() + q.cond.Signal() +} + +// pop blocks until an event is available, returning ok=false only once the queue +// is closed AND fully drained. +func (q *clusterQueue) pop() (clusterEvent, bool) { + q.mu.Lock() + for q.head == len(q.buf) && !q.closed { + q.cond.Wait() + } + if q.head == len(q.buf) { // closed and drained + q.mu.Unlock() + return clusterEvent{}, false + } + ev := q.buf[q.head] + q.buf[q.head] = clusterEvent{} // release pointers held by the consumed slot + q.head++ + switch { + case q.head == len(q.buf): + // Fully drained → reset to reuse the backing array from the front. + q.buf = q.buf[:0] + q.head = 0 + case q.head > 1024 && q.head*2 >= len(q.buf): + // Head waste is large → compact so a long sustained burst doesn't grow + // the backing array without bound. + n := copy(q.buf, q.buf[q.head:]) + for i := n; i < len(q.buf); i++ { + q.buf[i] = clusterEvent{} + } + q.buf = q.buf[:n] + q.head = 0 + } + q.mu.Unlock() + return ev, true +} + +// close wakes any waiting consumer so it can exit once the backlog is drained. +func (q *clusterQueue) close() { + q.mu.Lock() + q.closed = true + q.mu.Unlock() + q.cond.Broadcast() +} + +// enqueueClusterEvent hands an event to the worker WITHOUT blocking and WITHOUT +// dropping. It runs on the cluster session's socket-read goroutine: blocking here +// would stop draining the TCP socket, the node's send buffer would fill, and the +// feed would fall behind — the exact bug this indirection fixes. The queue is +// unbounded, so a burst grows the backlog rather than losing a spot. +func (a *App) enqueueClusterEvent(ev clusterEvent) { + a.clusterEvents.push(ev) } // clusterEventWorker drains clusterEventCh and does everything that used to run @@ -5923,7 +5989,11 @@ func (a *App) enqueueClusterEvent(ev clusterEvent) { // to the UI, run alert rules (which may query a remote MySQL) and mirror it to the // Flex panadapter — all serialised on this one goroutine, off the read path. func (a *App) clusterEventWorker() { - for ev := range a.clusterEventCh { + for { + ev, ok := a.clusterEvents.pop() + if !ok { + return + } if ev.line != nil { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f787923..4fb44fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1064,6 +1064,15 @@ export default function App() { // Keyed by `${call}|${band}|${mode}` so two spots of the same call on // different slots don't share the same colour. const [spotStatus, setSpotStatus] = useState>({}); + // Live mirror of spotStatus so the incoming-spot buffer can tell which slots + // still need resolving without re-subscribing the cluster:spot listener. + const spotStatusRef = useRef(spotStatus); + useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]); + // Incoming spots are staged here for a few ms, their status resolved, then + // committed to `spots` together — so a row paints with its NEW BAND/MODE badge + // already on, instead of flashing plain text then flipping to the pill. + const pendingSpotsRef = useRef([]); + const pendingSpotTimer = useRef(undefined); // === Modals === const [editingQSO, setEditingQSO] = useState(null); @@ -1740,13 +1749,76 @@ export default function App() { const activeIds = new Set((sts ?? []).map((s) => s.server_id)); setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id))); }); - const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { + // Commit the staged spots: resolve the status for any slot we don't know yet + // FIRST, then insert the rows — so they appear with the right badge already + // painted instead of flashing plain text then flipping to a pill. + const flushPendingSpots = async () => { + pendingSpotTimer.current = undefined; + const batch = pendingSpotsRef.current; + pendingSpotsRef.current = []; + if (batch.length === 0) return; + // Resolve unknown statuses before the rows go in. + try { + const known = spotStatusRef.current; + const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = []; + const seen = new Set(); + for (const s of batch) { + const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); + if (seen.has(k) || known[k]) continue; + seen.add(k); + unknown.push({ + call: s.dx_call, band: s.band ?? '', + mode: inferSpotMode(s.comment ?? '', s.freq_hz), + pota_ref: (s as any).pota_ref ?? '', + }); + } + if (unknown.length > 0) { + const res = await ClusterSpotStatuses(unknown as any); + setSpotStatus((prev) => { + const next = { ...prev }; + for (const r of res) { + const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; + next[k] = { + status: r.status ?? '', + country: r.country, + continent: (r as any).continent, + worked_call: !!(r as any).worked_call, + new_county: !!(r as any).new_county, + new_pota: !!(r as any).new_pota, + }; + } + return next; + }); + } + } catch {} + // Now insert the staged rows. Same de-dupe as before: a station re-spotted + // (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that + // the freshest spot REPLACES and floats to the top. Historical (SH/DX + // replay) rows are left alone. setSpots((arr) => { - const next = [sp, ...arr]; + const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`; + const hist = (x: ClusterSpot) => !!(x as any).historical; + let next = arr; + for (const sp of batch) { + const k = key(sp); + 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 unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { + // Stage the spot; a short timer resolves its status then commits it. + pendingSpotsRef.current.push(sp); + // 50 ms is enough to coalesce an RBN burst into one status lookup (the + // worked-index is in memory, so resolving is near-instant) while staying + // imperceptible. + if (pendingSpotTimer.current === undefined) { + pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50); + } // Self-spot: someone spotted OUR callsign — show it in the shared header // toast (same place as the other notifications), not a separate banner. + // Fired immediately (not staged) so the notification never lags the spot. const mine = myCallRef.current; if (mine && (sp.dx_call ?? '').toUpperCase() === mine) { const by = cleanSpotter(sp.spotter ?? '') || '?'; @@ -1754,7 +1826,10 @@ export default function App() { showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`); } }); - return () => { unsubState?.(); unsubSpot?.(); }; + return () => { + unsubState?.(); unsubSpot?.(); + if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index 7b7a781..44c4165 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -113,6 +113,23 @@ function tok(name: string, text: string): Badge { return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` }; } +// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the +// Status badges) when the slot is notable, instead of flooding the whole cell with +// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is +// null → plain text, no pill. +function cellChip(value: any, name: string | null): any { + const txt = value === undefined || value === null || value === '' ? '' : String(value); + if (!name) return txt || ; + return ( + {txt} + ); +} + function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null { switch (s?.status) { case 'new': return tok('danger', t('clg2.newDxcc')); @@ -129,7 +146,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono', defaultVisible: true, sort: 'desc', - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.call'), colId: 'call', @@ -204,7 +221,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ group: 'Spot', label: t('clg2.c.pota'), colId: 'pota', headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono', defaultVisible: true, - cellStyle: { color: '#166534' }, + cellStyle: { color: 'var(--success)' }, tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined), }, { @@ -219,10 +236,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ headerName: t('clg2.c.band'), field: 'band' as any, width: 75, defaultVisible: true, cellClass: 'font-mono', - // NEW BAND for this entity → fill the cell (keeps the band text aligned). - cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' - ? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 } - : undefined), + // NEW BAND for this entity → small warning pill around the band text. + cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null), tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined), }, { @@ -231,16 +246,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ defaultVisible: true, cellClass: 'font-mono', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', - // Fill the mode cell: teal = NEW MODE (mode never worked on this entity), - // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere). - cellStyle: (p: any) => { - const st = statusFor(p)?.status; - // Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the - // Status badge text tells them apart. - if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }; - return undefined; - }, - cellRenderer: (p: any) => p.value ? p.value : , + // Only NEW MODE pills the mode cell — there the mode itself is genuinely new + // for the entity. NEW SLOT means band AND mode were each worked before (just + // not together), so highlighting the mode cell would wrongly imply "CW is new"; + // that case is signalled by the Status badge alone. + cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null), tooltipValueGetter: (p: any) => { const st = statusFor(p)?.status; if (st === 'new-mode') return t('clg2.tipNewMode'); @@ -252,7 +262,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx', headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono', valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''), - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz', @@ -289,7 +299,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[ spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) ]?.country ?? '', - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.continent'), colId: 'continent', @@ -298,31 +308,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[ spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) ]?.continent ?? '', - cellStyle: { color: '#7a6b50', fontSize: 10 }, + cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 }, }, { group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter', headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono', defaultVisible: true, valueFormatter: (p) => cleanSpotter(p.value ?? ''), - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.source'), colId: 'source', headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100, defaultVisible: true, - cellStyle: { color: '#9a8870', fontSize: 10 }, + cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 }, }, { group: 'Spot', label: t('clg2.c.locator'), colId: 'locator', headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono', - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.comment'), colId: 'comment', headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true, - cellStyle: { color: '#7a6b50' }, + cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at', diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index ec751c2..e50aa63 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -540,6 +540,7 @@ function AutostartPanelComponent() { // (a random install ID + version + OS, sent once a day). Real component so it // can own its state; embedded inside GeneralPanel. function TelemetryToggle() { + const { t } = useI18n(); const [on, setOn] = useState(true); const [loaded, setLoaded] = useState(false); useEffect(() => { @@ -549,8 +550,8 @@ function TelemetryToggle() { ); } @@ -560,6 +561,7 @@ function TelemetryToggle() { // events — a small web script on your server renders it for the QRZ page. Only // useful on a MySQL logbook. Self-contained component (owns its async state). function LiveStatusToggle() { + const { t } = useI18n(); const [on, setOn] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { @@ -569,7 +571,7 @@ function LiveStatusToggle() { ); } @@ -578,25 +580,20 @@ function LiveStatusToggle() { // panes show, independently: the great-circle map, the locator street map, the // cluster grid or the worked-before grid. Per-profile (stored via SetUIPref, // which is profile-prefixed). Self-contained so it owns its async-loaded state. -const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [ - { value: 'map1', label: 'Map — great-circle + beam' }, - { value: 'map2', label: 'Map — locator (street)' }, - { value: 'cluster', label: 'Cluster spots' }, - { value: 'worked', label: 'Worked before' }, - { value: 'recent', label: 'Recent QSOs' }, - { value: 'netcontrol', label: 'Net control' }, -]; +const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol']; function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) { + const { t } = useI18n(); const [left, setLeft] = useState('map1'); const [right, setRight] = useState('map2'); // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z. const options = [ - ...MAIN_PANE_OPTIONS, - ...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []), - ...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []), - ].sort((a, b) => a.label.localeCompare(b.label)); + ...MAIN_PANE_VALUES, + ...(flexAvailable ? ['flex'] : []), + ...(icomAvailable ? ['icom'] : []), + ].map((value) => ({ value, label: t(`settings.pane.${value}`) })) + .sort((a, b) => a.label.localeCompare(b.label)); useEffect(() => { - const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v); + const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v); Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) .then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); }, []); @@ -609,11 +606,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged? }; return (
-

Main view

-

Choose what the Main tab shows on each side (per profile).

+

{t('settings.mainView')}

+

{t('settings.mainViewHint')}