bug
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
68
TCPServer.go
68
TCPServer.go
@@ -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()
|
||||
|
||||
19
database.go
19
database.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
frontend/src/components/LogsTab.svelte
Normal file
139
frontend/src/components/LogsTab.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
130
log.go
@@ -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
80
loghook.go
Normal 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
49
main.go
@@ -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
627
spot.go
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
45
stats.go
Normal 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
|
||||
}
|
||||
29
utils.go
29
utils.go
@@ -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) {
|
||||
|
||||
280
watchlist.json
280
watchlist.json
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user