up
This commit is contained in:
@@ -36,6 +36,9 @@ type ServerConfig struct {
|
||||
}
|
||||
|
||||
// Spot is a single DX spot as parsed from the cluster stream.
|
||||
// Country/Continent are filled by the caller (app.go) before the spot
|
||||
// is emitted to the UI, so the table never has empty country cells
|
||||
// flickering in for a few hundred ms.
|
||||
type Spot struct {
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
@@ -47,6 +50,8 @@ type Spot struct {
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
@@ -92,6 +97,7 @@ type session struct {
|
||||
conn net.Conn
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
stopped bool // guards against double-stop on the same session
|
||||
spotsCnt int
|
||||
}
|
||||
|
||||
@@ -162,11 +168,14 @@ func (m *Manager) StopServer(id int64) {
|
||||
if ok {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
remaining := len(m.sessions)
|
||||
m.mu.Unlock()
|
||||
fmt.Printf("cluster.StopServer id=%d found=%v remaining=%d\n", id, ok, remaining)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.stop()
|
||||
fmt.Printf("cluster.StopServer id=%d stopped successfully\n", id)
|
||||
m.emitStatus()
|
||||
}
|
||||
|
||||
@@ -230,10 +239,19 @@ func (s *session) send(cmd string) error {
|
||||
}
|
||||
|
||||
func (s *session) stop() {
|
||||
// Critical: do NOT nil out s.stopCh — the supervisor goroutine reads
|
||||
// `<-s.stopCh` in its select. Setting the field to nil would make
|
||||
// `<-nil` block forever, leaving the supervisor stuck on its backoff
|
||||
// timer and then re-dialing → a "deleted" cluster keeps spotting.
|
||||
// We just close() the channel and let the goroutine see the broadcast.
|
||||
s.mu.Lock()
|
||||
if s.stopped {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.stopped = true
|
||||
stop, done := s.stopCh, s.doneCh
|
||||
conn := s.conn
|
||||
s.stopCh, s.doneCh, s.conn = nil, nil, nil
|
||||
s.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
+28
-14
@@ -832,34 +832,48 @@ type EntitySlot struct {
|
||||
Slots map[string]map[string]struct{} // band → modes worked
|
||||
}
|
||||
|
||||
// EntitySlotMap returns slot data for every QSO grouped by lowercase
|
||||
// country name (cty.dat-style key). Cheap on a 25k-row table: one
|
||||
// scan, no joins. Callers can compare a spot's entity to this map to
|
||||
// decide if it's NEW / NEW SLOT / WORKED.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) {
|
||||
// EntitySlotMap returns slot data for every QSO, grouping by entity.
|
||||
//
|
||||
// `resolveEntity` maps a callsign to its canonical entity name (we use
|
||||
// cty.dat for this). When non-nil, the resolved name wins over the
|
||||
// stored `country` column — that's important because QRZ's "Turkey"
|
||||
// disagrees with cty.dat's "Asiatic Turkey" and the cluster status
|
||||
// comparison would otherwise miss past QSOs. When nil, we fall back to
|
||||
// the stored country (useful for tests).
|
||||
//
|
||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT lower(country), lower(band), upper(mode) FROM qso
|
||||
WHERE country IS NOT NULL AND country != ''
|
||||
AND band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''`)
|
||||
`SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||
WHERE band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]*EntitySlot, 256)
|
||||
for rows.Next() {
|
||||
var country, band, mode string
|
||||
if err := rows.Scan(&country, &band, &mode); err != nil {
|
||||
var call, country, band, mode string
|
||||
if err := rows.Scan(&call, &country, &band, &mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, ok := out[country]
|
||||
key := country
|
||||
if resolveEntity != nil {
|
||||
if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" {
|
||||
key = name
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
e, ok := out[key]
|
||||
if !ok {
|
||||
e = &EntitySlot{
|
||||
Country: country,
|
||||
Country: key,
|
||||
Bands: make(map[string]struct{}),
|
||||
Slots: make(map[string]map[string]struct{}),
|
||||
}
|
||||
out[country] = e
|
||||
out[key] = e
|
||||
}
|
||||
e.Bands[band] = struct{}{}
|
||||
bandSlots, ok := e.Slots[band]
|
||||
|
||||
Reference in New Issue
Block a user