perf
This commit is contained in:
@@ -56,7 +56,7 @@ type Config struct {
|
|||||||
Skimmer bool `yaml:"skimmer"`
|
Skimmer bool `yaml:"skimmer"`
|
||||||
FT8 bool `yaml:"ft8"`
|
FT8 bool `yaml:"ft8"`
|
||||||
FT4 bool `yaml:"ft4"`
|
FT4 bool `yaml:"ft4"`
|
||||||
Command string `yanl:"command"`
|
Command string `yaml:"command"`
|
||||||
LoginPrompt string `yaml:"login_prompt"`
|
LoginPrompt string `yaml:"login_prompt"`
|
||||||
} `yaml:"cluster"`
|
} `yaml:"cluster"`
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func NewConfig(configPath string) *Config {
|
|||||||
d := yaml.NewDecoder(file)
|
d := yaml.NewDecoder(file)
|
||||||
|
|
||||||
if err := d.Decode(&Cfg); err != nil {
|
if err := d.Decode(&Cfg); err != nil {
|
||||||
log.Println("could not decod config file")
|
log.Println("could not decode config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
return Cfg
|
return Cfg
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
import Sidebar from './components/Sidebar.svelte';
|
import Sidebar from './components/Sidebar.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import ErrorBanner from './components/ErrorBanner.svelte';
|
import ErrorBanner from './components/ErrorBanner.svelte';
|
||||||
import SoundManager from './components/SoundManager.svelte';
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let spots = [];
|
let spots = [];
|
||||||
let previousSpots = []; // Pour détecter les nouveaux spots
|
|
||||||
let filteredSpots = [];
|
let filteredSpots = [];
|
||||||
let stats = {
|
let stats = {
|
||||||
totalSpots: 0,
|
totalSpots: 0,
|
||||||
@@ -68,66 +66,21 @@
|
|||||||
let reconnectTimer;
|
let reconnectTimer;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let maxReconnectAttempts = 10;
|
let maxReconnectAttempts = 10;
|
||||||
let soundEnabled = true;
|
let isShuttingDown = false;
|
||||||
let isShuttingDown = false; // ✅ Flag pour éviter les erreurs pendant le shutdown
|
let filterTimeout;
|
||||||
|
|
||||||
// Reactive filtered spots
|
|
||||||
$: {
|
$: {
|
||||||
if (spotFilters.showAll) {
|
if (spotFilters.showAll) {
|
||||||
filteredSpots = spots;
|
filteredSpots = spots;
|
||||||
} else {
|
} else {
|
||||||
filteredSpots = applyFilters(spots, spotFilters, watchlist);
|
if (filterTimeout) clearTimeout(filterTimeout);
|
||||||
|
|
||||||
|
filterTimeout = setTimeout(() => {
|
||||||
|
filteredSpots = applyFilters(spots, spotFilters, watchlist);
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (typeof localStorage !== 'undefined') {
|
|
||||||
localStorage.setItem('soundEnabled', soundEnabled.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détecter les nouveaux spots et jouer les sons appropriés
|
|
||||||
$: if (spots.length > 0 && soundEnabled) {
|
|
||||||
checkForNewSpots(spots, previousSpots, watchlist);
|
|
||||||
// ✅ Ne garder que les 100 derniers spots pour la comparaison
|
|
||||||
previousSpots = spots.slice(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkForNewSpots(currentSpots, prevSpots, wl) {
|
|
||||||
// Ne pas jouer de sons au chargement initial
|
|
||||||
if (prevSpots.length === 0) return;
|
|
||||||
|
|
||||||
// Créer un Set des IDs précédents pour une recherche rapide
|
|
||||||
const previousIds = new Set(prevSpots.map(s => `${s.DX}-${s.Frequency}-${s.Time}`));
|
|
||||||
|
|
||||||
// Trouver les nouveaux spots
|
|
||||||
const newSpots = currentSpots.filter(spot => {
|
|
||||||
const spotId = `${spot.DX}-${spot.Frequency}-${spot.Time}`;
|
|
||||||
return !previousIds.has(spotId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newSpots.length === 0) return;
|
|
||||||
|
|
||||||
// Vérifier s'il y a un nouveau DXCC (priorité maximale)
|
|
||||||
const hasNewDXCC = newSpots.some(spot => spot.NewDXCC === true);
|
|
||||||
if (hasNewDXCC) {
|
|
||||||
playSound('newDXCC');
|
|
||||||
return; // Ne jouer qu'un seul son
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier s'il y a un spot de la watchlist
|
|
||||||
const hasWatchlistSpot = newSpots.some(spot =>
|
|
||||||
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
|
|
||||||
);
|
|
||||||
if (hasWatchlistSpot) {
|
|
||||||
playSound('watchlist');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function playSound(type) {
|
|
||||||
window.dispatchEvent(new CustomEvent('playSound', {
|
|
||||||
detail: { type }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters(allSpots, filters, wl) {
|
function applyFilters(allSpots, filters, wl) {
|
||||||
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
|
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
|
||||||
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
|
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
|
||||||
@@ -242,59 +195,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wsStatus = 'connecting';
|
wsStatus = 'connecting';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket('ws://localhost:8080/api/ws');
|
ws = new WebSocket('ws://localhost:8080/api/ws');
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
wsStatus = 'connected';
|
wsStatus = 'connected';
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
showToast('Connected to server', 'success');
|
showToast('Connected to server', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
handleWebSocketMessage(message);
|
handleWebSocketMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('Error parsing WebSocket message:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
|
wsStatus = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
wsStatus = 'disconnected';
|
||||||
|
|
||||||
|
if (isShuttingDown) {
|
||||||
|
console.log('App is shutting down, skip reconnection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||||
|
errorMessage = `Connection lost. Reconnecting in ${delay/1000}s... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`;
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Unable to connect to server. Please refresh the page.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) { // ✅ AJOUTER cette ligne
|
||||||
|
console.error('Error creating WebSocket:', error);
|
||||||
wsStatus = 'disconnected';
|
wsStatus = 'disconnected';
|
||||||
};
|
} // ✅ AJOUTER cette ligne
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
wsStatus = 'disconnected';
|
|
||||||
|
|
||||||
if (reconnectAttempts < maxReconnectAttempts) {
|
|
||||||
reconnectAttempts++;
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
|
||||||
errorMessage = `Connection lost. Reconnecting in ${delay/1000}s... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`;
|
|
||||||
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
connectWebSocket();
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
errorMessage = 'Unable to connect to server. Please refresh the page.';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating WebSocket:', error);
|
|
||||||
wsStatus = 'disconnected';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleWebSocketMessage(message) {
|
function handleWebSocketMessage(message) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
@@ -324,9 +282,6 @@
|
|||||||
const milestoneData = message.data;
|
const milestoneData = message.data;
|
||||||
const toastType = milestoneData.type === 'qso' ? 'milestone' : 'band';
|
const toastType = milestoneData.type === 'qso' ? 'milestone' : 'band';
|
||||||
showToast(milestoneData.message, toastType);
|
showToast(milestoneData.message, toastType);
|
||||||
if (soundEnabled) {
|
|
||||||
playSound('milestone');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,62 +350,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shutdownApp() {
|
async function shutdownApp() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/shutdown', {
|
// ✅ Désactiver la reconnexion et masquer l'erreur IMMÉDIATEMENT
|
||||||
method: 'POST',
|
isShuttingDown = true;
|
||||||
headers: { 'Content-Type': 'application/json' }
|
errorMessage = '';
|
||||||
});
|
maxReconnectAttempts = 0;
|
||||||
|
|
||||||
const data = await response.json();
|
// ✅ Fermer le WebSocket proprement
|
||||||
if (data.success) {
|
if (ws) {
|
||||||
showToast('FlexDXCluster shutting down...', 'info');
|
ws.onclose = null; // Désactiver le handler de reconnexion
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
wsStatus = 'disconnected';
|
||||||
|
|
||||||
if (ws) ws.close();
|
showToast('FlexDXCluster shutting down...', 'info');
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
||||||
wsStatus = 'disconnected';
|
|
||||||
maxReconnectAttempts = 0;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
// ✅ Envoyer la commande de shutdown au backend
|
||||||
document.body.innerHTML = `
|
const response = await fetch('/api/shutdown', {
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
method: 'POST',
|
||||||
<div class="text-center">
|
headers: { 'Content-Type': 'application/json' }
|
||||||
<svg class="w-24 h-24 mx-auto mb-6 text-blue-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>
|
const data = await response.json();
|
||||||
<h1 class="text-4xl font-bold mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
if (data.success) {
|
||||||
FlexDXCluster Stopped
|
// ✅ Afficher le message de shutdown après un court délai
|
||||||
</h1>
|
setTimeout(() => {
|
||||||
<p class="text-slate-400 text-lg">The application has been shut down successfully.</p>
|
document.body.innerHTML = `
|
||||||
<p class="text-slate-500 text-sm mt-4">You can close this window.</p>
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||||
</div>
|
<div class="text-center">
|
||||||
|
<svg class="w-24 h-24 mx-auto mb-6 text-blue-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>
|
||||||
|
<h1 class="text-4xl font-bold mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
|
FlexDXCluster Stopped
|
||||||
|
</h1>
|
||||||
|
<p class="text-slate-400 text-lg">The application has been shut down successfully.</p>
|
||||||
|
<p class="text-slate-500 text-sm mt-4">You can close this window.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
}, 1000);
|
`;
|
||||||
}
|
}, 500);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Error shutting down:', error);
|
} catch (error) {
|
||||||
|
console.error('Error shutting down:', error);
|
||||||
|
if (!isShuttingDown) {
|
||||||
showToast(`Cannot shutdown: ${error.message}`, 'error');
|
showToast(`Cannot shutdown: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const savedSoundEnabled = localStorage.getItem('soundEnabled');
|
const savedSoundEnabled = localStorage.getItem('soundEnabled');
|
||||||
if (savedSoundEnabled !== null) {
|
|
||||||
soundEnabled = savedSoundEnabled === 'true';
|
|
||||||
}
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
fetchSolarData();
|
fetchSolarData();
|
||||||
|
|
||||||
const solarInterval = setInterval(fetchSolarData, 15 * 60 * 1000);
|
const solarInterval = setInterval(fetchSolarData, 15 * 60 * 1000);
|
||||||
|
|
||||||
const cleanupInterval = setInterval(() => {
|
|
||||||
// Nettoyer previousSpots
|
|
||||||
if (previousSpots.length > 100) {
|
|
||||||
previousSpots = previousSpots.slice(0, 100);
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const handleSendSpot = (e) => {
|
const handleSendSpot = (e) => {
|
||||||
sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode);
|
sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode);
|
||||||
};
|
};
|
||||||
@@ -466,8 +423,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-2">
|
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-2">
|
||||||
<!-- Gestionnaire de sons -->
|
|
||||||
<SoundManager bind:enabled={soundEnabled} />
|
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<ErrorBanner message={errorMessage} on:close={() => errorMessage = ''} />
|
<ErrorBanner message={errorMessage} on:close={() => errorMessage = ''} />
|
||||||
@@ -481,9 +436,7 @@
|
|||||||
{stats}
|
{stats}
|
||||||
{solarData}
|
{solarData}
|
||||||
{wsStatus}
|
{wsStatus}
|
||||||
{soundEnabled}
|
|
||||||
on:shutdown={shutdownApp}
|
on:shutdown={shutdownApp}
|
||||||
on:toggleSound={() => soundEnabled = !soundEnabled}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCards
|
<StatsCards
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
export let soundEnabled = true;
|
|
||||||
export let stats;
|
export let stats;
|
||||||
export let solarData;
|
export let solarData;
|
||||||
export let wsStatus;
|
export let wsStatus;
|
||||||
@@ -101,25 +100,6 @@
|
|||||||
Flex
|
Flex
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Bouton Son -->
|
|
||||||
<button
|
|
||||||
on:click={() => dispatch('toggleSound')}
|
|
||||||
class="px-3 py-1.5 rounded-lg transition-colors flex items-center gap-2 text-sm {soundEnabled ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-700/50 hover:bg-slate-700 text-slate-300'}"
|
|
||||||
title={soundEnabled ? 'Mute sounds' : 'Enable sounds'}>
|
|
||||||
{#if soundEnabled}
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Sound On</span>
|
|
||||||
{:else}
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clip-rule="evenodd"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Sound Off</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={() => dispatch('shutdown')}
|
on:click={() => dispatch('shutdown')}
|
||||||
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
|
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ func (s *HTTPServer) broadcastUpdates() {
|
|||||||
s.broadcast <- WSMessage{Type: "stats", Data: stats}
|
s.broadcast <- WSMessage{Type: "stats", Data: stats}
|
||||||
|
|
||||||
// Broadcast spots
|
// Broadcast spots
|
||||||
spots := s.FlexRepo.GetAllSpots("300")
|
spots := s.FlexRepo.GetAllSpots("0")
|
||||||
s.checkBandOpening(spots)
|
s.checkBandOpening(spots)
|
||||||
s.broadcast <- WSMessage{Type: "spots", Data: spots}
|
s.broadcast <- WSMessage{Type: "spots", Data: spots}
|
||||||
|
|
||||||
|
|||||||
18
package-lock.json
generated
Normal file
18
package-lock.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "FlexDXClusterGui",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"svelte-virtual-list": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svelte-virtual-list": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-virtual-list/-/svelte-virtual-list-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-e7e+jn8VDjmdD8A1oOVBWmUi7jg+uugmiihkK0Cuk35JeeCR8ja40/B6DpCTHJFsQAZg6EVPjetN2ibgr25YCA==",
|
||||||
|
"license": "LIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"svelte-virtual-list": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD"]
|
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD","PZ5RA"]
|
||||||
Reference in New Issue
Block a user