diff --git a/app.go b/app.go index d501756..e2519c3 100644 --- a/app.go +++ b/app.go @@ -886,6 +886,13 @@ func (a *App) startup(ctx context.Context) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:spot", s) } + // A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and + // stops there. It is a replay of the past: firing 100 alerts at once, or + // painting 100 stale stations on the panadapter as if they were on the + // air right now, would be actively misleading. + if s.Historical { + return + } // Fire any matching alert rules (sound / visual / e-mail). a.evaluateAlerts(s) // Mirror the spot onto the FlexRadio panadapter when enabled. The @@ -904,6 +911,14 @@ func (a *App) startup(ctx context.Context) { wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status()) } }, + // Raw traffic → the cluster console. Spots are parsed out of this stream, + // but SH/DX, WHO, the MOTD and error replies are NOT spots — without this + // they were dropped on the floor and the command box looked inert. + func(l cluster.Line) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cluster:line", l) + } + }, ) a.refreshOperatorGrid() if cs, _ := a.clusterAutoConnect(); cs { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2cfb3f..a784c59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { 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, Trash2, Unlock, X, Zap, + Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -912,6 +912,31 @@ export default function App() { const [clusterFilterSource, setClusterFilterSource] = useState(''); const [clusterGroup, setClusterGroup] = useState(true); const [clusterCmd, setClusterCmd] = useState(''); + // Cluster console: the raw traffic. Spots are parsed out of the stream into the + // grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded, + // so a command appeared to do nothing at all. + type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string }; + const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever + const [clusterLines, setClusterLines] = useState([]); + const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false); + const clusterConsoleRef = useRef(null); + useEffect(() => { + const off = EventsOn('cluster:line', (l: any) => { + setClusterLines((prev) => { + const next = [...prev, l as ClusterLine]; + return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next; + }); + }); + return () => { off?.(); }; + }, []); + // Follow the tail, but ONLY when already at the bottom — otherwise scrolling up + // to read a SH/DX reply would yank you back down on the next spot. + useEffect(() => { + const el = clusterConsoleRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + if (atBottom) el.scrollTop = el.scrollHeight; + }, [clusterLines]); // Multi-band filter: empty set = all bands. The user toggles chips. const [clusterBands, setClusterBands] = useState>(new Set()); // Lock-to-entry: when on, the band filter follows the entry's current @@ -4336,8 +4361,48 @@ export default function App() { ); })()} + {/* Console — the RAW cluster stream. Spots are parsed out of it into + the grid above, but SH/DX, WHO, the MOTD and error replies are not + spots: without this they were dropped and the command box looked + inert (you typed SH/DX/100 and nothing ever happened). */} + {clusterConsoleOpen && ( +
+
+ + + {t('cluster.console')} + + {clusterLines.length} +
+ + +
+
+ {clusterLines.length === 0 ? ( +

{t('cluster.consoleEmpty')}

+ ) : clusterLines.map((l, i) => ( +
+ {l.at} + {l.sent && »} + {l.text} +
+ ))} +
+
+ )} + {/* Command input — sends to the master server. */}
+ {!clusterConsoleOpen && ( + + )} → master +// freq dxcall date time comment +// +// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast +// regex, and get dropped. The decimal point in the frequency is required — it is +// what keeps ordinary prose out of the spot grid. +var showDXRE = regexp.MustCompile( + `^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`, +) + +// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged +// Historical: these are PAST spots, so they belong in the grid but must not fire +// alerts or land on the panadapter — replaying 100 of them would spam both, and a +// station spotted three hours ago is not on the air now. +func parseShowDX(line string) (Spot, bool) { + if strings.Contains(strings.ToUpper(line), "DX DE") { + return Spot{}, false // that's the broadcast form; spotRE owns it + } + m := showDXRE.FindStringSubmatch(line) + if m == nil { + return Spot{}, false + } + khz, err := strconv.ParseFloat(m[1], 64) + if err != nil || khz <= 0 { + return Spot{}, false + } + hz := int64(khz * 1000) + return Spot{ + Spotter: strings.ToUpper(m[6]), + DXCall: strings.ToUpper(m[2]), + FreqKHz: khz, + FreqHz: hz, + Band: bandFromHz(hz), + Comment: strings.TrimSpace(m[5]), + TimeUTC: m[4] + "Z", + ReceivedAt: time.Now(), + Raw: line, + Historical: true, + }, true +} + func parseSpot(line string) (Spot, bool) { m := spotRE.FindStringSubmatch(line) if m == nil { diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index 1bbd9ca..86108af 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) { } } } + +// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely +// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply +// arrived, matched nothing and was dropped: the command looked like it did +// nothing at all. These lines must now land in the grid, flagged Historical. +func TestParseShowDX(t *testing.T) { + cases := []struct { + line string + call string + khz float64 + spotter string + }{ + {" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX ", "EA8DHH", 14195.0, "F5ABC"}, + {" 7005.5 RA3XYZ 14-Jul-2026 0912Z ", "RA3XYZ", 7005.5, "DL1ABC"}, + {"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 ", "VK9/DL2XYZ", 21025.0, "JA1ABC"}, + // No spotter brackets — still a spot, just without a DE. + {" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""}, + } + for _, c := range cases { + got, ok := parseShowDX(c.line) + if !ok { + t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line) + continue + } + if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter { + t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q", + c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter) + } + if !got.Historical { + t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line) + } + if got.Band == "" { + t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz) + } + } +} + +// The SH/DX parser must not swallow ordinary cluster prose — a console full of +// chatter turned into fake spots would be worse than no parser at all. +func TestParseShowDXRejectsNoise(t *testing.T) { + noise := []string{ + "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it + "Hello and welcome to the DXSpider cluster", + "WWV de VE7CC <18Z> : SFI=110, A=16, K=2", + "F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >", + "", + "There are 42 users online", + } + for _, l := range noise { + if s, ok := parseShowDX(l); ok { + t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s) + } + } +}