This commit is contained in:
2025-10-13 23:29:09 +05:30
parent cbaacb298f
commit ec248f9c95
7 changed files with 301 additions and 41 deletions

View File

@@ -25,13 +25,13 @@ help:
## install-deps: Installe les dépendances npm
install-deps:
@echo "[1/2] Installation des dépendances npm..."
@echo "[1/2] Installation des dependances npm..."
cd $(FRONTEND_DIR) && npm install
@echo "Dépendances installées"
@echo "Dependances installees"
@echo ""
@echo "[2/2] Vérification de Go..."
@echo "[2/2] Verification de Go..."
@go version
@echo "Go est installé"
@echo "Go est installe"
## frontend: Build le frontend Svelte
frontend:

BIN
flex.sqlite-journal Normal file

Binary file not shown.

View File

@@ -7,9 +7,11 @@
import Sidebar from './components/Sidebar.svelte';
import Toast from './components/Toast.svelte';
import ErrorBanner from './components/ErrorBanner.svelte';
import SoundManager from './components/SoundManager.svelte';
// State
let spots = [];
let previousSpots = []; // Pour détecter les nouveaux spots
let filteredSpots = [];
let stats = {
totalSpots: 0,
@@ -23,13 +25,14 @@
filters: { skimmer: false, ft8: false, ft4: false }
};
let topSpotters = [];
let watchlist = [];
let watchlist = []; // ✅ Initialisé vide, sera rempli par WebSocket
let recentQSOs = [];
let logStats = { today: 0, thisWeek: 0, thisMonth: 0, total: 0 };
let dxccProgress = { worked: 0, total: 340, percentage: 0 };
let solarData = { sfi: 'N/A', sunspots: 'N/A', aIndex: 'N/A', kIndex: 'N/A' };
let activeTab = 'stats';
let showOnlyActive = false; // ✅ État global pour persister entre les onglets
let wsStatus = 'disconnected';
let errorMessage = '';
let toastMessage = '';
@@ -58,13 +61,15 @@
band12M: false,
band10M: false,
band6M: false
};
};
// WebSocket
let ws;
let reconnectTimer;
let reconnectAttempts = 0;
let maxReconnectAttempts = 10;
let soundEnabled = true;
let isShuttingDown = false; // ✅ Flag pour éviter les erreurs pendant le shutdown
// Reactive filtered spots
$: {
@@ -75,6 +80,52 @@
}
}
// Détecter les nouveaux spots et jouer les sons appropriés
$: if (spots.length > 0 && soundEnabled) {
checkForNewSpots(spots, previousSpots, watchlist);
previousSpots = [...spots];
}
// ✅ SUPPRIMÉ - La watchlist est gérée côté serveur via WebSocket
// Les fonctions addToWatchlist et removeFromWatchlist ne sont plus nécessaires
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) {
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
@@ -255,6 +306,7 @@
topSpotters = message.data || [];
break;
case 'watchlist':
// ✅ La watchlist est mise à jour par WebSocket
watchlist = message.data || [];
break;
case 'log':
@@ -344,13 +396,11 @@
if (data.success) {
showToast('FlexDXCluster shutting down...', 'info');
// Fermer le WebSocket et arrêter les tentatives de reconnexion
if (ws) ws.close();
if (reconnectTimer) clearTimeout(reconnectTimer);
wsStatus = 'disconnected';
maxReconnectAttempts = 0; // Empêcher les reconnexions
maxReconnectAttempts = 0;
// Afficher la page de shutdown après 1 seconde
setTimeout(() => {
document.body.innerHTML = `
<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">
@@ -378,10 +428,8 @@
connectWebSocket();
fetchSolarData();
// Update solar data every 15 minutes
const solarInterval = setInterval(fetchSolarData, 15 * 60 * 1000);
// Listen for sendSpot events from watchlist
const handleSendSpot = (e) => {
sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode);
};
@@ -397,6 +445,9 @@
</script>
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-4">
<!-- Gestionnaire de sons -->
<SoundManager bind:enabled={soundEnabled} />
{#if errorMessage}
<ErrorBanner message={errorMessage} on:close={() => errorMessage = ''} />
{/if}
@@ -424,8 +475,8 @@
on:toggleFilter={(e) => toggleFilter(e.detail)}
/>
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
<div class="col-span-3">
<div class="grid grid-cols-4 gap-3 overflow-hidden" style="height: calc(100vh - 360px); min-height: 500px;">
<div class="col-span-3 overflow-hidden">
<SpotsTable
spots={filteredSpots}
{watchlist}
@@ -434,15 +485,18 @@
/>
</div>
<Sidebar
bind:activeTab
{topSpotters}
{spots}
{watchlist}
{recentQSOs}
{logStats}
{dxccProgress}
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
/>
<div class="overflow-hidden">
<Sidebar
bind:activeTab
bind:showOnlyActive
{topSpotters}
{spots}
{watchlist}
{recentQSOs}
{logStats}
{dxccProgress}
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
/>
</div>
</div>
</div>

View File

@@ -11,11 +11,17 @@
export let recentQSOs;
export let logStats;
export let dxccProgress;
export let showOnlyActive = false; // ✅ Export pour persister l'état
const dispatch = createEventDispatcher();
// ✅ Propagation des évènements vers le parent
function handleToast(event) {
dispatch('toast', event.detail);
}
</script>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden h-full">
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col h-full" style="height: 100%; max-height: 100%;">
<!-- Tabs Header -->
<div class="flex border-b border-slate-700/50 bg-slate-900/30 flex-shrink-0">
<button
@@ -48,14 +54,15 @@
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden" style="min-height: 0;">
{#if activeTab === 'stats'}
<StatsTab {topSpotters} {spots} />
{:else if activeTab === 'watchlist'}
<WatchlistTab
{watchlist}
{spots}
on:toast={(e) => dispatch('toast', e.detail)}
{:else if activeTab === 'watchlist'}
<WatchlistTab
{watchlist}
{spots}
bind:showOnlyActive
on:toast={handleToast}
/>
{:else if activeTab === 'log'}
<LogTab

View File

@@ -0,0 +1,158 @@
<script>
import { onMount } from 'svelte';
export let enabled = true;
export let volume = 0.3;
export let showControls = false; // Masquer les contrôles par défaut
let audioContext;
let isMuted = false;
onMount(() => {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
window.addEventListener('playSound', handlePlaySound);
// Charger les préférences depuis localStorage
const savedMuted = localStorage.getItem('soundMuted');
const savedVolume = localStorage.getItem('soundVolume');
if (savedMuted !== null) isMuted = savedMuted === 'true';
if (savedVolume !== null) volume = parseFloat(savedVolume);
return () => {
window.removeEventListener('playSound', handlePlaySound);
if (audioContext) audioContext.close();
};
});
function handlePlaySound(event) {
if (!enabled || isMuted) return;
const { type } = event.detail;
if (type === 'newDXCC') {
playNewDXCCSound();
} else if (type === 'watchlist') {
playWatchlistSound();
}
}
function playNewDXCCSound() {
if (!audioContext) return;
const now = audioContext.currentTime;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
// Mélodie joyeuse : C5 -> E5 -> G5 -> C6
oscillator.frequency.setValueAtTime(523.25, now);
oscillator.frequency.setValueAtTime(659.25, now + 0.15);
oscillator.frequency.setValueAtTime(783.99, now + 0.3);
oscillator.frequency.setValueAtTime(1046.50, now + 0.45);
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(volume, now + 0.05);
gainNode.gain.setValueAtTime(volume * 0.8, now + 0.5);
gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.7);
oscillator.start(now);
oscillator.stop(now + 0.7);
}
function playWatchlistSound() {
if (!audioContext) return;
const now = audioContext.currentTime;
playBeep(now, 800, 0.1);
playBeep(now + 0.15, 1000, 0.1);
}
function playBeep(startTime, frequency, duration) {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, startTime);
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
function toggleMute() {
isMuted = !isMuted;
localStorage.setItem('soundMuted', isMuted.toString());
}
function updateVolume(newVolume) {
volume = newVolume;
localStorage.setItem('soundVolume', volume.toString());
}
</script>
{#if showControls}
<div class="fixed bottom-4 right-4 flex items-center gap-2 bg-slate-800/90 backdrop-blur rounded-lg border border-slate-700/50 p-2 shadow-lg z-50">
<button
on:click={toggleMute}
class="p-2 rounded transition-colors {isMuted ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}"
title={isMuted ? 'Unmute sounds' : 'Mute sounds'}>
{#if isMuted}
<svg class="w-5 h-5" 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>
{:else}
<svg class="w-5 h-5" 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>
{/if}
</button>
<div class="flex items-center gap-2 border-l border-slate-700 pl-2">
<svg class="w-4 h-4 text-slate-400" 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.072"></path>
</svg>
<input
type="range"
min="0"
max="100"
value={volume * 100}
on:input={(e) => updateVolume(e.target.value / 100)}
class="w-20 h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
title="Volume: {Math.round(volume * 100)}%"
/>
<span class="text-xs text-slate-400 w-8">{Math.round(volume * 100)}%</span>
</div>
</div>
{/if}
<style>
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #3b82f6;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
background: #3b82f6;
border-radius: 50%;
cursor: pointer;
border: none;
}
</style>

View File

@@ -1,25 +1,61 @@
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let watchlist;
export let spots;
export let showOnlyActive = false;
const dispatch = createEventDispatcher();
let newCallsign = '';
let showOnlyActive = false;
let watchlistSpots = [];
let refreshInterval;
$: matchingSpots = countWatchlistSpots(spots, watchlist);
$: displayList = showOnlyActive
? watchlist.filter(cs => getMatchingSpotsForCallsign(cs).length > 0)
: watchlist;
// Fetch watchlist spots with worked status from API
// ✅ Tri alphanumérique simple : 0-9 puis A-Z
$: displayList = getDisplayList(watchlist, watchlistSpots, showOnlyActive);
// ✅ Rafraîchir automatiquement les spots de la watchlist
$: if (watchlist.length > 0) {
fetchWatchlistSpots();
}
// ✅ Rafraîchir aussi quand les spots changent (temps réel)
$: if (spots.length > 0 && watchlist.length > 0) {
fetchWatchlistSpots();
}
onMount(() => {
// Rafraîchir toutes les 10 secondes pour être sûr
refreshInterval = setInterval(() => {
if (watchlist.length > 0) {
fetchWatchlistSpots();
}
}, 10000);
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
function getDisplayList(wl, wlSpots, activeOnly) {
let list = wl;
if (activeOnly) {
// Filtrer pour ne montrer que les callsigns avec des spots actifs
list = wl.filter(cs => {
const spots = wlSpots.filter(s => s.dx === cs || s.dx.startsWith(cs));
return spots.length > 0;
});
}
// Tri alphanumérique : 0-9 puis A-Z
return [...list].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
}
async function fetchWatchlistSpots() {
try {
const response = await fetch('/api/watchlist/spots');
@@ -110,14 +146,19 @@
});
window.dispatchEvent(event);
}
// ✅ Fonction pour le toggle
function toggleActiveOnly() {
showOnlyActive = !showOnlyActive;
}
</script>
<div class="h-full flex flex-col overflow-hidden">
<div class="h-full flex flex-col" style="height: 100%; max-height: 100%;">
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-bold">Watchlist</h2>
<button
on:click={() => showOnlyActive = !showOnlyActive}
on:click={toggleActiveOnly}
class="px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-2 {showOnlyActive ? 'bg-blue-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
@@ -143,7 +184,7 @@
</div>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div class="flex-1 p-3 overflow-y-auto" style="overflow-y: auto; min-height: 0; flex: 1 1 0;">
{#if displayList.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">
@@ -186,7 +227,7 @@
</div>
{#if count > 0}
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto" style="overflow-y: auto;">
{#each matchingSpots.slice(0, 10) as spot}
<button
on:click={() => sendSpot(spot)}

View File

@@ -1 +1 @@
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG"]
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB"]