feat: added command sent in cluster panel
This commit is contained in:
@@ -886,6 +886,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
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).
|
// Fire any matching alert rules (sound / visual / e-mail).
|
||||||
a.evaluateAlerts(s)
|
a.evaluateAlerts(s)
|
||||||
// Mirror the spot onto the FlexRadio panadapter when enabled. The
|
// 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())
|
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()
|
a.refreshOperatorGrid()
|
||||||
if cs, _ := a.clusterAutoConnect(); cs {
|
if cs, _ := a.clusterAutoConnect(); cs {
|
||||||
|
|||||||
+66
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -912,6 +912,31 @@ export default function App() {
|
|||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
const [clusterGroup, setClusterGroup] = useState(true);
|
||||||
const [clusterCmd, setClusterCmd] = useState('');
|
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<ClusterLine[]>([]);
|
||||||
|
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
|
||||||
|
const clusterConsoleRef = useRef<HTMLDivElement | null>(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.
|
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
||||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
// 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 && (
|
||||||
|
<div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
|
||||||
|
<Terminal className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('cluster.console')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
|
||||||
|
<button className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
|
||||||
|
{clusterLines.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
|
||||||
|
) : clusterLines.map((l, i) => (
|
||||||
|
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
|
||||||
|
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
|
||||||
|
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Command input — sends to the master server. */}
|
{/* Command input — sends to the master server. */}
|
||||||
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
||||||
|
{!clusterConsoleOpen && (
|
||||||
|
<button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
|
||||||
|
<Terminal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono text-xs h-8"
|
className="font-mono text-xs h-8"
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const en: Dict = {
|
|||||||
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||||
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||||
'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —',
|
'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
|
||||||
|
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||||
'msg.expand': 'Click to read the full message',
|
'msg.expand': 'Click to read the full message',
|
||||||
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
||||||
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
||||||
@@ -309,6 +311,8 @@ const fr: Dict = {
|
|||||||
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||||
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||||
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —',
|
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
|
||||||
|
'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
|
||||||
'msg.expand': 'Cliquer pour lire le message en entier',
|
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||||
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
||||||
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
||||||
|
|||||||
+141
-11
@@ -61,6 +61,11 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||||
|
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||||
|
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||||
|
// ago is not on the air now.
|
||||||
|
Historical bool `json:"historical,omitempty"`
|
||||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
POTAName string `json:"pota_name,omitempty"` // park name
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,21 @@ type ServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// session is one telnet connection bound to a single server config.
|
// session is one telnet connection bound to a single server config.
|
||||||
|
// Line is one raw line of cluster traffic: everything the server says, plus the
|
||||||
|
// commands we send (echoed, so the console reads as a conversation).
|
||||||
|
//
|
||||||
|
// Spots are PARSED OUT of this stream — but everything else used to be silently
|
||||||
|
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
|
||||||
|
// the command box look broken. The raw stream is the fix: the console shows what
|
||||||
|
// the server actually said, spot or not.
|
||||||
|
type Line struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sent bool `json:"sent"` // true = a command WE sent
|
||||||
|
At string `json:"at"` // HH:MM:SS, local
|
||||||
|
}
|
||||||
|
|
||||||
// Internal — callers use Manager. The onStatus callback is fire-and-
|
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||||
// forget: it tells the manager something changed; the frontend fetches
|
// forget: it tells the manager something changed; the frontend fetches
|
||||||
// the new aggregate via Status() rather than receiving per-server diffs.
|
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||||
@@ -99,6 +119,7 @@ type session struct {
|
|||||||
cfg ServerConfig
|
cfg ServerConfig
|
||||||
login string
|
login string
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -117,6 +138,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[int64]*session
|
sessions map[int64]*session
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +146,11 @@ type Manager struct {
|
|||||||
// spot (with the source server filled in). emitStatusChanged is called
|
// spot (with the source server filled in). emitStatusChanged is called
|
||||||
// whenever ANY server's status changes — the frontend then re-fetches
|
// whenever ANY server's status changes — the frontend then re-fetches
|
||||||
// the aggregate Status() via a Wails binding.
|
// the aggregate Status() via a Wails binding.
|
||||||
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sessions: make(map[int64]*session),
|
sessions: make(map[int64]*session),
|
||||||
onSpot: emitSpot,
|
onSpot: emitSpot,
|
||||||
|
onLine: emitLine,
|
||||||
onStatus: emitStatusChanged,
|
onStatus: emitStatusChanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
login: login,
|
login: login,
|
||||||
onSpot: m.onSpot,
|
onSpot: m.onSpot,
|
||||||
|
onLine: m.onLine,
|
||||||
onStatus: m.emitStatus,
|
onStatus: m.emitStatus,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
|
|||||||
}
|
}
|
||||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||||
|
if err == nil {
|
||||||
|
// Echo it into the console, so the operator sees WHAT was sent and can pair
|
||||||
|
// it with the reply that follows. A console that only shows one side of the
|
||||||
|
// conversation is barely better than none.
|
||||||
|
s.emitLine(cmd, true)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init commands: fire 1s after login goes through. Each command on
|
// Init commands, once per connection (so they replay after a reconnect).
|
||||||
// its own line; blank lines and "//" comments are skipped.
|
//
|
||||||
|
// Timing: the cluster needs a moment after login before it will take commands,
|
||||||
|
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
|
||||||
|
// happily swallows a burst of back-to-back lines and silently ignores half of
|
||||||
|
// them. A fast-but-dropped command is worse than a slow one.
|
||||||
|
//
|
||||||
|
// They go through s.send() — NOT a raw conn.Write, which is what they used to
|
||||||
|
// do. That matters for two reasons: the raw write skipped the write deadline
|
||||||
|
// (a wedged server could hang this goroutine forever), and it skipped the
|
||||||
|
// console echo, so there was no way to SEE whether your init commands had
|
||||||
|
// actually been applied. Now you watch them go out and the reply come back.
|
||||||
initFired := false
|
initFired := false
|
||||||
fireInitCommands := func() {
|
fireInitCommands := func() {
|
||||||
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||||
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
initFired = true
|
initFired = true
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(initCommandLeadIn)
|
||||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||||
line = strings.TrimRight(line, "\r")
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "//") {
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
if err := s.send(line); err != nil {
|
||||||
time.Sleep(500 * time.Millisecond)
|
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(initCommandGap)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
fireInitCommands()
|
fireInitCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
if spot, ok := parseSpot(line); ok {
|
// EVERY line goes to the console — spot or not. This is the whole point:
|
||||||
|
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
|
||||||
|
// (which is what happened before) made the command box look inert.
|
||||||
|
s.emitLine(line, false)
|
||||||
|
|
||||||
|
// Live broadcast first; then the SH/DX table form. Without the second, a
|
||||||
|
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
|
||||||
|
// "SH/DX/100" looked like it did nothing at all.
|
||||||
|
spot, ok := parseSpot(line)
|
||||||
|
if !ok {
|
||||||
|
spot, ok = parseShowDX(line)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
spot.SourceID = s.cfg.ID
|
spot.SourceID = s.cfg.ID
|
||||||
spot.SourceName = s.cfg.Name
|
spot.SourceName = s.cfg.Name
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.spotsCnt++
|
// Historical spots are a replay of the past, not new traffic — counting
|
||||||
s.status.SpotsCount = s.spotsCnt
|
// them would inflate the server's "spots received" figure.
|
||||||
|
if !spot.Historical {
|
||||||
|
s.spotsCnt++
|
||||||
|
s.status.SpotsCount = s.spotsCnt
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.onSpot != nil {
|
if s.onSpot != nil {
|
||||||
s.onSpot(spot)
|
s.onSpot(spot)
|
||||||
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitLine hands one line of traffic to the console. Blank lines are dropped —
|
||||||
|
// clusters send plenty and they'd only pad the scrollback.
|
||||||
|
func (s *session) emitLine(text string, sent bool) {
|
||||||
|
if s.onLine == nil || strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onLine(Line{
|
||||||
|
ServerID: s.cfg.ID,
|
||||||
|
ServerName: s.cfg.Name,
|
||||||
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
|
Sent: sent,
|
||||||
|
At: time.Now().Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
@@ -465,6 +538,63 @@ var spotRE = regexp.MustCompile(
|
|||||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pacing for the per-server init commands.
|
||||||
|
//
|
||||||
|
// A cluster is not ready to take commands the instant the login line goes out —
|
||||||
|
// it is still printing its banner — so we wait before the first one. And they are
|
||||||
|
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
|
||||||
|
// back-to-back lines and silently apply only some of them: the connection stays
|
||||||
|
// up, no error is returned, your filters just aren't set. A command that is
|
||||||
|
// dropped is far worse than one that is slow.
|
||||||
|
const (
|
||||||
|
initCommandLeadIn = 1500 * time.Millisecond
|
||||||
|
initCommandGap = 700 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
||||||
|
// broadcast format, and therefore matched nothing at all:
|
||||||
|
//
|
||||||
|
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||||
|
// freq dxcall date time comment <spotter>
|
||||||
|
//
|
||||||
|
// 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) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -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 <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
|
||||||
|
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
|
||||||
|
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user