This commit is contained in:
2025-10-18 18:51:44 +02:00
parent 30cde5052b
commit 26bfd17683
17 changed files with 1133 additions and 288 deletions

View File

@@ -19,6 +19,21 @@ var spotRe *regexp.Regexp = regexp.MustCompile(`DX\sde\s([\w\d]+).*:\s+(\d+.\d)\
var defaultLoginRe *regexp.Regexp = regexp.MustCompile("[\\w\\d-_]+ login:")
var defaultPasswordRe *regexp.Regexp = regexp.MustCompile("Password:")
const (
// Reconnection settings
MaxReconnectAttempts = 10
BaseReconnectDelay = 1 * time.Second
MaxReconnectDelay = 60 * time.Second
// Timeout settings
ConnectionTimeout = 10 * time.Second
LoginTimeout = 30 * time.Second
ReadTimeout = 5 * time.Minute
// Channel buffer sizes
SpotChannelBuffer = 100
)
type TCPClient struct {
Login string
Password string
@@ -62,15 +77,15 @@ func NewTCPClient(TCPServer *TCPServer, Countries Countries, contactRepo *Log4OM
MsgChan: TCPServer.MsgChan,
CmdChan: TCPServer.CmdChan,
SpotChanToHTTPServer: spotChanToHTTPServer,
SpotChanToFlex: make(chan TelnetSpot, 100),
SpotChanToFlex: make(chan TelnetSpot, SpotChannelBuffer),
TCPServer: *TCPServer,
Countries: Countries,
ContactRepo: contactRepo,
ctx: ctx,
cancel: cancel,
maxReconnectAttempts: 10, // Max 10 tentatives avant abandon
baseReconnectDelay: 1 * time.Second, // Délai initial
maxReconnectDelay: 60 * time.Second, // Max 1 minute entre tentatives
maxReconnectAttempts: MaxReconnectAttempts,
baseReconnectDelay: BaseReconnectDelay,
maxReconnectDelay: MaxReconnectDelay,
}
}
@@ -108,7 +123,7 @@ func (c *TCPClient) connect() error {
Log.Debugf("Attempting to connect to %s (attempt %d/%d)", addr, c.reconnectAttempts+1, c.maxReconnectAttempts)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
conn, err := net.DialTimeout("tcp", addr, ConnectionTimeout)
if err != nil {
return fmt.Errorf("failed to connect to %s: %w", addr, err)
}
@@ -267,12 +282,19 @@ func (c *TCPClient) ReadLine() {
}
if c.LoggedIn {
// Check for cancellation before reading
select {
case <-c.ctx.Done():
return
default:
}
// Lecture avec timeout pour détecter les connexions mortes
c.Conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
c.Conn.SetReadDeadline(time.Now().Add(ReadTimeout))
message, err := c.Reader.ReadBytes('\n')
if err != nil {
Log.Errorf("Error reading message: %s", err)
return // ✅ Retour au lieu de récursion - la boucle principale va reconnecter
return
}
c.Conn.SetReadDeadline(time.Time{}) // Reset deadline

View File

@@ -2,7 +2,6 @@ package main
import (
"bufio"
"fmt"
"net"
"os"
"strings"
@@ -78,37 +77,45 @@ func (s *TCPServer) StartServer() {
}
func (s *TCPServer) handleConnection() {
s.Conn.Write([]byte("Welcome to the FlexDXCluster telnet server! Type 'bye' to exit.\n"))
// Store the connection locally to avoid race conditions
conn := s.Conn
s.Reader = bufio.NewReader(s.Conn)
s.Writer = bufio.NewWriter(s.Conn)
conn.Write([]byte("Welcome to the FlexDXCluster telnet server! Type 'bye' to exit.\n"))
reader := bufio.NewReader(conn)
defer func() {
s.Mutex.Lock()
delete(s.Clients, conn)
s.Mutex.Unlock()
conn.Close()
Log.Infof("Client %s disconnected", conn.RemoteAddr().String())
}()
for {
message, err := s.Reader.ReadString('\n')
message, err := reader.ReadString('\n')
if err != nil {
s.Mutex.Lock()
delete(s.Clients, s.Conn)
s.Mutex.Unlock()
Log.Debugf("Error reading from client %s: %v", conn.RemoteAddr().String(), err)
return
}
message = strings.TrimSpace(message)
// if message is by then disconnect
// if message is bye then disconnect
if message == "bye" {
s.Mutex.Lock()
delete(s.Clients, s.Conn)
s.Mutex.Unlock()
s.Conn.Close()
Log.Infof("client %s disconnected", s.Conn.RemoteAddr().String())
Log.Infof("Client %s sent bye command", conn.RemoteAddr().String())
return
}
if strings.Contains(message, "DX") || strings.Contains(message, "SH/DX") || strings.Contains(message, "set") || strings.Contains(message, "SET") {
// send DX spot to the client
s.CmdChan <- message
select {
case s.CmdChan <- message:
Log.Debugf("Command from client %s: %s", conn.RemoteAddr().String(), message)
default:
Log.Warn("Command channel is full, dropping command")
}
}
}
}
@@ -123,17 +130,31 @@ func (s *TCPServer) Write(message string) (n int, err error) {
func (s *TCPServer) broadcastMessage(message string) {
s.Mutex.Lock()
defer s.Mutex.Unlock()
if len(s.Clients) > 0 {
if s.MessageSent == 0 {
time.Sleep(3 * time.Second)
s.MessageSent += 1
}
for client := range s.Clients {
_, err := client.Write([]byte(message + "\r\n"))
s.MessageSent += 1
if err != nil {
fmt.Println("Error while sending message to clients:", client.RemoteAddr())
}
if len(s.Clients) == 0 {
return
}
if s.MessageSent == 0 {
time.Sleep(3 * time.Second)
s.MessageSent = 1
}
// Collect failed clients
var failedClients []net.Conn
for client := range s.Clients {
_, err := client.Write([]byte(message + "\r\n"))
s.MessageSent++
if err != nil {
Log.Warnf("Error sending to client %s: %v", client.RemoteAddr(), err)
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(s.Clients, client)
client.Close()
}
}

View File

@@ -5,7 +5,7 @@ general:
log_level: INFO # INFO or DEBUG or WARN
telnetserver: true # not in use for now
flexradiospot: true
send_freq_log: true # if not using a Flex then turn this on to send Freq and Mode to Log4OM which should in turn change the freq on the radio
sendFreqModeToLog4OM: true # if not using a Flex then turn this on to send Freq and Mode to Log4OM which should in turn change the freq on the radio
# Spot colors, if empty then default, colors in HEX AARRGGBB format
spot_color_new_entity:
background_color_new_entity:

View File

@@ -18,7 +18,7 @@ type Config struct {
LogLevel string `yaml:"log_level"`
TelnetServer bool `yaml:"telnetserver"`
FlexRadioSpot bool `yaml:"flexradiospot"`
SendFreqModeToLog bool `yaml:"send_freq_log"`
SendFreqModeToLog bool `yaml:"sendFreqModeToLog4OM"`
SpotColorNewEntity string `yaml:"spot_color_new_entity"`
BackgroundColorNewEntity string `yaml:"background_color_new_entity"`
SpotColorNewBand string `yaml:"spot_color_new_band"`

View File

@@ -24,11 +24,6 @@ type Contact struct {
Country string
}
type Spotter struct {
Spotter string
NumberofSpots string
}
type QSO struct {
Callsign string `json:"callsign"`
Band string `json:"band"`
@@ -65,6 +60,11 @@ func NewLog4OMContactsRepository(filePath string) *Log4OMContactsRepository {
Log.Errorf("Cannot open db", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return &Log4OMContactsRepository{
db: db,
Log: Log}
@@ -74,6 +74,11 @@ func NewLog4OMContactsRepository(filePath string) *Log4OMContactsRepository {
if err != nil {
Log.Errorf("Cannot open db", err)
}
// Configure connection pool for SQLite
db.SetMaxOpenConns(1) // SQLite works best with single connection for writes
db.SetMaxIdleConns(1)
_, err = db.Exec("PRAGMA journal_mode=WAL")
if err != nil {
panic(err)
@@ -91,11 +96,14 @@ func NewFlexDXDatabase(filePath string) *FlexDXClusterRepository {
db, err := sql.Open("sqlite3", filePath)
if err != nil {
fmt.Println("Cannot open db", err)
Log.Errorf("Cannot open db: %v", err)
}
Log.Debugln("Opening SQLite database")
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
_, err = db.ExecContext(
context.Background(),
`CREATE TABLE IF NOT EXISTS "spots" (
@@ -204,7 +212,7 @@ func (r *Log4OMContactsRepository) ListByCountryMode(countryID string, mode stri
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
r.Log.Println(err)
}
contacts = append(contacts, c)
@@ -241,7 +249,7 @@ func (r *Log4OMContactsRepository) ListByCountryModeBand(countryID string, band
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND mode = ? AND band = ?", countryID, mode, band)
if err != nil {
log.Error("could not query the database", err)
r.Log.Error("could not query the database", err)
}
defer rows.Close()
@@ -250,7 +258,7 @@ func (r *Log4OMContactsRepository) ListByCountryModeBand(countryID string, band
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
r.Log.Error(err)
}
contacts = append(contacts, c)
@@ -264,7 +272,7 @@ func (r *Log4OMContactsRepository) ListByCountryBand(countryID string, band stri
defer wg.Done()
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND band = ?", countryID, band)
if err != nil {
fmt.Println(err)
r.Log.Error(err)
}
defer rows.Close()
@@ -273,7 +281,7 @@ func (r *Log4OMContactsRepository) ListByCountryBand(countryID string, band stri
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
r.Log.Error(err)
}
contacts = append(contacts, c)
@@ -285,7 +293,7 @@ func (r *Log4OMContactsRepository) ListByCallSign(callSign string, band string,
defer wg.Done()
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE callsign = ? AND band = ? AND mode = ?", callSign, band, mode)
if err != nil {
fmt.Println(err)
r.Log.Error(err)
}
defer rows.Close()
@@ -294,7 +302,7 @@ func (r *Log4OMContactsRepository) ListByCallSign(callSign string, band string,
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
r.Log.Error(err)
}
contacts = append(contacts, c)
@@ -461,32 +469,6 @@ func (r *FlexDXClusterRepository) GetAllSpots(limit string) []FlexSpot {
return Spots
}
func (r *FlexDXClusterRepository) GetSpotters() []Spotter {
sList := []Spotter{}
rows, err := r.db.Query("select spotter, count(*) as occurences from spots group by spotter order by occurences desc, spotter limit 15")
if err != nil {
r.Log.Error(err)
return nil
}
defer rows.Close()
s := Spotter{}
for rows.Next() {
if err := rows.Scan(&s.Spotter, &s.NumberofSpots); err != nil {
fmt.Println(err)
return nil
}
sList = append(sList, s)
}
return sList
}
func (r *FlexDXClusterRepository) FindDXSameBand(spot FlexSpot) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE dx = ? AND band = ?", spot.DX, spot.Band)
if err != nil {
@@ -500,7 +482,7 @@ func (r *FlexDXClusterRepository) FindDXSameBand(spot FlexSpot) (*FlexSpot, erro
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
}
@@ -534,7 +516,7 @@ func (r *FlexDXClusterRepository) UpdateSpotSameBand(spot FlexSpot) error {
func (r *FlexDXClusterRepository) FindSpotByCommandNumber(commandNumber string) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE commandNumber = ?", commandNumber)
if err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
@@ -544,7 +526,7 @@ func (r *FlexDXClusterRepository) FindSpotByCommandNumber(commandNumber string)
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
}
@@ -554,7 +536,7 @@ func (r *FlexDXClusterRepository) FindSpotByCommandNumber(commandNumber string)
func (r *FlexDXClusterRepository) FindSpotByFlexSpotNumber(spotNumber string) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE flexSpotNumber = ?", spotNumber)
if err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
@@ -564,7 +546,7 @@ func (r *FlexDXClusterRepository) FindSpotByFlexSpotNumber(spotNumber string) (*
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
}
@@ -584,7 +566,7 @@ func (r *FlexDXClusterRepository) UpdateFlexSpotNumberByID(flexSpotNumber string
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
r.Log.Error(err)
return nil, err
}
}

View File

@@ -1,5 +1,6 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { soundManager } from './lib/soundManager.js';
import Header from './components/Header.svelte';
import StatsCards from './components/StatsCards.svelte';
import FilterBar from './components/FilterBar.svelte';
@@ -317,9 +318,6 @@
}, 30000); // 30 secondes
}
break;
case 'spotters':
topSpotters = message.data || [];
break;
case 'watchlist':
watchlist = message.data || [];
spotCache.saveMetadata('watchlist', watchlist).catch(err => console.error('Cache save error:', err));
@@ -336,11 +334,29 @@
case 'dxccProgress':
dxccProgress = message.data || { worked: 0, total: 340, percentage: 0 };
break;
case 'milestone': // ✅ AJOUTER
case 'milestone':
const milestoneData = message.data;
const toastType = milestoneData.type === 'qso' ? 'milestone' : 'band';
showToast(milestoneData.message, toastType);
break;
case 'watchlistAlert':
// Dispatch custom event for watchlist alert
const alertEvent = new CustomEvent('watchlistAlert', {
detail: message.data
});
window.dispatchEvent(alertEvent);
// Play sound if enabled
if (message.data.playSound) {
soundManager.playWatchlistAlert('medium');
}
// Show toast notification
showToast(
`🎯 ${message.data.callsign} spotted on ${message.data.band} ${message.data.mode}!`,
'success'
);
break;
}
}
@@ -555,6 +571,7 @@ async function shutdownApp() {
{solarData}
{wsStatus}
{cacheLoaded}
{soundManager}
on:shutdown={shutdownApp}
/>

View File

@@ -5,10 +5,17 @@
export let solarData;
export let wsStatus;
export let cacheLoaded = false;
export let soundManager;
let soundEnabled = true;
const dispatch = createEventDispatcher();
function toggleSound() {
soundEnabled = !soundEnabled;
soundManager.setEnabled(soundEnabled);
}
function getSFIColor(sfi) {
const value = parseInt(sfi);
if (isNaN(value)) return 'text-slate-500';
@@ -111,6 +118,22 @@
</span>
{/if}
<button
on:click={toggleSound}
title="{soundEnabled ? 'Disable' : 'Enable'} sound alerts"
class="px-3 py-1.5 rounded transition-colors {soundEnabled ? 'bg-blue-600 hover:bg-blue-700' : 'bg-slate-700 hover:bg-slate-600'} flex items-center gap-2">
{#if soundEnabled}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd"></path>
</svg>
{:else}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
{/if}
<span class="text-xs hidden sm:inline">{soundEnabled ? 'Sound ON' : 'Sound OFF'}</span>
</button>
<button
on:click={() => dispatch('shutdown')}
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">

View File

@@ -20,8 +20,8 @@
}
function getPriorityColor(spot) {
const inWatchlist = watchlist.some(pattern =>
spot.DX === pattern || spot.DX.startsWith(pattern)
const inWatchlist = watchlist.some(entry =>
spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign)
);
if (inWatchlist) return 'bg-pink-500/20 text-pink-400 border-pink-500/50';
@@ -35,8 +35,8 @@
}
function getStatusLabel(spot) {
const inWatchlist = watchlist.some(pattern =>
spot.DX === pattern || spot.DX.startsWith(pattern)
const inWatchlist = watchlist.some(entry =>
spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign)
);
if (inWatchlist) return 'Watchlist';
@@ -49,6 +49,12 @@
if (spot.Worked) return 'Worked';
return '';
}
function getCleanComment(spot) {
// Retirer le commentaire original brut s'il existe
if (!spot.OriginalComment) return '';
return spot.OriginalComment.trim();
}
</script>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden h-full">
@@ -59,14 +65,15 @@
<!-- Header fixe -->
<div class="bg-slate-900/50 flex-shrink-0">
<div class="flex text-left text-xs text-slate-400 font-semibold">
<div class="p-2" style="width: 12%;">DX</div>
<div class="p-2" style="width: 25%;">Country</div>
<div class="p-2" style="width: 12%;">Freq</div>
<div class="p-2" style="width: 8%;">Band</div>
<div class="p-2" style="width: 8%;">Mode</div>
<div class="p-2" style="width: 12%;">Spotter</div>
<div class="p-2" style="width: 8%;">Time</div>
<div class="p-2" style="width: 15%;">Status</div>
<div class="p-2" style="width: 10%;">DX</div>
<div class="p-2" style="width: 18%;">Country</div>
<div class="p-2" style="width: 10%;">Freq</div>
<div class="p-2" style="width: 7%;">Band</div>
<div class="p-2" style="width: 7%;">Mode</div>
<div class="p-2" style="width: 10%;">Spotter</div>
<div class="p-2" style="width: 7%;">Time</div>
<div class="p-2" style="width: 18%;">Comment</div>
<div class="p-2" style="width: 13%;">Status</div>
</div>
</div>
@@ -74,7 +81,7 @@
<div class="flex-1 overflow-hidden" bind:this={container}>
<VirtualList items={spots} {itemHeight} let:item>
<div class="flex border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm" style="height: {itemHeight}px;">
<div class="p-2 flex items-center" style="width: 12%;">
<div class="p-2 flex items-center" style="width: 10%;">
<button
class="font-bold text-blue-400 hover:text-blue-300 transition-colors truncate w-full text-left"
on:click={() => handleSpotClick(item)}
@@ -82,21 +89,24 @@
{item.DX}
</button>
</div>
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 25%;" title={item.CountryName || 'N/A'}>
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 18%;" title={item.CountryName || 'N/A'}>
{item.CountryName || 'N/A'}
</div>
<div class="p-2 flex items-center font-mono text-xs" style="width: 12%;">{item.FrequencyMhz}</div>
<div class="p-2 flex items-center" style="width: 8%;">
<div class="p-2 flex items-center font-mono text-xs" style="width: 10%;">{item.FrequencyMhz}</div>
<div class="p-2 flex items-center" style="width: 7%;">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">{item.Band}</span>
</div>
<div class="p-2 flex items-center" style="width: 8%;">
<div class="p-2 flex items-center" style="width: 7%;">
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">{item.Mode}</span>
</div>
<div class="p-2 flex items-center text-slate-300 text-xs truncate" style="width: 12%;" title={item.SpotterCallsign}>
<div class="p-2 flex items-center text-slate-300 text-xs truncate" style="width: 10%;" title={item.SpotterCallsign}>
{item.SpotterCallsign}
</div>
<div class="p-2 flex items-center text-slate-400 text-xs" style="width: 8%;">{item.UTCTime}</div>
<div class="p-2 flex items-center" style="width: 15%;">
<div class="p-2 flex items-center text-slate-400 text-xs" style="width: 7%;">{item.UTCTime}</div>
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 18%;" title={getCleanComment(item)}>
{getCleanComment(item)}
</div>
<div class="p-2 flex items-center" style="width: 13%;">
{#if getStatusLabel(item)}
<span class="px-1.5 py-0.5 rounded text-xs font-semibold border {getPriorityColor(item)} truncate">
{getStatusLabel(item)}

View File

@@ -10,7 +10,8 @@
}
</script>
<div class="grid grid-cols-7 gap-3 mb-3">
<div class="grid grid-cols-[repeat(4,1fr)_auto] gap-3 mb-3 items-center">
<!-- Total Spots -->
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -21,6 +22,7 @@
<p class="text-xs text-slate-400 mt-1">Total Spots</p>
</div>
<!-- New DXCC -->
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -31,71 +33,75 @@
<p class="text-xs text-slate-400 mt-1">New DXCC</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div class="text-xl font-bold text-purple-400">{stats.activeSpotters}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Spotters</p>
</div>
<!-- Telnet Clients -->
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<div class="text-xl font-bold text-orange-400">{stats.connectedClients}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Clients</p>
</div>
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-center gap-6 h-full">
<label class="flex items-center gap-2 cursor-pointer">
<!-- Total Contacts -->
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div class="text-xl font-bold text-purple-400">{stats.totalContacts.toLocaleString()}</div>
</div>
<p class="text-xs text-slate-400 mt-1">QSOs</p>
</div>
<!-- Cluster Filters -->
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50 h-full">
<div class="flex items-center justify-center gap-4 h-full">
<span class="text-xs text-slate-400 font-semibold whitespace-nowrap">Cluster Filters:</span>
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
<input
type="checkbox"
checked={stats.filters.skimmer}
on:change={(e) => handleFilterChange('skimmer', e.target.checked)}
class="sr-only peer"
/>
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-sm font-medium">CW</span>
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-xs font-medium whitespace-nowrap">Skimmer</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
<input
type="checkbox"
checked={stats.filters.ft8}
on:change={(e) => handleFilterChange('ft8', e.target.checked)}
class="sr-only peer"
/>
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-sm font-medium">FT8</span>
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-xs font-medium">FT8</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
<input
type="checkbox"
checked={stats.filters.ft4}
on:change={(e) => handleFilterChange('ft4', e.target.checked)}
class="sr-only peer"
/>
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-sm font-medium">FT4</span>
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-xs font-medium">FT4</span>
</label>
<!-- ✅ AJOUTER ce switch Beacon -->
<label class="flex items-center gap-2 cursor-pointer">
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
<input
type="checkbox"
checked={stats.filters.beacon}
on:change={(e) => handleFilterChange('beacon', e.target.checked)}
class="sr-only peer"
/>
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-sm font-medium">Beacon</span>
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
<span class="text-xs font-medium">Beacon</span>
</label>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script>
export let topSpotters;
export let spots;
const BANDS = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M'];
@@ -30,24 +29,7 @@
</script>
<div class="h-full overflow-y-auto">
<div class="p-3 border-b border-slate-700/50">
<h2 class="text-lg font-bold">Top 3 Spotters</h2>
</div>
<div class="p-3">
{#each topSpotters.slice(0, 3) as spotter, index}
<div class="flex items-center justify-between mb-2 p-2 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors">
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
{index + 1}
</div>
<span class="text-sm font-semibold">{spotter.Spotter}</span>
</div>
<span class="text-slate-400 font-mono text-xs">{spotter.NumberofSpots}</span>
</div>
{/each}
</div>
<div class="p-3 border-t border-slate-700/50">
<h2 class="text-lg font-bold mb-3">Band Propagation</h2>
</div>

View File

@@ -1,5 +1,6 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { soundManager } from '../lib/soundManager.js';
export let watchlist;
export let spots;
@@ -10,50 +11,64 @@
let newCallsign = '';
let watchlistSpots = [];
let refreshInterval;
let editingNotes = {};
let tempNotes = {};
$: matchingSpots = countWatchlistSpots(spots, watchlist);
// ✅ Tri alphanumérique simple : 0-9 puis A-Z
$: displayList = getDisplayList(watchlist, watchlistSpots, showOnlyActive);
// ✅ Rafraîchir automatiquement les spots de la watchlist
$: if (watchlist.length > 0) {
fetchWatchlistSpots();
}
// ✅ Rafraîchir aussi quand les spots changent (temps réel)
$: if (spots.length > 0 && watchlist.length > 0) {
fetchWatchlistSpots();
}
onMount(() => {
// Rafraîchir toutes les 10 secondes pour être sûr
refreshInterval = setInterval(() => {
if (watchlist.length > 0) {
fetchWatchlistSpots();
}
}, 10000);
// Listen for watchlist alerts
window.addEventListener('watchlistAlert', handleWatchlistAlert);
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
window.removeEventListener('watchlistAlert', handleWatchlistAlert);
});
function handleWatchlistAlert(event) {
const { callsign, playSound } = event.detail;
if (playSound) {
soundManager.playWatchlistAlert('medium');
}
// Show toast notification
dispatch('toast', {
message: `🎯 ${callsign} spotted!`,
type: 'success'
});
}
function getDisplayList(wl, wlSpots, activeOnly) {
let list = wl;
if (activeOnly) {
// Filtrer pour ne montrer que les callsigns avec des spots actifs
list = wl.filter(cs => {
const spots = wlSpots.filter(s => s.dx === cs || s.dx.startsWith(cs));
list = wl.filter(entry => {
const spots = wlSpots.filter(s => s.dx === entry.callsign || s.dx.startsWith(entry.callsign));
return spots.length > 0;
});
}
// Tri alphanumérique : 0-9 puis A-Z
return [...list].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
// Sort alphabetically
return [...list].sort((a, b) => a.callsign.localeCompare(b.callsign, 'en', { numeric: true }));
}
async function fetchWatchlistSpots() {
@@ -71,28 +86,25 @@
function countWatchlistSpots(allSpots, wl) {
return allSpots.filter(spot =>
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
wl.some(entry => spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign))
).length;
}
function getMatchingSpotsForCallsign(callsign) {
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
// ✅ Trier par bande d'abord, puis par heure
const bandOrder = { '160M': 0, '80M': 1, '60M': 2, '40M': 3, '30M': 4, '20M': 5, '17M': 6, '15M': 7, '12M': 8, '10M': 9, '6M': 10 };
return spots.sort((a, b) => {
// Trier par bande en premier
const bandA = bandOrder[a.band] ?? 99;
const bandB = bandOrder[b.band] ?? 99;
if (bandA !== bandB) return bandA - bandB;
function getMatchingSpotsForCallsign(callsign) {
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
// Si même bande, trier par heure (plus récent en premier)
const timeA = a.utcTime || "00:00";
const timeB = b.utcTime || "00:00";
return timeB.localeCompare(timeA);
});
}
const bandOrder = { '160M': 0, '80M': 1, '60M': 2, '40M': 3, '30M': 4, '20M': 5, '17M': 6, '15M': 7, '12M': 8, '10M': 9, '6M': 10 };
return spots.sort((a, b) => {
const bandA = bandOrder[a.band] ?? 99;
const bandB = bandOrder[b.band] ?? 99;
if (bandA !== bandB) return bandA - bandB;
const timeA = a.utcTime || "00:00";
const timeB = b.utcTime || "00:00";
return timeB.localeCompare(timeA);
});
}
async function addToWatchlist() {
const callsign = newCallsign.trim().toUpperCase();
@@ -115,7 +127,7 @@ function getMatchingSpotsForCallsign(callsign) {
dispatch('toast', { message: `${callsign} added to watchlist`, type: 'success' });
await fetchWatchlistSpots();
} else {
dispatch('toast', { message: 'Failed to add callsign', type: 'error' });
dispatch('toast', { message: data.error || 'Failed to add callsign', type: 'error' });
}
} catch (error) {
console.error('Error adding to watchlist:', error);
@@ -144,6 +156,51 @@ function getMatchingSpotsForCallsign(callsign) {
}
}
async function updateSound(callsign, playSound) {
try {
const response = await fetch('/api/watchlist/update-sound', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign, playSound })
});
const data = await response.json();
if (data.success) {
dispatch('toast', { message: `Sound ${playSound ? 'enabled' : 'disabled'}`, type: 'success' });
}
} catch (error) {
console.error('Error updating sound:', error);
}
}
function startEditNotes(callsign, currentNotes) {
editingNotes[callsign] = true;
tempNotes[callsign] = currentNotes || '';
}
async function saveNotes(callsign) {
try {
const response = await fetch('/api/watchlist/update-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign, notes: tempNotes[callsign] || '' })
});
const data = await response.json();
if (data.success) {
editingNotes[callsign] = false;
dispatch('toast', { message: 'Notes saved', type: 'success' });
}
} catch (error) {
console.error('Error saving notes:', error);
}
}
function cancelEditNotes(callsign) {
editingNotes[callsign] = false;
delete tempNotes[callsign];
}
function handleKeyPress(e) {
if (e.key === 'Enter') {
e.preventDefault();
@@ -162,7 +219,6 @@ function getMatchingSpotsForCallsign(callsign) {
window.dispatchEvent(event);
}
// ✅ Fonction pour le toggle
function toggleActiveOnly() {
showOnlyActive = !showOnlyActive;
}
@@ -210,8 +266,8 @@ function getMatchingSpotsForCallsign(callsign) {
<p class="text-xs mt-1">{showOnlyActive ? 'Click "Active Only" to see all entries' : 'Add callsigns or prefixes to monitor'}</p>
</div>
{:else}
{#each displayList as callsign}
{@const matchingSpots = getMatchingSpotsForCallsign(callsign)}
{#each displayList as entry}
{@const matchingSpots = getMatchingSpotsForCallsign(entry.callsign)}
{@const count = matchingSpots.length}
{@const neededCount = matchingSpots.filter(s => !s.workedBandMode).length}
{@const borderClass = neededCount > 0 ? 'border-orange-500/30' : 'border-slate-700/50'}
@@ -220,7 +276,12 @@ function getMatchingSpotsForCallsign(callsign) {
<div class="flex items-center justify-between mb-2">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<div class="font-bold text-pink-400 text-lg">{callsign}</div>
<div class="font-bold text-pink-400 text-lg">{entry.callsign}</div>
{#if entry.playSound}
<span class="text-xs" title="Sound enabled">🔊</span>
{/if}
{#if count > 0}
<span class="text-xs text-slate-400">{count} active spot{count !== 1 ? 's' : ''}</span>
{#if neededCount > 0}
@@ -231,14 +292,62 @@ function getMatchingSpotsForCallsign(callsign) {
{:else}
<span class="text-xs text-slate-500">No active spots</span>
{/if}
{#if entry.lastSeenStr && entry.lastSeenStr !== 'Never'}
<span class="text-xs text-slate-500">Last seen: {entry.lastSeenStr}</span>
{/if}
</div>
</div>
<button
on:click={() => removeFromWatchlist(callsign)}
title="Remove from watchlist"
class="px-2 py-1 text-xs bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded transition-colors">
Remove
</button>
<div class="flex gap-1">
<button
on:click={() => updateSound(entry.callsign, !entry.playSound)}
class="px-2 py-1 text-xs rounded transition-colors {entry.playSound ? 'bg-blue-600/20 text-blue-400' : 'bg-slate-700/50 text-slate-400'}"
title="{entry.playSound ? 'Disable' : 'Enable'} sound">
{entry.playSound ? '🔊' : '🔇'}
</button>
<button
on:click={() => removeFromWatchlist(entry.callsign)}
title="Remove from watchlist"
class="px-2 py-1 text-xs bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded transition-colors">
Remove
</button>
</div>
</div>
<!-- Notes section -->
<div class="mt-2 mb-2">
{#if editingNotes[entry.callsign]}
<div class="flex gap-2">
<input
type="text"
bind:value={tempNotes[entry.callsign]}
placeholder="Add notes..."
class="flex-1 px-2 py-1 bg-slate-700/50 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-blue-500"
/>
<button
on:click={() => saveNotes(entry.callsign)}
class="px-2 py-1 text-xs bg-green-600 hover:bg-green-700 rounded">
Save
</button>
<button
on:click={() => cancelEditNotes(entry.callsign)}
class="px-2 py-1 text-xs bg-slate-600 hover:bg-slate-700 rounded">
Cancel
</button>
</div>
{:else}
<button
on:click={() => startEditNotes(entry.callsign, entry.notes)}
class="w-full text-left px-2 py-1 bg-slate-800/30 rounded text-xs text-slate-400 hover:bg-slate-700/30 hover:text-slate-300 transition-colors">
{#if entry.notes}
📝 {entry.notes}
{:else}
+ Add notes...
{/if}
</button>
{/if}
</div>
{#if count > 0}
@@ -259,6 +368,7 @@ function getMatchingSpotsForCallsign(callsign) {
</svg>
{/if}
<span class="font-bold text-blue-400">{spot.dx}</span>
<span class="text-slate-400 text-xs truncate" style="max-width: 120px;" title="{spot.countryName || 'Unknown'}">{spot.countryName || 'Unknown'}</span>
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded flex-shrink-0">{spot.band}</span>
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded flex-shrink-0">{spot.mode}</span>
<span class="text-slate-400 font-mono truncate">{spot.frequencyMhz}</span>

View File

@@ -0,0 +1,74 @@
class SoundManager {
constructor() {
this.enabled = true;
this.audioContext = null;
this.sounds = {};
this.init();
}
init() {
// Initialize Web Audio API on user interaction
if (typeof window !== 'undefined') {
document.addEventListener('click', () => this.initAudioContext(), { once: true });
}
}
initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
}
// Generate a beep sound for watchlist alerts
generateBeep(priority = 'medium') {
if (!this.enabled || !this.audioContext) return;
const ctx = this.audioContext;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
// Different frequencies for different priorities
const frequencies = {
high: 1200, // High pitch
medium: 800, // Medium pitch
low: 500 // Low pitch
};
oscillator.frequency.value = frequencies[priority] || frequencies.medium;
oscillator.type = 'sine';
// Volume and duration
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
}
// Play double beep for high priority
playWatchlistAlert(priority = 'medium') {
if (!this.enabled) return;
this.initAudioContext();
this.generateBeep(priority);
if (priority === 'high') {
// Double beep for high priority
setTimeout(() => this.generateBeep(priority), 200);
}
}
setEnabled(enabled) {
this.enabled = enabled;
}
isEnabled() {
return this.enabled;
}
}
export const soundManager = new SoundManager();

View File

@@ -44,7 +44,6 @@ type HTTPServer struct {
type Stats struct {
TotalSpots int `json:"totalSpots"`
ActiveSpotters int `json:"activeSpotters"`
NewDXCC int `json:"newDXCC"`
ConnectedClients int `json:"connectedClients"`
TotalContacts int `json:"totalContacts"`
@@ -149,19 +148,20 @@ func (s *HTTPServer) setupRoutes() {
api.HandleFunc("/stats", s.getStats).Methods("GET", "OPTIONS")
api.HandleFunc("/spots", s.getSpots).Methods("GET", "OPTIONS")
api.HandleFunc("/spots/{id}", s.getSpotByID).Methods("GET", "OPTIONS")
api.HandleFunc("/spotters", s.getTopSpotters).Methods("GET", "OPTIONS")
api.HandleFunc("/contacts", s.getContacts).Methods("GET", "OPTIONS")
api.HandleFunc("/filters", s.updateFilters).Methods("POST", "OPTIONS")
api.HandleFunc("/shutdown", s.shutdownApp).Methods("POST", "OPTIONS")
api.HandleFunc("/send-callsign", s.handleSendCallsign).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist", s.getWatchlist).Methods("GET", "OPTIONS")
api.HandleFunc("/watchlist/add", s.addToWatchlist).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist/remove", s.removeFromWatchlist).Methods("DELETE", "OPTIONS")
api.HandleFunc("/solar", s.HandleSolarData).Methods("GET", "OPTIONS")
api.HandleFunc("/log/recent", s.getRecentQSOs).Methods("GET", "OPTIONS")
api.HandleFunc("/log/stats", s.getLogStats).Methods("GET", "OPTIONS")
api.HandleFunc("/log/dxcc-progress", s.getDXCCProgress).Methods("GET", "OPTIONS")
api.HandleFunc("/watchlist/spots", s.getWatchlistSpotsWithStatus).Methods("GET", "OPTIONS")
api.HandleFunc("/watchlist/add", s.addToWatchlist).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist/remove", s.removeFromWatchlist).Methods("DELETE", "OPTIONS")
api.HandleFunc("/watchlist/update-notes", s.updateWatchlistNotes).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist/update-sound", s.updateWatchlistSound).Methods("POST", "OPTIONS")
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
@@ -250,10 +250,6 @@ func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
spots := s.FlexRepo.GetAllSpots("0")
conn.WriteJSON(WSMessage{Type: "spots", Data: spots})
// Send initial spotters
spotters := s.FlexRepo.GetSpotters()
conn.WriteJSON(WSMessage{Type: "spotters", Data: spotters})
// Send initial watchlist
watchlist := s.Watchlist.GetAll()
conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist})
@@ -296,11 +292,13 @@ func (s *HTTPServer) handleBroadcasts() {
func (s *HTTPServer) broadcastUpdates() {
statsTicker := time.NewTicker(1 * time.Second)
logTicker := time.NewTicker(10 * time.Second)
cleanupTicker := time.NewTicker(5 * time.Minute) // ✅ AJOUTER
cleanupTicker := time.NewTicker(5 * time.Minute)
watchlistSaveTicker := time.NewTicker(30 * time.Second)
defer statsTicker.Stop()
defer logTicker.Stop()
defer cleanupTicker.Stop() // ✅ AJOUTER
defer cleanupTicker.Stop()
defer watchlistSaveTicker.Stop()
for {
select {
@@ -322,10 +320,6 @@ func (s *HTTPServer) broadcastUpdates() {
s.checkBandOpening(spots)
s.broadcast <- WSMessage{Type: "spots", Data: spots}
// Broadcast spotters
spotters := s.FlexRepo.GetSpotters()
s.broadcast <- WSMessage{Type: "spotters", Data: spotters}
case <-logTicker.C:
s.wsMutex.RLock()
clientCount := len(s.wsClients)
@@ -351,6 +345,12 @@ func (s *HTTPServer) broadcastUpdates() {
}
s.broadcast <- WSMessage{Type: "dxccProgress", Data: dxccData}
case <-watchlistSaveTicker.C:
// Sauvegarder la watchlist périodiquement
if s.Watchlist != nil {
s.Watchlist.save()
}
}
}
}
@@ -444,7 +444,6 @@ func (s *HTTPServer) getDXCCProgress(w http.ResponseWriter, r *http.Request) {
func (s *HTTPServer) calculateStats() Stats {
allSpots := s.FlexRepo.GetAllSpots("0")
spotters := s.FlexRepo.GetSpotters()
contacts := s.ContactRepo.CountEntries()
newDXCCCount := 0
@@ -466,7 +465,6 @@ func (s *HTTPServer) calculateStats() Stats {
return Stats{
TotalSpots: len(allSpots),
ActiveSpotters: len(spotters),
NewDXCC: newDXCCCount,
ConnectedClients: len(s.TCPServer.Clients),
TotalContacts: contacts,
@@ -525,11 +523,6 @@ func (s *HTTPServer) getSpotByID(w http.ResponseWriter, r *http.Request) {
s.sendJSON(w, APIResponse{Success: true, Data: spot})
}
func (s *HTTPServer) getTopSpotters(w http.ResponseWriter, r *http.Request) {
spotters := s.FlexRepo.GetSpotters()
s.sendJSON(w, APIResponse{Success: true, Data: spotters})
}
func (s *HTTPServer) getContacts(w http.ResponseWriter, r *http.Request) {
count := s.ContactRepo.CountEntries()
data := map[string]interface{}{"totalContacts": count}
@@ -660,12 +653,76 @@ func (s *HTTPServer) removeFromWatchlist(w http.ResponseWriter, r *http.Request)
s.sendJSON(w, APIResponse{Success: true, Message: "Callsign removed from watchlist"})
}
func (s *HTTPServer) updateWatchlistNotes(w http.ResponseWriter, r *http.Request) {
var req struct {
Callsign string `json:"callsign"`
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Callsign == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"})
return
}
if err := s.Watchlist.UpdateNotes(req.Callsign, req.Notes); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: err.Error()})
return
}
s.Log.Debugf("Updated notes for %s", req.Callsign)
// Broadcast updated watchlist to all clients
s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()}
s.sendJSON(w, APIResponse{Success: true, Message: "Notes updated"})
}
func (s *HTTPServer) updateWatchlistSound(w http.ResponseWriter, r *http.Request) {
var req struct {
Callsign string `json:"callsign"`
PlaySound bool `json:"playSound"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Callsign == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"})
return
}
if err := s.Watchlist.UpdateSound(req.Callsign, req.PlaySound); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: err.Error()})
return
}
s.Log.Debugf("Updated sound setting for %s to %v", req.Callsign, req.PlaySound)
// Broadcast updated watchlist to all clients
s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()}
s.sendJSON(w, APIResponse{Success: true, Message: "Sound setting updated"})
}
func (s *HTTPServer) getWatchlistSpotsWithStatus(w http.ResponseWriter, r *http.Request) {
// Récupérer tous les spots
allSpots := s.FlexRepo.GetAllSpots("0")
// Récupérer la watchlist
watchlistCallsigns := s.Watchlist.GetAll()
// Récupérer la watchlist (maintenant ce sont des WatchlistEntry)
watchlistEntries := s.Watchlist.GetAll()
// Extraire juste les callsigns pour la comparaison
watchlistCallsigns := make([]string, len(watchlistEntries))
for i, entry := range watchlistEntries {
watchlistCallsigns[i] = entry.Callsign
}
// Filtrer les spots de la watchlist
var relevantSpots []FlexSpot

26
spot.go
View File

@@ -301,10 +301,10 @@ func (spot *TelnetSpot) GuessMode() {
}
case "17M":
if freqInt >= 18068 && freqInt < 18095 {
if freqInt >= 18068 && freqInt < 18090 {
spot.Mode = "CW"
}
if freqInt >= 18095 && freqInt < 18104 {
if freqInt >= 18090 && freqInt < 18104 {
spot.Mode = "FT8"
}
if freqInt >= 18104 && freqInt < 18108 {
@@ -332,33 +332,39 @@ func (spot *TelnetSpot) GuessMode() {
}
case "12M":
if freqInt >= 24890 && freqInt < 24915 {
if freqInt >= 24890 && freqInt < 24910 {
spot.Mode = "CW"
}
if freqInt >= 24915 && freqInt < 24919 {
if freqInt >= 24910 && freqInt < 24919 {
spot.Mode = "FT8"
}
if freqInt >= 24919 && freqInt < 24930 {
spot.Mode = "CW"
if freqInt >= 24919 && freqInt < 24922 {
spot.Mode = "FT4"
}
if freqInt >= 24922 && freqInt < 24930 {
spot.Mode = "RTTY"
}
if freqInt >= 24930 && freqInt <= 24990 {
spot.Mode = "USB"
}
case "10M":
if freqInt >= 28000 && freqInt < 28074 {
if freqInt >= 28000 && freqInt < 28070 {
spot.Mode = "CW"
}
if freqInt >= 28074 && freqInt < 28080 {
if freqInt >= 28070 && freqInt < 28080 {
spot.Mode = "FT8"
}
if freqInt >= 28080 && freqInt < 28100 {
spot.Mode = "RTTY"
}
if freqInt >= 28100 && freqInt < 28300 {
if freqInt >= 28100 && freqInt < 28180 {
spot.Mode = "CW"
}
if freqInt >= 28300 && freqInt < 29000 {
if freqInt >= 28180 && freqInt < 28190 {
spot.Mode = "FT4"
}
if freqInt >= 28190 && freqInt < 29000 {
spot.Mode = "USB"
}
if freqInt >= 29000 && freqInt <= 29700 {

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"strings"
"time"
@@ -11,25 +12,42 @@ type SpotProcessor struct {
FlexClient *FlexClient
HTTPServer *HTTPServer
SpotChan chan TelnetSpot
ctx context.Context
cancel context.CancelFunc
}
func NewSpotProcessor(flexRepo *FlexDXClusterRepository, flexClient *FlexClient, httpServer *HTTPServer, spotChan chan TelnetSpot) *SpotProcessor {
ctx, cancel := context.WithCancel(context.Background())
return &SpotProcessor{
FlexRepo: flexRepo,
FlexClient: flexClient,
HTTPServer: httpServer,
SpotChan: spotChan,
ctx: ctx,
cancel: cancel,
}
}
func (sp *SpotProcessor) Start() {
Log.Info("Starting Spot Processor...")
for spot := range sp.SpotChan {
sp.processSpot(spot)
for {
select {
case <-sp.ctx.Done():
Log.Info("Spot Processor shutting down...")
return
case spot := <-sp.SpotChan:
sp.processSpot(spot)
}
}
}
func (sp *SpotProcessor) Stop() {
Log.Info("Stopping Spot Processor...")
sp.cancel()
}
func (sp *SpotProcessor) processSpot(spot TelnetSpot) {
freq := FreqMhztoHz(spot.Frequency)
@@ -66,7 +84,31 @@ func (sp *SpotProcessor) processSpot(spot TelnetSpot) {
if sp.HTTPServer.Watchlist.Matches(flexSpot.DX) {
flexSpot.InWatchlist = true
flexSpot.Comment = flexSpot.Comment + " [Watchlist]"
Log.Infof("🎯 Watchlist match: %s", flexSpot.DX)
// Mark as seen and update last seen time
sp.HTTPServer.Watchlist.MarkSeen(flexSpot.DX)
// Get entry to check if sound should be played
entry := sp.HTTPServer.Watchlist.GetEntry(flexSpot.DX)
if entry != nil {
Log.Infof("🎯 Watchlist match: %s (LastSeen: %s)",
flexSpot.DX, entry.LastSeenStr)
// Send notification to websocket clients for sound alert
if entry.PlaySound && sp.HTTPServer != nil {
sp.HTTPServer.broadcast <- WSMessage{
Type: "watchlistAlert",
Data: map[string]interface{}{
"callsign": flexSpot.DX,
"frequency": flexSpot.FrequencyMhz,
"band": flexSpot.Band,
"mode": flexSpot.Mode,
"countryName": flexSpot.CountryName,
"playSound": entry.PlaySound,
},
}
}
}
}
}

View File

@@ -2,111 +2,315 @@ package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
type WatchlistEntry struct {
Callsign string `json:"callsign"`
Notes string `json:"notes"`
LastSeen time.Time `json:"lastSeen"`
LastSeenStr string `json:"lastSeenStr"`
AddedAt time.Time `json:"addedAt"`
SpotCount int `json:"spotCount"`
PlaySound bool `json:"playSound"`
}
type Watchlist struct {
Callsigns []string `json:"callsigns"`
mu sync.RWMutex
filename string
entries map[string]*WatchlistEntry
filePath string
mutex sync.RWMutex
}
func NewWatchlist(filename string) *Watchlist {
wl := &Watchlist{
Callsigns: []string{},
filename: filename,
func NewWatchlist(filePath string) *Watchlist {
w := &Watchlist{
entries: make(map[string]*WatchlistEntry),
filePath: filePath,
}
wl.Load()
return wl
w.load()
return w
}
func (wl *Watchlist) Load() error {
wl.mu.Lock()
defer wl.mu.Unlock()
func (w *Watchlist) load() {
w.mutex.Lock()
defer w.mutex.Unlock()
data, err := os.ReadFile(wl.filename)
data, err := os.ReadFile(w.filePath)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist, start with empty watchlist
return nil
if !os.IsNotExist(err) {
Log.Errorf("Error reading watchlist file: %v", err)
} else {
Log.Debug("Watchlist file does not exist yet, will be created on first save")
}
return err
return
}
return json.Unmarshal(data, &wl.Callsigns)
var entries []WatchlistEntry
if err := json.Unmarshal(data, &entries); err != nil {
Log.Errorf("Error parsing watchlist file: %v", err)
return
}
for i := range entries {
entry := &entries[i]
if !entry.LastSeen.IsZero() {
entry.LastSeenStr = formatLastSeen(entry.LastSeen)
} else {
entry.LastSeenStr = "Never"
}
w.entries[entry.Callsign] = entry
}
Log.Infof("Loaded %d entries from watchlist", len(w.entries))
}
// save est une méthode privée sans lock (appelée par Add/Remove qui ont déjà le lock)
func (wl *Watchlist) save() error {
data, err := json.Marshal(wl.Callsigns)
// saveUnsafe fait la sauvegarde SANS prendre de lock (à utiliser quand on a déjà un lock)
func (w *Watchlist) saveUnsafe() error {
entries := make([]WatchlistEntry, 0, len(w.entries))
for _, entry := range w.entries {
entries = append(entries, *entry)
}
data, err := json.MarshalIndent(entries, "", " ")
if err != nil {
return err
Log.Errorf("Error marshaling watchlist: %v", err)
return fmt.Errorf("error marshaling watchlist: %w", err)
}
return os.WriteFile(wl.filename, data, 0644)
if err := os.WriteFile(w.filePath, data, 0644); err != nil {
Log.Errorf("Error writing watchlist file %s: %v", w.filePath, err)
return fmt.Errorf("error writing watchlist file: %w", err)
}
Log.Debugf("Watchlist saved successfully (%d entries)", len(entries))
return nil
}
func (wl *Watchlist) Add(callsign string) error {
wl.mu.Lock()
defer wl.mu.Unlock()
// save est la version publique avec lock (pour les appels périodiques depuis httpserver)
func (w *Watchlist) save() error {
w.mutex.RLock()
defer w.mutex.RUnlock()
return w.saveUnsafe()
}
func (w *Watchlist) Add(callsign string) error {
w.mutex.Lock()
defer w.mutex.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
// Check if already exists
for _, c := range wl.Callsigns {
if c == callsign {
return nil // Already in watchlist
}
Log.Debugf("Attempting to add callsign: %s", callsign)
if callsign == "" {
Log.Warn("Attempted to add empty callsign")
return fmt.Errorf("callsign cannot be empty")
}
wl.Callsigns = append(wl.Callsigns, callsign)
return wl.save() // ← Appel de save() minuscule (sans lock)
if _, exists := w.entries[callsign]; exists {
Log.Warnf("Callsign %s already exists in watchlist", callsign)
return fmt.Errorf("callsign already in watchlist")
}
w.entries[callsign] = &WatchlistEntry{
Callsign: callsign,
Notes: "",
AddedAt: time.Now(),
LastSeen: time.Time{},
LastSeenStr: "Never",
SpotCount: 0,
PlaySound: true,
}
Log.Infof("Added %s to watchlist", callsign)
if err := w.saveUnsafe(); err != nil {
Log.Errorf("Failed to save watchlist after adding %s: %v", callsign, err)
return err
}
Log.Debugf("Watchlist saved successfully after adding %s", callsign)
return nil
}
func (wl *Watchlist) Remove(callsign string) error {
wl.mu.Lock()
defer wl.mu.Unlock()
func (w *Watchlist) Remove(callsign string) error {
w.mutex.Lock()
defer w.mutex.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
for i, c := range wl.Callsigns {
if c == callsign {
wl.Callsigns = append(wl.Callsigns[:i], wl.Callsigns[i+1:]...)
return wl.save() // ← Appel de save() minuscule (sans lock)
}
if _, exists := w.entries[callsign]; !exists {
Log.Warnf("Attempted to remove non-existent callsign: %s", callsign)
return fmt.Errorf("callsign not in watchlist")
}
delete(w.entries, callsign)
Log.Infof("Removed %s from watchlist", callsign)
if err := w.saveUnsafe(); err != nil {
Log.Errorf("Failed to save watchlist after removing %s: %v", callsign, err)
return err
}
return nil
}
func (wl *Watchlist) GetAll() []string {
wl.mu.RLock()
defer wl.mu.RUnlock()
func (w *Watchlist) UpdateNotes(callsign, notes string) error {
w.mutex.Lock()
defer w.mutex.Unlock()
result := make([]string, len(wl.Callsigns))
copy(result, wl.Callsigns)
return result
callsign = strings.ToUpper(strings.TrimSpace(callsign))
entry, exists := w.entries[callsign]
if !exists {
Log.Warnf("Attempted to update notes for non-existent callsign: %s", callsign)
return fmt.Errorf("callsign not in watchlist")
}
entry.Notes = notes
Log.Debugf("Updated notes for %s", callsign)
if err := w.saveUnsafe(); err != nil {
Log.Errorf("Failed to save watchlist after updating notes for %s: %v", callsign, err)
return err
}
return nil
}
func (wl *Watchlist) Matches(callsign string) bool {
wl.mu.RLock()
defer wl.mu.RUnlock()
func (w *Watchlist) UpdateSound(callsign string, playSound bool) error {
w.mutex.Lock()
defer w.mutex.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
entry, exists := w.entries[callsign]
if !exists {
Log.Warnf("Attempted to update sound for non-existent callsign: %s", callsign)
return fmt.Errorf("callsign not in watchlist")
}
entry.PlaySound = playSound
Log.Debugf("Updated sound setting for %s to %v", callsign, playSound)
if err := w.saveUnsafe(); err != nil {
Log.Errorf("Failed to save watchlist after updating sound for %s: %v", callsign, err)
return err
}
return nil
}
func (w *Watchlist) MarkSeen(callsign string) {
w.mutex.Lock()
defer w.mutex.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
entry, exists := w.entries[callsign]
if !exists {
return
}
entry.LastSeen = time.Now()
entry.LastSeenStr = formatLastSeen(entry.LastSeen)
entry.SpotCount++
Log.Debugf("Marked %s as seen (count: %d)", callsign, entry.SpotCount)
// Ne PAS sauvegarder à chaque spot - trop lent !
// La sauvegarde sera faite périodiquement par le ticker
}
func (w *Watchlist) Matches(callsign string) bool {
w.mutex.RLock()
defer w.mutex.RUnlock()
callsign = strings.ToUpper(callsign)
for _, pattern := range wl.Callsigns {
// Exact match
if callsign == pattern {
return true
}
// Prefix match (e.g., VK9 matches VK9XX)
if strings.HasPrefix(callsign, pattern) {
for pattern := range w.entries {
if callsign == pattern || strings.HasPrefix(callsign, pattern) {
return true
}
}
return false
}
func (w *Watchlist) GetEntry(callsign string) *WatchlistEntry {
w.mutex.RLock()
defer w.mutex.RUnlock()
callsign = strings.ToUpper(callsign)
for pattern, entry := range w.entries {
if callsign == pattern || strings.HasPrefix(callsign, pattern) {
// Retourner une copie pour éviter les race conditions
entryCopy := *entry
return &entryCopy
}
}
return nil
}
func (w *Watchlist) GetAll() []WatchlistEntry {
w.mutex.RLock()
defer w.mutex.RUnlock()
entries := make([]WatchlistEntry, 0, len(w.entries))
for _, entry := range w.entries {
// Mettre à jour LastSeenStr avant de retourner
entryCopy := *entry
if !entryCopy.LastSeen.IsZero() {
entryCopy.LastSeenStr = formatLastSeen(entryCopy.LastSeen)
}
entries = append(entries, entryCopy)
}
return entries
}
func (w *Watchlist) GetAllCallsigns() []string {
w.mutex.RLock()
defer w.mutex.RUnlock()
callsigns := make([]string, 0, len(w.entries))
for callsign := range w.entries {
callsigns = append(callsigns, callsign)
}
return callsigns
}
func formatLastSeen(t time.Time) string {
if t.IsZero() {
return "Never"
}
duration := time.Since(t)
if duration < time.Minute {
return "Just now"
} else if duration < time.Hour {
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
} else {
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}

View File

@@ -1 +1,290 @@
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD","3B8M","Z66IPA","C8K","5H3MB","SU0ERA"]
[
{
"callsign": "H44MS",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:16:49.1572859+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "SU0ERA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:45.8848244+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "C5LT",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:07.2442738+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "PJ6Y",
"notes": "",
"lastSeen": "2025-10-18T18:44:30.0286129+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:47.7237081+02:00",
"spotCount": 20,
"playSound": true
},
{
"callsign": "PY0FB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:24.3843986+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "5H3MB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:42.8402097+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "YJ0CA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:33.3921665+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "TJ1GD",
"notes": "",
"lastSeen": "2025-10-18T18:45:59.6232796+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:18:27.6004027+02:00",
"spotCount": 10,
"playSound": true
},
{
"callsign": "C8K",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:39.8627992+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "VP2M",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:57.308717+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "XT2AW",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:27.3839089+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "5X2I",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:14.6598633+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "FW5K",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:37.9061157+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "E51MWA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:43.6895454+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "3B8M",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:32.6851135+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "Z66IPA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:36.5251607+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "C21TS",
"notes": "",
"lastSeen": "2025-10-18T18:50:28.7708075+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:18:21.7895474+02:00",
"spotCount": 9,
"playSound": true
},
{
"callsign": "ZL7IO",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:30.7153757+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "V85NPV",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:15.8781583+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "YI1MB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:18.825584+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "C5R",
"notes": "",
"lastSeen": "2025-10-18T18:05:16.1881069+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:18:04.5006892+02:00",
"spotCount": 5,
"playSound": true
},
{
"callsign": "9L8MD",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:56.7896868+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "5K0UA",
"notes": "",
"lastSeen": "2025-10-18T18:48:03.346559+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:53.7390559+02:00",
"spotCount": 10,
"playSound": true
},
{
"callsign": "TZ4AM",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:19:00.3154177+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "9L9L",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:53.3401773+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "5X1XA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:01.2081871+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "VP8LP",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:49.0576187+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "EL2BG",
"notes": "",
"lastSeen": "2025-10-18T18:32:28.3424341+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:18:10.2000017+02:00",
"spotCount": 2,
"playSound": true
},
{
"callsign": "4X6TT",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:13.335878+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "E6AD",
"notes": "",
"lastSeen": "2025-10-18T18:43:14.6994075+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:40.8765179+02:00",
"spotCount": 25,
"playSound": true
},
{
"callsign": "5J0EA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:51.0758741+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "XV9",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:24.9155327+02:00",
"spotCount": 0,
"playSound": true
}
]