This commit is contained in:
2025-10-19 10:15:11 +02:00
parent 26bfd17683
commit 0159c68fa5
17 changed files with 1078 additions and 523 deletions

View File

@@ -15,7 +15,7 @@ import (
log "github.com/sirupsen/logrus"
)
var spotRe *regexp.Regexp = regexp.MustCompile(`DX\sde\s([\w\d]+).*:\s+(\d+.\d)\s+([\w\d\/]+)\s+(CW|cw|SSB|ssb|FT8|ft8|FT4|ft4|RTTY|rtty|USB|usb|LSB|lsb)?\s+(.*)\s\s\s+([\d]+\w{1})`)
var spotRe *regexp.Regexp = regexp.MustCompile(`(?i)DX\sde\s([\w\d\-#]+).*?:\s*(\d+\.\d+)\s+([\w\d\/]+)\s+(?:(CW|SSB|FT8|FT4|RTTY|USB|LSB|FM)\s+)?(.+?)\s+(\d{4}Z)`)
var defaultLoginRe *regexp.Regexp = regexp.MustCompile("[\\w\\d-_]+ login:")
var defaultPasswordRe *regexp.Regexp = regexp.MustCompile("Password:")
@@ -315,6 +315,7 @@ func (c *TCPClient) ReadLine() {
}
if strings.Contains(messageString, "DX") {
IncrementSpotsReceived()
ProcessTelnetSpot(spotRe, messageString, c.SpotChanToFlex, c.SpotChanToHTTPServer, c.Countries, c.ContactRepo)
}

View File

@@ -16,31 +16,34 @@ var (
)
type TCPServer struct {
Address string
Port string
Clients map[net.Conn]bool
Mutex *sync.Mutex
LogWriter *bufio.Writer
Reader *bufio.Reader
Writer *bufio.Writer
Conn net.Conn
Listener net.Listener
MsgChan chan string
CmdChan chan string
Log *log.Logger
Config *Config
MessageSent int
Address string
Port string
Clients map[net.Conn]*ClientInfo // ✅ Map avec structure ClientInfo
Mutex *sync.Mutex
LogWriter *bufio.Writer
Reader *bufio.Reader
Writer *bufio.Writer
Conn net.Conn
Listener net.Listener
MsgChan chan string
CmdChan chan string
Log *log.Logger
Config *Config
}
// ✅ Structure pour stocker les infos client
type ClientInfo struct {
ConnectedAt time.Time
}
func NewTCPServer(address string, port string) *TCPServer {
return &TCPServer{
Address: address,
Port: port,
Clients: make(map[net.Conn]bool),
MsgChan: make(chan string, 100),
CmdChan: make(chan string),
Mutex: new(sync.Mutex),
MessageSent: 0,
Address: address,
Port: port,
Clients: make(map[net.Conn]*ClientInfo),
MsgChan: make(chan string, 100),
CmdChan: make(chan string),
Mutex: new(sync.Mutex),
}
}
@@ -68,8 +71,11 @@ func (s *TCPServer) StartServer() {
Log.Error("Could not accept connections to telnet server")
continue
}
s.Mutex.Lock()
s.Clients[s.Conn] = true
s.Clients[s.Conn] = &ClientInfo{
ConnectedAt: time.Now(), // ✅ Enregistre l'heure de connexion
}
s.Mutex.Unlock()
go s.handleConnection()
@@ -77,7 +83,6 @@ func (s *TCPServer) StartServer() {
}
func (s *TCPServer) handleConnection() {
// Store the connection locally to avoid race conditions
conn := s.Conn
conn.Write([]byte("Welcome to the FlexDXCluster telnet server! Type 'bye' to exit.\n"))
@@ -101,14 +106,13 @@ func (s *TCPServer) handleConnection() {
message = strings.TrimSpace(message)
// if message is bye then disconnect
if message == "bye" {
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
if strings.Contains(message, "DX") || strings.Contains(message, "SH/DX") ||
strings.Contains(message, "set") || strings.Contains(message, "SET") {
select {
case s.CmdChan <- message:
Log.Debugf("Command from client %s: %s", conn.RemoteAddr().String(), message)
@@ -135,24 +139,24 @@ func (s *TCPServer) broadcastMessage(message string) {
return
}
if s.MessageSent == 0 {
time.Sleep(3 * time.Second)
s.MessageSent = 1
// ✅ Si un client vient de se connecter (< 3 secondes), NE RIEN ENVOYER
for _, info := range s.Clients {
if time.Since(info.ConnectedAt) < 3*time.Second {
// ✅ Client trop récent, on DROP le spot
return
}
}
// 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

@@ -122,6 +122,7 @@ func NewFlexDXDatabase(filePath string) *FlexDXClusterRepository {
"timestamp" INTEGER,
"lifeTime" TEXT,
"priority" TEXT,
"originalComment" TEXT,
"comment" TEXT,
"color" TEXT,
"backgroundColor" TEXT,
@@ -458,7 +459,7 @@ func (r *FlexDXClusterRepository) GetAllSpots(limit string) []FlexSpot {
s := FlexSpot{}
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 {
&s.OriginalComment, &s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
return nil // Arrête le traitement s'il y a une erreur sur une ligne
}
@@ -481,7 +482,7 @@ func (r *FlexDXClusterRepository) FindDXSameBand(spot FlexSpot) (*FlexSpot, erro
s := FlexSpot{}
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 {
&s.OriginalComment, &s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
r.Log.Error(err)
return nil, err
}
@@ -490,8 +491,8 @@ func (r *FlexDXClusterRepository) FindDXSameBand(spot FlexSpot) (*FlexSpot, erro
}
func (r *FlexDXClusterRepository) CreateSpot(spot FlexSpot) {
query := "INSERT INTO `spots` (`commandNumber`, `flexSpotNumber`, `dx`, `freqMhz`, `freqHz`, `band`, `mode`, `spotter`, `flexMode`, `source`, `time`, `timestamp`, `lifeTime`, `priority`, `comment`, `color`, `backgroundColor`, `countryName`, `dxcc`, `newDXCC`, `newBand`, `newMode`, `newSlot`, `worked`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
insertResult, err := r.db.ExecContext(context.Background(), query, spot.CommandNumber, spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, time.Now().Unix(), spot.LifeTime, spot.Priority, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked)
query := "INSERT INTO `spots` (`commandNumber`, `flexSpotNumber`, `dx`, `freqMhz`, `freqHz`, `band`, `mode`, `spotter`, `flexMode`, `source`, `time`, `timestamp`, `lifeTime`, `priority`, `originalComment`, `comment`, `color`, `backgroundColor`, `countryName`, `dxcc`, `newDXCC`, `newBand`, `newMode`, `newSlot`, `worked`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
insertResult, err := r.db.ExecContext(context.Background(), query, spot.CommandNumber, spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, time.Now().Unix(), spot.LifeTime, spot.Priority, spot.OriginalComment, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked)
if err != nil {
Log.Errorf("cannot insert spot in database: %s", err)
}
@@ -504,8 +505,8 @@ func (r *FlexDXClusterRepository) CreateSpot(spot FlexSpot) {
}
func (r *FlexDXClusterRepository) UpdateSpotSameBand(spot FlexSpot) error {
_, err := r.db.Exec(`UPDATE spots SET commandNumber = ?, DX = ?, freqMhz = ?, freqHz = ?, band = ?, mode = ?, spotter = ?, flexMode = ?, source = ?, time = ?, timestamp = ?, lifeTime = ?, priority = ?, comment = ?, color = ?, backgroundColor = ?, countryName = ?, dxcc = ?, newDXCC = ?, newBand = ?, newMode = ?, newSlot = ?, worked = ? WHERE DX = ? AND band = ?`,
spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, spot.TimeStamp, spot.LifeTime, spot.Priority, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked, spot.DX, spot.Band)
_, err := r.db.Exec(`UPDATE spots SET commandNumber = ?, DX = ?, freqMhz = ?, freqHz = ?, band = ?, mode = ?, spotter = ?, flexMode = ?, source = ?, time = ?, timestamp = ?, lifeTime = ?, priority = ?, originalComment = ?, comment = ?, color = ?, backgroundColor = ?, countryName = ?, dxcc = ?, newDXCC = ?, newBand = ?, newMode = ?, newSlot = ?, worked = ? WHERE DX = ? AND band = ?`,
spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, spot.TimeStamp, spot.LifeTime, spot.Priority, spot.OriginalComment, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked, spot.DX, spot.Band)
if err != nil {
r.Log.Errorf("could not update database: %s", err)
return err
@@ -525,7 +526,7 @@ func (r *FlexDXClusterRepository) FindSpotByCommandNumber(commandNumber string)
s := FlexSpot{}
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 {
&s.OriginalComment, &s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
r.Log.Error(err)
return nil, err
}
@@ -545,7 +546,7 @@ func (r *FlexDXClusterRepository) FindSpotByFlexSpotNumber(spotNumber string) (*
s := FlexSpot{}
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 {
&s.OriginalComment, &s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
r.Log.Error(err)
return nil, err
}
@@ -565,7 +566,7 @@ func (r *FlexDXClusterRepository) UpdateFlexSpotNumberByID(flexSpotNumber string
s := FlexSpot{}
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 {
&s.OriginalComment, &s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
r.Log.Error(err)
return nil, err
}

View File

@@ -10,6 +10,7 @@
import ErrorBanner from './components/ErrorBanner.svelte';
import { spotWorker } from './lib/spotWorker.js';
import { spotCache } from './lib/spotCache.js';
import LogsTab from './components/LogsTab.svelte';
// State
@@ -39,6 +40,7 @@
let errorMessage = '';
let toastMessage = '';
let toastType = 'info';
let logs = [];
let spotFilters = {
showAll: true,
@@ -328,6 +330,21 @@
spotCache.saveQSOs(recentQSOs).catch(err => console.error('Cache save error:', err));
}
break;
case 'appLog':
// Un seul log applicatif
if (message.data) {
logs = [...logs, message.data];
// Garder seulement les 500 derniers
if (logs.length > 500) {
logs = logs.slice(-500);
}
}
break;
case 'appLogs':
// Logs initiaux (au chargement)
logs = message.data || [];
break;
case 'logStats':
logStats = message.data || {};
break;
@@ -608,6 +625,7 @@ async function shutdownApp() {
{recentQSOs}
{logStats}
{dxccProgress}
{logs}
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
/>
</div>

View File

@@ -0,0 +1,139 @@
<script>
export let logs = [];
let autoScroll = true;
let container;
let selectedLevels = {
debug: true,
info: true,
warning: true,
error: true
};
// ✅ Filtrer les logs par niveau sélectionné
$: filteredLogs = logs.filter(log => {
const level = log.level.toLowerCase();
return selectedLevels[level] || false;
});
// ✅ Auto-scroll UNIQUEMENT si activé
$: if (autoScroll && container && filteredLogs.length > 0) {
setTimeout(() => {
if (autoScroll) { // ✅ Vérifier à nouveau car peut avoir changé
container.scrollTop = container.scrollHeight;
}
}, 10);
}
function getLevelColor(level) {
switch(level.toLowerCase()) {
case 'error': return 'text-red-400';
case 'warning':
case 'warn': return 'text-yellow-400';
case 'info': return 'text-blue-400';
case 'debug': return 'text-slate-400';
default: return 'text-slate-300';
}
}
function getLevelBadge(level) {
switch(level.toLowerCase()) {
case 'error': return 'bg-red-500/20 text-red-400 border-red-500/50';
case 'warning':
case 'warn': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
case 'info': return 'bg-blue-500/20 text-blue-400 border-blue-500/50';
case 'debug': return 'bg-slate-500/20 text-slate-400 border-slate-500/50';
default: return 'bg-slate-500/20 text-slate-300 border-slate-500/50';
}
}
function clearLogs() {
logs = [];
}
function toggleLevel(level) {
selectedLevels[level] = !selectedLevels[level];
}
</script>
<div class="h-full flex flex-col bg-slate-900/50 rounded-lg border border-slate-700/50">
<!-- Header -->
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-bold">Application Logs</h2>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
bind:checked={autoScroll}
class="rounded cursor-pointer">
<span>Auto-scroll</span>
</label>
<button
on:click={clearLogs}
class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded text-sm transition-colors">
Clear Logs
</button>
</div>
</div>
<!-- ✅ Filtres par niveau -->
<div class="flex items-center gap-2 text-xs">
<span class="text-slate-400 mr-2">Show:</span>
<button
on:click={() => toggleLevel('debug')}
class="px-2 py-1 rounded border transition-colors {selectedLevels.debug ? 'bg-slate-500/20 text-slate-400 border-slate-500/50' : 'bg-slate-800/50 text-slate-600 border-slate-700/30'}">
DEBUG
</button>
<button
on:click={() => toggleLevel('info')}
class="px-2 py-1 rounded border transition-colors {selectedLevels.info ? 'bg-blue-500/20 text-blue-400 border-blue-500/50' : 'bg-slate-800/50 text-slate-600 border-slate-700/30'}">
INFO
</button>
<button
on:click={() => toggleLevel('warning')}
class="px-2 py-1 rounded border transition-colors {selectedLevels.warning ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50' : 'bg-slate-800/50 text-slate-600 border-slate-700/30'}">
WARN
</button>
<button
on:click={() => toggleLevel('error')}
class="px-2 py-1 rounded border transition-colors {selectedLevels.error ? 'bg-red-500/20 text-red-400 border-red-500/50' : 'bg-slate-800/50 text-slate-600 border-slate-700/30'}">
ERROR
</button>
<span class="ml-auto text-slate-500">{filteredLogs.length} / {logs.length} logs</span>
</div>
</div>
<!-- Logs container -->
<div
bind:this={container}
class="flex-1 overflow-y-auto p-3 font-mono text-xs"
style="min-height: 0;">
{#if filteredLogs.length === 0}
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" 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>
<p class="text-sm">
{logs.length === 0 ? 'No logs yet' : 'No logs matching selected levels'}
</p>
</div>
{:else}
{#each filteredLogs as log (log.timestamp + log.message)}
<div class="flex gap-3 py-1 hover:bg-slate-800/30 px-2 rounded">
<span class="text-slate-500 flex-shrink-0">{log.timestamp}</span>
<span class="px-2 py-0.5 rounded border text-xs font-semibold flex-shrink-0 {getLevelBadge(log.level)}">
{log.level.toUpperCase()}
</span>
<span class="{getLevelColor(log.level)} flex-1 break-all">{log.message}</span>
</div>
{/each}
{/if}
</div>
</div>

View File

@@ -3,6 +3,7 @@
import StatsTab from './StatsTab.svelte';
import WatchlistTab from './WatchlistTab.svelte';
import LogTab from './LogTab.svelte';
import LogsTab from './LogsTab.svelte';
export let activeTab;
export let topSpotters;
@@ -12,6 +13,7 @@
export let logStats;
export let dxccProgress;
export let showOnlyActive = false; // ✅ Export pour persister l'état
export let logs = [];
const dispatch = createEventDispatcher();
@@ -49,8 +51,18 @@
<svg class="w-4 h-4 inline-block mr-1" 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>
Log
Log4OM
</button>
<button
class="px-4 py-2 text-sm font-semibold transition-colors {activeTab === 'logs' ? 'bg-blue-500/20 text-blue-400 border-b-2 border-blue-500' : 'text-slate-400 hover:text-slate-300'}"
on:click={() => activeTab = 'logs'}>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
AppLogs
</button>
</div>
<!-- Tab Content -->
@@ -70,6 +82,8 @@
{logStats}
{dxccProgress}
/>
{:else if activeTab === 'logs'}
<LogsTab {logs} />
{/if}
</div>
</div>

View File

@@ -51,7 +51,6 @@
}
function getCleanComment(spot) {
// Retirer le commentaire original brut s'il existe
if (!spot.OriginalComment) return '';
return spot.OriginalComment.trim();
}
@@ -67,11 +66,11 @@
<div class="flex text-left text-xs text-slate-400 font-semibold">
<div class="p-2" style="width: 10%;">DX</div>
<div class="p-2" style="width: 18%;">Country</div>
<div class="p-2" style="width: 7%;">Time</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>
@@ -92,6 +91,7 @@
<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 text-slate-400 text-xs" style="width: 7%;">{item.UTCTime}</div>
<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>
@@ -102,7 +102,6 @@
<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: 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>

View File

@@ -10,25 +10,49 @@
}
</script>
<div class="grid grid-cols-[repeat(4,1fr)_auto] gap-3 mb-3 items-center">
<!-- Total Spots -->
<div class="grid grid-cols-[repeat(6,1fr)_auto] gap-3 mb-3 items-center">
<!-- Spots Received -->
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div class="text-xl font-bold text-blue-400">{stats.totalSpots}</div>
<div class="text-xl font-bold text-cyan-400">{stats.spotsReceived || 0}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Total Spots</p>
<p class="text-xs text-slate-400 mt-1">Received</p>
</div>
<!-- Spots Processed -->
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xl font-bold text-green-400">{stats.spotsProcessed || 0}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Processed</p>
</div>
<!-- Success Rate -->
<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 {stats.spotSuccessRate >= 95 ? 'text-green-400' : stats.spotSuccessRate >= 80 ? 'text-yellow-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<div class="text-xl font-bold {stats.spotSuccessRate >= 95 ? 'text-green-400' : stats.spotSuccessRate >= 80 ? 'text-yellow-400' : 'text-red-400'}">
{stats.spotSuccessRate ? stats.spotSuccessRate.toFixed(1) : '0.0'}%
</div>
</div>
<p class="text-xs text-slate-400 mt-1">Success Rate</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">
<svg class="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xl font-bold text-green-400">{stats.newDXCC}</div>
<div class="text-xl font-bold text-emerald-400">{stats.newDXCC}</div>
</div>
<p class="text-xs text-slate-400 mt-1">New DXCC</p>
</div>

View File

@@ -21,6 +21,7 @@ import (
//go:embed frontend/dist/*
var frontendFiles embed.FS
var httpServerInstance *HTTPServer
type HTTPServer struct {
Router *mux.Router
@@ -52,6 +53,10 @@ type Stats struct {
MyCallsign string `json:"myCallsign"`
Mode string `json:"mode"`
Filters Filters `json:"filters"`
SpotsReceived int64 `json:"spotsReceived"`
SpotsProcessed int64 `json:"spotsProcessed"`
SpotsRejected int64 `json:"spotsRejected"`
SpotSuccessRate float64 `json:"spotSuccessRate"`
}
type Filters struct {
@@ -131,6 +136,8 @@ func NewHTTPServer(flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContact
lastBandOpening: make(map[string]time.Time),
}
httpServerInstance = server
server.setupRoutes()
go server.handleBroadcasts()
go server.broadcastUpdates()
@@ -162,6 +169,8 @@ func (s *HTTPServer) setupRoutes() {
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")
api.HandleFunc("/stats/spots", s.getSpotProcessingStats).Methods("GET", "OPTIONS")
api.HandleFunc("/logs", s.getLogs).Methods("GET", "OPTIONS")
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
@@ -268,6 +277,11 @@ func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
"percentage": float64(dxccCount) / 340.0 * 100.0,
}
conn.WriteJSON(WSMessage{Type: "dxccProgress", Data: dxccData})
if logBuffer != nil {
logs := logBuffer.GetAll()
conn.WriteJSON(WSMessage{Type: "appLogs", Data: logs})
}
}
func (s *HTTPServer) handleBroadcasts() {
@@ -355,6 +369,30 @@ func (s *HTTPServer) broadcastUpdates() {
}
}
func (s *HTTPServer) getLogs(w http.ResponseWriter, r *http.Request) {
if logBuffer == nil {
s.sendJSON(w, APIResponse{Success: true, Data: []LogEntry{}})
return
}
logs := logBuffer.GetAll()
s.sendJSON(w, APIResponse{Success: true, Data: logs})
}
func (s *HTTPServer) getSpotProcessingStats(w http.ResponseWriter, r *http.Request) {
received, processed, rejected := GetSpotStats()
successRate := GetSpotSuccessRate()
stats := map[string]interface{}{
"received": received,
"processed": processed,
"rejected": rejected,
"successRate": successRate,
}
s.sendJSON(w, APIResponse{Success: true, Data: stats})
}
func (s *HTTPServer) checkQSOMilestones(todayCount int) {
s.statsMutex.Lock()
defer s.statsMutex.Unlock()
@@ -463,6 +501,10 @@ func (s *HTTPServer) calculateStats() Stats {
flexStatus = "connected"
}
// Récupérer les stats de traitement des spots
received, processed, rejected := GetSpotStats()
successRate := GetSpotSuccessRate()
return Stats{
TotalSpots: len(allSpots),
NewDXCC: newDXCCCount,
@@ -477,6 +519,10 @@ func (s *HTTPServer) calculateStats() Stats {
FT4: Cfg.Cluster.FT4,
Beacon: Cfg.Cluster.Beacon,
},
SpotsReceived: received,
SpotsProcessed: processed,
SpotsRejected: rejected,
SpotSuccessRate: successRate,
}
}
@@ -901,6 +947,7 @@ func (s *HTTPServer) sendJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
// ✅ Fonction de shutdown propre
func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) {
s.Log.Info("Shutdown request received from dashboard")
@@ -908,7 +955,10 @@ func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) {
go func() {
time.Sleep(500 * time.Millisecond)
s.Log.Info("Initiating shutdown...")
// ✅ Utiliser le shutdown centralisé
GracefulShutdown(s.TCPClient, s.TCPServer, s.FlexClient, s.FlexRepo, s.ContactRepo)
os.Exit(0)
}()
}

130
log.go
View File

@@ -1,42 +1,102 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
log "github.com/sirupsen/logrus"
prefixed "github.com/x-cray/logrus-prefixed-formatter"
)
var Log *log.Logger
var logFile *os.File
var logWriter *syncWriter
var logCtx context.Context
var logCancel context.CancelFunc
// syncWriter écrit de manière synchrone (pas de buffer)
type syncWriter struct {
file *os.File
mutex sync.Mutex
}
func (w *syncWriter) Write(p []byte) (n int, err error) {
w.mutex.Lock()
defer w.mutex.Unlock()
n, err = w.file.Write(p)
if err == nil {
w.file.Sync() // Force l'écriture immédiate sur disque
}
return n, err
}
func NewLog() *log.Logger {
// ✅ Vérifier que Cfg existe
if Cfg == nil {
panic("Config not initialized! Call NewConfig() before NewLog()")
}
// ✅ Chemin du log à côté de l'exe
exe, _ := os.Executable()
exePath := filepath.Dir(exe)
logPath := filepath.Join(exePath, "flexradio.log")
if Cfg.General.DeleteLogFileAtStart {
if _, err := os.Stat("flexradio.log"); err == nil {
os.Remove("flexradio.log")
if _, err := os.Stat(logPath); err == nil {
os.Remove(logPath)
}
}
logCtx, logCancel = context.WithCancel(context.Background())
var w io.Writer
if Cfg.General.LogToFile {
f, err := os.OpenFile("flexradio.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
panic(err)
panic(fmt.Sprintf("Cannot open log file %s: %v", logPath, err))
}
logFile = f
logWriter = &syncWriter{file: f}
// ✅ IMPORTANT: Vérifier si Stdout est disponible (mode console vs GUI)
if isConsoleAvailable() {
// Mode console : log vers fichier ET console
w = io.MultiWriter(os.Stdout, logWriter)
} else {
// Mode GUI (windowsgui) : log SEULEMENT vers fichier
w = logWriter
}
w = io.MultiWriter(os.Stdout, f)
} else {
w = io.Writer(os.Stdout)
// Log uniquement vers console (si disponible)
if isConsoleAvailable() {
w = os.Stdout
} else {
// Pas de console, pas de log fichier -> log vers null
w = io.Discard
}
}
Log = &log.Logger{
Out: w,
Formatter: &prefixed.TextFormatter{
DisableColors: false,
TimestampFormat: "02-01-2006 15:04:05",
FullTimestamp: true,
ForceFormatting: true,
DisableColors: !isConsoleAvailable(),
TimestampFormat: "02-01-2006 15:04:05",
FullTimestamp: true,
ForceFormatting: true,
DisableSorting: true, // ✅ Ajoute
QuoteEmptyFields: true, // ✅ Ajoute
SpacePadding: 0, // ✅ Ajoute (pas d'espace)
},
Hooks: make(log.LevelHooks),
}
if Cfg.General.LogLevel == "DEBUG" {
@@ -49,5 +109,55 @@ func NewLog() *log.Logger {
Log.Level = log.InfoLevel
}
logBuffer = NewLogBuffer(500) // Garde les 500 derniers logs
// Log.AddHook(&LogHook{buffer: logBuffer})
// ✅ Premier vrai log
Log.Infof("Logger initialized - Level: %s, ToFile: %v, LogPath: %s",
Cfg.General.LogLevel, Cfg.General.LogToFile, logPath)
return Log
}
func InitLogHook() {
if logBuffer == nil {
logBuffer = NewLogBuffer(500)
}
Log.AddHook(&LogHook{buffer: logBuffer})
Log.Info("Log hook initialized and broadcasting enabled")
}
// ✅ Détecter si on a une console (fonctionne sur Windows)
func isConsoleAvailable() bool {
// Si Stdout est nil ou invalide, on n'a pas de console
stat, err := os.Stdout.Stat()
if err != nil {
return false
}
// Si c'est un char device, on a une console
return (stat.Mode() & os.ModeCharDevice) != 0
}
// ✅ Fonction pour fermer proprement le log
func CloseLog() {
if Log != nil {
Log.Info("Closing log file...")
}
if logCancel != nil {
logCancel()
}
time.Sleep(200 * time.Millisecond) // Donne le temps d'écrire
if logWriter != nil {
logWriter.mutex.Lock()
if logFile != nil {
logFile.Sync()
logFile.Close()
logFile = nil
}
logWriter.mutex.Unlock()
}
}

80
loghook.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"sync"
log "github.com/sirupsen/logrus"
)
// LogBuffer garde les derniers logs en mémoire
type LogBuffer struct {
entries []LogEntry
maxSize int
mutex sync.RWMutex
}
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
}
var logBuffer *LogBuffer
func NewLogBuffer(maxSize int) *LogBuffer {
return &LogBuffer{
entries: make([]LogEntry, 0, maxSize),
maxSize: maxSize,
}
}
func (lb *LogBuffer) Add(entry LogEntry) {
lb.mutex.Lock()
defer lb.mutex.Unlock()
lb.entries = append(lb.entries, entry)
// Garder seulement les N derniers
if len(lb.entries) > lb.maxSize {
lb.entries = lb.entries[1:]
}
}
func (lb *LogBuffer) GetAll() []LogEntry {
lb.mutex.RLock()
defer lb.mutex.RUnlock()
// Retourner une copie
result := make([]LogEntry, len(lb.entries))
copy(result, lb.entries)
return result
}
// Hook pour capturer les logs
type LogHook struct {
buffer *LogBuffer
}
func (h *LogHook) Levels() []log.Level {
return log.AllLevels
}
func (h *LogHook) Fire(entry *log.Entry) error {
logEntry := LogEntry{
Timestamp: entry.Time.Format("02-01-2006 15:04:05"),
Level: entry.Level.String(),
Message: entry.Message,
}
h.buffer.Add(logEntry)
// Broadcaster vers les clients WebSocket connectés
if httpServerInstance != nil {
httpServerInstance.broadcast <- WSMessage{
Type: "appLog",
Data: logEntry,
}
}
return nil
}

49
main.go
View File

@@ -33,6 +33,35 @@ func ParseFlags() (string, error) {
return configPath, nil
}
func GracefulShutdown(tcpClient *TCPClient, tcpServer *TCPServer, flexClient *FlexClient, flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository) {
Log.Info("Starting graceful shutdown...")
// Fermer les clients
if tcpClient != nil {
tcpClient.Close()
}
if flexClient != nil {
flexClient.Close()
}
// Fermer les serveurs
if tcpServer != nil {
// tcpServer.Close() si tu as une méthode close
}
// Fermer les bases de données
if flexRepo != nil && flexRepo.db != nil {
flexRepo.db.Close()
}
if contactRepo != nil && contactRepo.db != nil {
contactRepo.db.Close()
}
// ✅ Fermer le log en dernier
Log.Info("Shutdown complete")
CloseLog()
}
func main() {
// Generate our config based on the config supplied
@@ -42,17 +71,18 @@ func main() {
log.Fatal(err)
}
cfg := NewConfig(cfgPath)
NewConfig(cfgPath)
log := NewLog()
defer CloseLog()
log.Info("Running FlexDXCluster version 2.1")
log.Infof("Callsign: %s", cfg.General.Callsign)
log.Infof("Callsign: %s", Cfg.General.Callsign)
DeleteDatabase("./flex.sqlite", log)
log.Debugf("Gotify Push Enabled: %v", cfg.Gotify.Enable)
if cfg.Gotify.Enable {
log.Debugf("Gotify Push NewDXCC: %v - NewBand: %v - NewMode: %v - NewBandAndMode: %v", cfg.Gotify.NewDXCC, cfg.Gotify.NewBand, cfg.Gotify.NewMode, cfg.Gotify.NewBandAndMode)
log.Debugf("Gotify Push Enabled: %v", Cfg.Gotify.Enable)
if Cfg.Gotify.Enable {
log.Debugf("Gotify Push NewDXCC: %v - NewBand: %v - NewMode: %v - NewBandAndMode: %v", Cfg.Gotify.NewDXCC, Cfg.Gotify.NewBand, Cfg.Gotify.NewMode, Cfg.Gotify.NewBandAndMode)
}
// Load country.xml to get all the DXCC number
@@ -64,7 +94,7 @@ func main() {
defer fRepo.db.Close()
// Database connection to Log4OM
cRepo := NewLog4OMContactsRepository(cfg.SQLite.SQLitePath)
cRepo := NewLog4OMContactsRepository(Cfg.SQLite.SQLitePath)
defer cRepo.db.Close()
contacts := cRepo.CountEntries()
log.Infof("Log4OM Database Contains %v Contacts", contacts)
@@ -73,12 +103,13 @@ func main() {
SpotChanToHTTPServer := make(chan TelnetSpot, 100)
// Initialize servers and clients
TCPServer := NewTCPServer(cfg.TelnetServer.Host, cfg.TelnetServer.Port)
TCPServer := NewTCPServer(Cfg.TelnetServer.Host, Cfg.TelnetServer.Port)
TCPClient := NewTCPClient(TCPServer, Countries, cRepo, SpotChanToHTTPServer)
FlexClient := NewFlexClient(*fRepo, TCPServer, nil, nil)
// Initialize HTTP Server for Dashboard
HTTPServer := NewHTTPServer(fRepo, cRepo, TCPServer, TCPClient, FlexClient, "8080")
InitLogHook()
FlexClient.HTTPServer = HTTPServer
@@ -92,8 +123,8 @@ func main() {
go TCPServer.StartServer()
go HTTPServer.Start()
log.Infof("Telnet Server: %s:%s", cfg.TelnetServer.Host, cfg.TelnetServer.Port)
log.Infof("Cluster: %s:%s", cfg.Cluster.Server, cfg.Cluster.Port)
log.Infof("Telnet Server: %s:%s", Cfg.TelnetServer.Host, Cfg.TelnetServer.Port)
log.Infof("Cluster: %s:%s", Cfg.Cluster.Server, Cfg.Cluster.Port)
CheckSignal(TCPClient, TCPServer, FlexClient, fRepo, cRepo)

627
spot.go
View File

@@ -32,123 +32,136 @@ func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan Te
match := re.FindStringSubmatch(spotRaw)
if len(match) != 0 {
spot := TelnetSpot{
DX: match[3],
Spotter: match[1],
Frequency: match[2],
Mode: match[4],
Comment: strings.Trim(match[5], " "),
Time: match[6],
}
DXCC := GetDXCC(spot.DX, Countries)
spot.DXCC = DXCC.DXCC
spot.CountryName = DXCC.CountryName
if spot.DXCC == "" {
Log.Errorf("Could not identify the DXCC for %s", spot.DX)
return
}
spot.GetBand()
spot.GuessMode()
spot.CallsignWorked = false
spot.NewBand = false
spot.NewMode = false
spot.NewDXCC = false
spot.NewSlot = false
contactsChan := make(chan []Contact)
contactsModeChan := make(chan []Contact)
contactsModeBandChan := make(chan []Contact)
contactsBandChan := make(chan []Contact)
contactsCallChan := make(chan []Contact)
wg := new(sync.WaitGroup)
wg.Add(5)
go contactRepo.ListByCountry(spot.DXCC, contactsChan, wg)
contacts := <-contactsChan
go contactRepo.ListByCountryMode(spot.DXCC, spot.Mode, contactsModeChan, wg)
contactsMode := <-contactsModeChan
go contactRepo.ListByCountryBand(spot.DXCC, spot.Band, contactsBandChan, wg)
contactsBand := <-contactsBandChan
go contactRepo.ListByCallSign(spot.DX, spot.Band, spot.Mode, contactsCallChan, wg)
contactsCall := <-contactsCallChan
go contactRepo.ListByCountryModeBand(spot.DXCC, spot.Band, spot.Mode, contactsModeBandChan, wg)
contactsModeBand := <-contactsModeBandChan
wg.Wait()
if len(contacts) == 0 {
spot.NewDXCC = true
}
if len(contactsMode) == 0 {
spot.NewMode = true
}
if len(contactsBand) == 0 {
spot.NewBand = true
}
if len(contactsModeBand) == 0 && !spot.NewDXCC && !spot.NewBand && !spot.NewMode {
spot.NewSlot = true
}
if len(contactsCall) > 0 {
spot.CallsignWorked = true
}
// Envoyer TOUJOURS le spot vers le processeur principal (base de données + HTTP)
// Ce canal est maintenant géré par une goroutine dans main.go
select {
case SpotChanToHTTPServer <- spot:
// Spot envoyé avec succès
default:
Log.Warn("SpotChanToHTTPServer is full, spot may be lost")
}
// Logging des spots
if spot.NewDXCC {
Log.Debugf("(** New DXCC **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && spot.NewBand && spot.NewMode {
Log.Debugf("(** New Band/Mode **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && spot.NewBand && !spot.NewMode {
Log.Debugf("(** New Band **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && !spot.NewBand && spot.NewMode && spot.Mode != "" {
Log.Debugf("(** New Mode **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && spot.NewSlot && spot.Mode != "" {
Log.Debugf("(** New Slot **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && spot.CallsignWorked {
Log.Debugf("(** Worked **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && !spot.CallsignWorked {
Log.Debugf("DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
}
if len(match) == 0 {
IncrementSpotsRejected()
Log.Warnf("❌ Regex no match: %s", spotRaw)
return
}
spot := TelnetSpot{
DX: match[3],
Spotter: match[1],
Frequency: match[2],
Mode: match[4],
Comment: strings.Trim(match[5], " "),
Time: match[6],
}
DXCC := GetDXCC(spot.DX, Countries)
spot.DXCC = DXCC.DXCC
spot.CountryName = DXCC.CountryName
if spot.DXCC == "" {
IncrementSpotsRejected()
Log.Warnf("❌ DXCC not found: %s", spot.DX)
return
}
spot.GetBand()
spot.GuessMode(spotRaw)
spot.CallsignWorked = false
spot.NewBand = false
spot.NewMode = false
spot.NewDXCC = false
spot.NewSlot = false
contactsChan := make(chan []Contact)
contactsModeChan := make(chan []Contact)
contactsModeBandChan := make(chan []Contact)
contactsBandChan := make(chan []Contact)
contactsCallChan := make(chan []Contact)
wg := new(sync.WaitGroup)
wg.Add(5)
go contactRepo.ListByCountry(spot.DXCC, contactsChan, wg)
contacts := <-contactsChan
go contactRepo.ListByCountryMode(spot.DXCC, spot.Mode, contactsModeChan, wg)
contactsMode := <-contactsModeChan
go contactRepo.ListByCountryBand(spot.DXCC, spot.Band, contactsBandChan, wg)
contactsBand := <-contactsBandChan
go contactRepo.ListByCallSign(spot.DX, spot.Band, spot.Mode, contactsCallChan, wg)
contactsCall := <-contactsCallChan
go contactRepo.ListByCountryModeBand(spot.DXCC, spot.Band, spot.Mode, contactsModeBandChan, wg)
contactsModeBand := <-contactsModeBandChan
wg.Wait()
// ✅ Déterminer le statut
if len(contacts) == 0 {
spot.NewDXCC = true
}
if len(contactsMode) == 0 {
spot.NewMode = true
}
if len(contactsBand) == 0 {
spot.NewBand = true
}
if len(contactsModeBand) == 0 && !spot.NewDXCC && !spot.NewBand && !spot.NewMode {
spot.NewSlot = true
}
if len(contactsCall) > 0 {
spot.CallsignWorked = true
}
// ✅ Envoyer le spot
select {
case SpotChanToHTTPServer <- spot:
IncrementSpotsProcessed()
default:
IncrementSpotsRejected()
Log.Errorf("❌ Spot dropped (channel full): %s @ %s", spot.DX, spot.Frequency)
return
}
// ✅ LOGS CONCIS ET ADAPTES
statusIcon := ""
statusText := ""
if spot.NewDXCC {
statusIcon = "🆕"
statusText = "NEW DXCC"
} else if spot.NewBand && spot.NewMode {
statusIcon = "📻"
statusText = "NEW BAND+MODE"
} else if spot.NewBand {
statusIcon = "📡"
statusText = "NEW BAND"
} else if spot.NewMode {
statusIcon = "🔧"
statusText = "NEW MODE"
} else if spot.NewSlot {
statusIcon = "✨"
statusText = "NEW SLOT"
} else if spot.CallsignWorked {
statusIcon = "✓"
statusText = "WORKED"
} else {
statusIcon = "·"
statusText = "SPOT"
}
// ✅ Log unique et concis
Log.Debugf("%s [%s] %s on %.1f kHz (%s %s) - %s @ %s",
statusIcon,
statusText,
spot.DX,
mustParseFloat(spot.Frequency),
spot.Band,
spot.Mode,
spot.CountryName,
spot.Time,
)
}
// ✅ Helper pour convertir la fréquence
func mustParseFloat(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
}
func (spot *TelnetSpot) GetBand() {
@@ -216,189 +229,231 @@ func (spot *TelnetSpot) GetBand() {
}
}
func (spot *TelnetSpot) GuessMode() {
func (spot *TelnetSpot) GuessMode(rawSpot string) {
// ✅ D'ABORD : Chercher le mode dans le commentaire
if spot.Mode == "" {
spot.Mode = extractModeFromComment(spot.Comment)
if spot.Mode != "" {
Log.Debugf("Mode extracted from comment: %s", spot.Mode)
}
}
// ✅ Normaliser SSB avant de deviner
if spot.Mode == "SSB" {
if spot.Band == "10M" || spot.Band == "12M" || spot.Band == "6M" || spot.Band == "15M" || spot.Band == "17M" || spot.Band == "20M" {
spot.Mode = "USB"
} else {
spot.Mode = "LSB"
}
Log.Debugf("Converted SSB to %s for band %s", spot.Mode, spot.Band)
return
}
// ✅ Si pas de mode, deviner depuis la fréquence
if spot.Mode == "" {
freqInt, err := strconv.ParseFloat(spot.Frequency, 32)
Log.Debugf("No mode specified in spot, will guess from frequency %v with spot: %s", freqInt, strings.TrimSpace(rawSpot))
if err != nil {
Log.Errorf("could not convert frequency string in float64:", err)
Log.Errorf("could not convert frequency string to float: %v", err)
return
}
switch spot.Band {
case "160M":
if freqInt >= 1800 && freqInt <= 1840 {
case "160M": // 1.800 - 2.000 MHz
if freqInt < 1838 {
spot.Mode = "CW"
}
if freqInt >= 1840 && freqInt <= 1844 {
} else if freqInt < 1843 {
spot.Mode = "FT8"
}
case "80M":
if freqInt >= 3500 && freqInt < 3568 {
spot.Mode = "CW"
}
if freqInt >= 3568 && freqInt < 3573 {
spot.Mode = "FT4"
}
if freqInt >= 3573 && freqInt < 3580 {
spot.Mode = "FT8"
}
if freqInt >= 3580 && freqInt < 3600 {
spot.Mode = "CW"
}
if freqInt >= 3600 && freqInt <= 3800 {
spot.Mode = "LSB"
}
case "60M":
if freqInt >= 5351.5 && freqInt < 5354 {
spot.Mode = "CW"
}
if freqInt >= 5354 && freqInt < 5366 {
spot.Mode = "LSB"
}
if freqInt >= 5366 && freqInt <= 5266.5 {
spot.Mode = "FT8"
}
case "40M":
if freqInt >= 7000 && freqInt < 7045.5 {
spot.Mode = "CW"
}
if freqInt >= 7045.5 && freqInt < 7048.5 {
spot.Mode = "FT4"
}
if freqInt >= 7048.5 && freqInt < 7074 {
spot.Mode = "CW"
}
if freqInt >= 7074 && freqInt < 7078 {
spot.Mode = "FT8"
}
if freqInt >= 7078 && freqInt <= 7300 {
spot.Mode = "LSB"
}
case "30M":
if freqInt >= 10100 && freqInt < 10130 {
spot.Mode = "CW"
}
if freqInt >= 10130 && freqInt < 10140 {
spot.Mode = "FT8"
}
if freqInt >= 10140 && freqInt <= 10150 {
spot.Mode = "FT4"
}
case "20M":
if freqInt >= 14000 && freqInt < 14074 {
spot.Mode = "CW"
}
if freqInt >= 14074 && freqInt < 14078 {
spot.Mode = "FT8"
}
if freqInt >= 14078 && freqInt < 14083 {
spot.Mode = "FT4"
}
if freqInt >= 14083 && freqInt < 14119 {
spot.Mode = "FT8"
}
if freqInt >= 14119 && freqInt < 14350 {
spot.Mode = "USB"
}
case "17M":
if freqInt >= 18068 && freqInt < 18090 {
spot.Mode = "CW"
}
if freqInt >= 18090 && freqInt < 18104 {
spot.Mode = "FT8"
}
if freqInt >= 18104 && freqInt < 18108 {
spot.Mode = "FT4"
}
if freqInt >= 18108 && freqInt <= 18168 {
spot.Mode = "USB"
}
case "15M":
if freqInt >= 21000 && freqInt < 21074 {
spot.Mode = "CW"
}
if freqInt >= 21074 && freqInt < 21100 {
spot.Mode = "FT8"
}
if freqInt >= 21100 && freqInt < 21140 {
spot.Mode = "RTTY"
}
if freqInt >= 21140 && freqInt < 21144 {
spot.Mode = "FT4"
}
if freqInt >= 21144 && freqInt <= 21450 {
spot.Mode = "USB"
}
case "12M":
if freqInt >= 24890 && freqInt < 24910 {
spot.Mode = "CW"
}
if freqInt >= 24910 && freqInt < 24919 {
spot.Mode = "FT8"
}
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 < 28070 {
spot.Mode = "CW"
}
if freqInt >= 28070 && freqInt < 28080 {
spot.Mode = "FT8"
}
if freqInt >= 28080 && freqInt < 28100 {
spot.Mode = "RTTY"
}
if freqInt >= 28100 && freqInt < 28180 {
spot.Mode = "CW"
}
if freqInt >= 28180 && freqInt < 28190 {
spot.Mode = "FT4"
}
if freqInt >= 28190 && freqInt < 29000 {
spot.Mode = "USB"
}
if freqInt >= 29000 && freqInt <= 29700 {
spot.Mode = "FM"
}
case "6M":
if freqInt >= 50000 && freqInt < 50100 {
spot.Mode = "CW"
}
if freqInt >= 50100 && freqInt < 50313 {
spot.Mode = "USB"
}
if freqInt >= 50313 && freqInt < 50320 {
spot.Mode = "FT8"
}
if freqInt >= 50320 && freqInt < 50400 {
spot.Mode = "USB"
}
if freqInt >= 50400 && freqInt < +52000 {
spot.Mode = "FM"
}
}
} else {
spot.Mode = strings.ToUpper(spot.Mode)
if spot.Mode == "SSB" {
if spot.Band == "10M" || spot.Band == "12M" || spot.Band == "6M" || spot.Band == "15M" || spot.Band == "17M" || spot.Band == "20M" {
spot.Mode = "USB"
} else {
spot.Mode = "LSB"
}
case "80M": // 3.500 - 4.000 MHz
if freqInt < 3570 {
spot.Mode = "CW"
} else if freqInt < 3575 {
spot.Mode = "FT4"
} else if freqInt < 3578 {
spot.Mode = "FT8"
} else if freqInt < 3590 {
spot.Mode = "RTTY"
} else {
spot.Mode = "LSB"
}
case "60M": // 5.330 - 5.405 MHz
if freqInt < 5357 {
spot.Mode = "CW"
} else if freqInt < 5359 {
spot.Mode = "FT8"
} else {
spot.Mode = "USB"
}
case "40M": // 7.000 - 7.300 MHz
if freqInt < 7040 {
spot.Mode = "CW"
} else if freqInt < 7047 {
spot.Mode = "RTTY"
} else if freqInt < 7050 {
spot.Mode = "FT4"
} else if freqInt < 7080 {
spot.Mode = "FT8" // ✅ 7.056 = FT8
} else if freqInt < 7125 {
spot.Mode = "RTTY" // ✅ 7.112 = RTTY
} else {
spot.Mode = "LSB"
}
case "30M": // 10.100 - 10.150 MHz (CW/Digital seulement)
if freqInt < 10130 {
spot.Mode = "CW"
} else if freqInt < 10142 {
spot.Mode = "FT8"
} else {
spot.Mode = "FT4"
}
case "20M": // 14.000 - 14.350 MHz
if freqInt < 14070 {
spot.Mode = "CW"
} else if freqInt < 14078 {
spot.Mode = "FT8"
} else if freqInt < 14083 {
spot.Mode = "FT4"
} else if freqInt < 14112 {
spot.Mode = "RTTY"
} else {
spot.Mode = "USB"
}
case "17M": // 18.068 - 18.168 MHz
if freqInt < 18090 {
spot.Mode = "CW"
} else if freqInt < 18104 {
spot.Mode = "FT8"
} else if freqInt < 18106 {
spot.Mode = "FT4"
} else if freqInt < 18110 {
spot.Mode = "RTTY"
} else {
spot.Mode = "USB"
}
case "15M": // 21.000 - 21.450 MHz
if freqInt < 21070 {
spot.Mode = "CW"
} else if freqInt < 21078 {
spot.Mode = "FT8"
} else if freqInt < 21120 {
spot.Mode = "RTTY"
} else if freqInt < 21143 {
spot.Mode = "FT4"
} else {
spot.Mode = "USB"
}
case "12M": // 24.890 - 24.990 MHz
if freqInt < 24910 {
spot.Mode = "CW" // ✅ 24.896 = CW
} else if freqInt < 24918 {
spot.Mode = "FT8"
} else if freqInt < 24922 {
spot.Mode = "FT4"
} else if freqInt < 24930 {
spot.Mode = "RTTY"
} else {
spot.Mode = "USB"
}
case "10M": // 28.000 - 29.700 MHz
if freqInt < 28070 {
spot.Mode = "CW"
} else if freqInt < 28095 {
spot.Mode = "FT8"
} else if freqInt < 28179 {
spot.Mode = "RTTY"
} else if freqInt < 28190 {
spot.Mode = "FT4"
} else if freqInt < 29000 {
spot.Mode = "USB"
} else {
spot.Mode = "FM"
}
case "6M": // 50.000 - 54.000 MHz
if freqInt < 50100 {
spot.Mode = "CW"
} else if freqInt < 50313 {
spot.Mode = "USB" // ✅ DX Window + général
} else if freqInt < 50318 {
spot.Mode = "FT8" // ✅ 50.313-50.318
} else if freqInt < 50323 {
spot.Mode = "FT4" // ✅ 50.318-50.323
} else if freqInt < 51000 {
spot.Mode = "USB" // ✅ Retour à USB
} else {
spot.Mode = "FM"
}
default:
// ✅ Bande inconnue
if freqInt < 10.0 {
spot.Mode = "LSB"
} else {
spot.Mode = "USB"
}
}
if spot.Mode != "" {
Log.Debugf("✅ Guessed mode %s for %s on %s MHz (band %s)", spot.Mode, spot.DX, spot.Frequency, spot.Band)
} else {
Log.Warnf("❌ Could not guess mode for %s on %s MHz (band %s), raw spot: %s", spot.DX, spot.Frequency, spot.Band, rawSpot)
}
} else {
spot.Mode = strings.ToUpper(spot.Mode)
}
}
// ✅ Extraire le mode depuis le commentaire
func extractModeFromComment(comment string) string {
commentUpper := strings.ToUpper(comment)
// ✅ 1. Détecter FT8/FT4 avec leurs patterns typiques (dB + Hz)
if strings.Contains(commentUpper, "FT8") ||
(strings.Contains(commentUpper, "DB") && strings.Contains(commentUpper, "HZ")) {
return "FT8"
}
if strings.Contains(commentUpper, "FT4") {
return "FT4"
}
// ✅ 2. Détecter CW avec WPM (Words Per Minute)
if strings.Contains(commentUpper, "WPM") || strings.Contains(commentUpper, " CW ") ||
strings.HasSuffix(commentUpper, "CW") || strings.HasPrefix(commentUpper, "CW ") {
return "CW"
}
// ✅ 3. Autres modes digitaux
digitalModes := []string{"RTTY", "PSK31", "PSK63", "PSK", "MFSK", "OLIVIA", "CONTESTIA", "JT65", "JT9"}
for _, mode := range digitalModes {
if strings.Contains(commentUpper, mode) {
return mode
}
}
if spot.Mode == "" {
Log.Errorf("Could not identify mode for %s on %s", spot.DX, spot.Frequency)
// ✅ 4. Modes voice
voiceModes := []string{"USB", "LSB", "SSB", "FM", "AM"}
for _, mode := range voiceModes {
// Chercher le mode comme mot complet (pas dans "SSBC" par exemple)
if strings.Contains(commentUpper, " "+mode+" ") ||
strings.HasPrefix(commentUpper, mode+" ") ||
strings.HasSuffix(commentUpper, " "+mode) ||
commentUpper == mode {
return mode
}
}
return ""
}

View File

@@ -78,6 +78,7 @@ func (sp *SpotProcessor) processSpot(spot TelnetSpot) {
DXCC: spot.DXCC,
}
flexSpot.OriginalComment = spot.Comment
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
if sp.HTTPServer != nil && sp.HTTPServer.Watchlist != nil {

45
stats.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import "sync"
var (
spotsReceived int64
spotsProcessed int64
spotsRejected int64
spotsMutex sync.RWMutex
)
func IncrementSpotsReceived() {
spotsMutex.Lock()
spotsReceived++
spotsMutex.Unlock()
}
func IncrementSpotsProcessed() {
spotsMutex.Lock()
spotsProcessed++
spotsMutex.Unlock()
}
func IncrementSpotsRejected() {
spotsMutex.Lock()
spotsRejected++
spotsMutex.Unlock()
}
func GetSpotStats() (int64, int64, int64) {
spotsMutex.RLock()
defer spotsMutex.RUnlock()
return spotsReceived, spotsProcessed, spotsRejected
}
func GetSpotSuccessRate() float64 {
spotsMutex.RLock()
defer spotsMutex.RUnlock()
if spotsReceived == 0 {
return 0.0
}
return float64(spotsProcessed) / float64(spotsReceived) * 100.0
}

View File

@@ -32,31 +32,14 @@ func FreqHztoMhz(freq string) string {
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
func CheckSignal(TCPClient *TCPClient, TCPServer *TCPServer, FlexClient *FlexClient, fRepo *FlexDXClusterRepository, cRepo *Log4OMContactsRepository) {
func CheckSignal(tcpClient *TCPClient, tcpServer *TCPServer, flexClient *FlexClient, flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository) {
sigchan := make(chan os.Signal, 1)
signal.Notify(sigchan, os.Interrupt, syscall.SIGTERM)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
<-sigchan
// Gracely closing all connextions if signal is received
for sig := range sigCh {
Log.Infof("received signal: %v, shutting down all connections.", sig)
TCPClient.Close()
TCPServer.Conn.Close()
FlexClient.Conn.Close()
if err := fRepo.db.Close(); err != nil {
Log.Error("failed to close the database connection properly")
os.Exit(1)
}
if err := cRepo.db.Close(); err != nil {
Log.Error("failed to close Log4OM database connection properly")
os.Exit(1)
}
os.Exit(0)
}
GracefulShutdown(tcpClient, tcpServer, flexClient, flexRepo, contactRepo)
os.Exit(0)
}
func SendUDPMessage(data []byte) {

View File

@@ -1,10 +1,37 @@
[
{
"callsign": "H44MS",
"callsign": "3B8M",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:16:49.1572859+02:00",
"addedAt": "2025-10-18T17:18:32.6851135+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "E6AD",
"notes": "",
"lastSeen": "2025-10-19T10:11:57.4553495+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:40.8765179+02:00",
"spotCount": 198,
"playSound": true
},
{
"callsign": "TZ4AM",
"notes": "",
"lastSeen": "2025-10-18T23:16:45.8032011+02:00",
"lastSeenStr": "10 hours ago",
"addedAt": "2025-10-18T17:19:00.3154177+02:00",
"spotCount": 22,
"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
},
@@ -18,47 +45,47 @@
"playSound": true
},
{
"callsign": "C5LT",
"callsign": "C21TS",
"notes": "",
"lastSeen": "2025-10-18T18:50:28.7708075+02:00",
"lastSeenStr": "15 hours ago",
"addedAt": "2025-10-18T17:18:21.7895474+02:00",
"spotCount": 9,
"playSound": true
},
{
"callsign": "E51MWA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:07.2442738+02:00",
"addedAt": "2025-10-18T17:17:43.6895454+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",
"callsign": "ZL7IO",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:24.3843986+02:00",
"addedAt": "2025-10-18T17:17:30.7153757+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "5H3MB",
"callsign": "XV9",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:42.8402097+02:00",
"addedAt": "2025-10-18T17:18:24.9155327+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "YJ0CA",
"callsign": "9L8MD",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:33.3921665+02:00",
"addedAt": "2025-10-18T17:18:56.7896868+02:00",
"spotCount": 0,
"playSound": true
},
@@ -66,35 +93,17 @@
"callsign": "TJ1GD",
"notes": "",
"lastSeen": "2025-10-18T18:45:59.6232796+02:00",
"lastSeenStr": "Just now",
"lastSeenStr": "15 hours ago",
"addedAt": "2025-10-18T17:18:27.6004027+02:00",
"spotCount": 10,
"playSound": true
},
{
"callsign": "C8K",
"callsign": "V85NPV",
"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",
"addedAt": "2025-10-18T17:18:15.8781583+02:00",
"spotCount": 0,
"playSound": true
},
@@ -107,33 +116,105 @@
"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": "YJ0CA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:33.3921665+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "FW5K",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"lastSeen": "2025-10-19T10:11:59.0591627+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:37.9061157+02:00",
"spotCount": 45,
"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": "E51MWA",
"callsign": "H44MS",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:43.6895454+02:00",
"addedAt": "2025-10-18T17:16:49.1572859+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "3B8M",
"callsign": "VP8LP",
"notes": "",
"lastSeen": "2025-10-19T02:44:48.7228962+02:00",
"lastSeenStr": "7 hours ago",
"addedAt": "2025-10-18T17:18:49.0576187+02:00",
"spotCount": 4,
"playSound": true
},
{
"callsign": "C5LT",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:32.6851135+02:00",
"addedAt": "2025-10-18T17:18:07.2442738+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "XT2AW",
"notes": "",
"lastSeen": "2025-10-19T10:00:12.7622002+02:00",
"lastSeenStr": "10 minutes ago",
"addedAt": "2025-10-18T17:17:27.3839089+02:00",
"spotCount": 3,
"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": "EL2BG",
"notes": "",
"lastSeen": "2025-10-19T10:03:29.0397937+02:00",
"lastSeenStr": "7 minutes ago",
"addedAt": "2025-10-18T17:18:10.2000017+02:00",
"spotCount": 8,
"playSound": true
},
{
"callsign": "PJ6Y",
"notes": "",
"lastSeen": "2025-10-19T09:46:21.9937557+02:00",
"lastSeenStr": "24 minutes ago",
"addedAt": "2025-10-18T17:17:47.7237081+02:00",
"spotCount": 219,
"playSound": true
},
{
"callsign": "Z66IPA",
"notes": "",
@@ -143,85 +224,13 @@
"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",
"lastSeen": "2025-10-19T10:05:21.8975474+02:00",
"lastSeenStr": "5 minutes ago",
"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,
"spotCount": 45,
"playSound": true
},
{
@@ -234,21 +243,21 @@
"playSound": true
},
{
"callsign": "VP8LP",
"callsign": "PY0FB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:49.0576187+02:00",
"addedAt": "2025-10-18T17:17:24.3843986+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "EL2BG",
"callsign": "C5R",
"notes": "",
"lastSeen": "2025-10-18T18:32:28.3424341+02:00",
"lastSeen": "2025-10-19T10:11:33.5959073+02:00",
"lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:18:10.2000017+02:00",
"spotCount": 2,
"addedAt": "2025-10-18T17:18:04.5006892+02:00",
"spotCount": 57,
"playSound": true
},
{
@@ -261,29 +270,20 @@
"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",
"callsign": "5H3MB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:17:51.0758741+02:00",
"addedAt": "2025-10-18T17:18:42.8402097+02:00",
"spotCount": 0,
"playSound": true
},
{
"callsign": "XV9",
"callsign": "9L9L",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
"addedAt": "2025-10-18T17:18:24.9155327+02:00",
"addedAt": "2025-10-18T17:18:53.3401773+02:00",
"spotCount": 0,
"playSound": true
}