fix(cluster): stop saying "waiting for spots" when 76 have arrived

An operator sent two screenshots: 76 LIVE in the counter, and the panel beside
it reading "Waiting for spots… Spots will appear as the cluster sends them."
Both his band and mode were locked to the rig — 20 m, SSB — so the filters were
doing exactly their job and the empty state was describing a different problem
entirely. He went looking for a connection fault.

It now says how many arrived, names every filter currently narrowing the list,
and offers one button to clear them all. The band and mode locks are named
first: they follow the rig rather than a click, so they are the two nobody
remembers switching on.

Also times the connection. He reports the first launch taking a while to
produce spots and a restart connecting instantly, which is the signature of a
slow name resolution rather than a slow node — the OS caches the answer, so the
second run skips it. The log now carries the dial duration and the delay to the
first spot, which separates that from a node that simply had nothing to say.
Hypothesis, not conclusion: the next log settles it.
This commit is contained in:
2026-08-15 23:35:56 +02:00
parent 8113557015
commit be0326c862
4 changed files with 90 additions and 4 deletions
+6 -2
View File
@@ -3,10 +3,14 @@
"version": "0.25.4", "version": "0.25.4",
"date": "", "date": "",
"en": [ "en": [
"WinKeyer: the opening probe goes out as one write, matching a capture of a client that talks to the same K3NG keyer, and the handshake bytes are always logged so a keyer that stays silent can be diagnosed." "WinKeyer: the opening probe goes out as one write, matching a capture of a client that talks to the same K3NG keyer, and the handshake bytes are always logged so a keyer that stays silent can be diagnosed.",
"DX cluster: when spots arrive and every one is filtered out, the panel says so, names the filters doing it and offers to clear them — it used to say “waiting for spots” beside a counter reading 76 live.",
"DX cluster: the log times the connection and the first spot, so a slow first launch can be told apart from a quiet node."
], ],
"fr": [ "fr": [
"WinKeyer : la sonde douverture part en un seul envoi, calquée sur la capture dun client qui dialogue avec le même manipulateur K3NG, et les octets de la poignée de main sont toujours journalisés pour diagnostiquer un manipulateur muet." "WinKeyer : la sonde douverture part en un seul envoi, calquée sur la capture dun client qui dialogue avec le même manipulateur K3NG, et les octets de la poignée de main sont toujours journalisés pour diagnostiquer un manipulateur muet.",
"Cluster DX : quand des spots arrivent et que tout est filtré, le panneau le dit, nomme les filtres responsables et propose de les effacer — il affichait « en attente de spots » à côté dun compteur à 76 en direct.",
"Cluster DX : le journal chronomètre la connexion et le premier spot, pour distinguer un premier lancement lent dun nœud silencieux."
] ]
}, },
{ {
+64 -2
View File
@@ -1539,6 +1539,41 @@ export default function App() {
// Hide spots already worked (exact call worked, or this band+mode slot done). // Hide spots already worked (exact call worked, or this band+mode slot done).
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false)); const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
// Everything currently narrowing the spot list, in words. Shown when spots
// arrived and none survived — the operator needs to know WHICH filter to
// loosen, and the two locks are the least memorable because they follow the
// rig rather than a click.
const clusterActiveFilterSummary = useMemo(() => {
const on: string[] = [];
if (clusterLockBand) on.push(t('clg2.fBandLock'));
else if (clusterBands.size > 0) on.push(t('clg2.fBands', { list: [...clusterBands].join(', ') }));
if (clusterLockMode) on.push(t('clg2.fModeLock'));
else if (clusterModeFilter.size > 0) on.push(t('clg2.fModes', { list: [...clusterModeFilter].join(', ') }));
if (clusterStatusFilter.size > 0) on.push(t('clg2.fStatus'));
if (clusterHideWorked) on.push(t('clg2.fHideWorked'));
if (clusterLotwOnly) on.push(t('clg2.fLotwOnly'));
if (clusterSpotterConts.size > 0) on.push(t('clg2.fSpotterCont'));
if (clusterFilterSource) on.push(t('clg2.fSource'));
if (clusterSearch.trim()) on.push(t('clg2.fSearch', { q: clusterSearch.trim() }));
return on.join(' · ');
}, [t, clusterLockBand, clusterBands, clusterLockMode, clusterModeFilter, clusterStatusFilter,
clusterHideWorked, clusterLotwOnly, clusterSpotterConts, clusterFilterSource, clusterSearch]);
// Undo every one of them at once. A list of ten switches spread down a panel
// is not something to walk back by hand when the answer is "show me anything".
const clearClusterFilters = useCallback(() => {
setClusterLockBand(false);
setClusterBands(new Set());
setClusterLockMode(false);
setClusterModeFilter(new Set());
setClusterStatusFilter(new Set());
setClusterHideWorked(false);
setClusterLotwOnly(false);
setClusterSpotterConts(new Set());
setClusterFilterSource('');
setClusterSearch('');
}, []);
// Persist every cluster filter selection whenever it changes, so it is still // Persist every cluster filter selection whenever it changes, so it is still
// set after a close/reopen. // set after a close/reopen.
useEffect(() => { useEffect(() => {
@@ -6738,14 +6773,41 @@ export default function App() {
// pane). All the filter state lives in the right-side panel. // pane). All the filter state lives in the right-side panel.
const rendered = clusterRenderedRows; const rendered = clusterRenderedRows;
if (rendered.length === 0) { if (rendered.length === 0) {
const connected = clusterServerStatuses.some((s) => s.state === 'connected');
// Spots HAVE arrived and the filters ate every one of them.
//
// This used to say "Waiting for spots…" regardless, which is a
// lie the moment the counter beside it reads 76 live: an
// operator reads the two together and goes looking for a
// connection fault instead of at the filter panel. The band and
// mode locks are the usual culprits and the least visible —
// they follow the rig, so nobody remembers switching them on.
if (connected && spots.length > 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12 px-6 text-center">
<SlidersHorizontal className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{t('clg2.allFiltered', { n: spots.length })}
</div>
{clusterActiveFilterSummary && (
<div className="text-xs max-w-md leading-relaxed">
{t('clg2.activeFilters')} <span className="font-medium text-foreground/80">{clusterActiveFilterSummary}</span>
</div>
)}
<Button size="sm" variant="outline" className="mt-1" onClick={clearClusterFilters}>
{t('clg2.clearAllFilters')}
</Button>
</div>
);
}
return ( return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12"> <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" /> <Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70"> <div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'} {connected ? 'Waiting for spots…' : 'No active connection'}
</div> </div>
<div className="text-xs"> <div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected') {connected
? 'Spots will appear as the cluster sends them.' ? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'} : 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div> </div>
File diff suppressed because one or more lines are too long
+18
View File
@@ -369,10 +369,21 @@ func (s *session) runOnce() (time.Time, error) {
// failure surfaces as an error on Read — which is the only thing that ends // failure surfaces as an error on Read — which is the only thing that ends
// a session below. // a session below.
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
// TIMED, and reported. An operator sees "connected" and no spots for a
// minute on the first launch, then an instant connection when the program is
// restarted — which is the signature of a slow name resolution rather than a
// slow cluster (the OS caches the answer, so the second run skips it). The
// only way to tell that from a node that simply had nothing to say is to
// know how long the dial itself took.
dialStart := time.Now()
conn, err := d.Dial("tcp", addr) conn, err := d.Dial("tcp", addr)
if err != nil { if err != nil {
applog.Printf("cluster[%s] dial %s failed after %s: %v", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond), err)
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err) return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
} }
applog.Printf("cluster[%s] connected to %s in %s", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond))
linkUpAt := time.Now()
firstSpotLogged := false
s.mu.Lock() s.mu.Lock()
s.conn = conn s.conn = conn
s.mu.Unlock() s.mu.Unlock()
@@ -556,6 +567,13 @@ func (s *session) runOnce() (time.Time, error) {
} }
s.mu.Unlock() s.mu.Unlock()
if s.onSpot != nil { if s.onSpot != nil {
if !firstSpotLogged {
firstSpotLogged = true
// The gap between the socket opening and the first spot is the
// other half of the answer: a long dial is the network, a quick
// dial and a long silence is the node (or the login) instead.
applog.Printf("cluster[%s] first spot %s after connecting", s.cfg.Name, time.Since(linkUpAt).Round(time.Millisecond))
}
s.onSpot(spot) s.onSpot(spot)
} }
} }