Migrate to a better architectured project

This commit is contained in:
nemunaire 2025-10-28 19:23:27 +07:00
commit b1b9eaa028
21 changed files with 2712 additions and 1540 deletions

40
cmd/repeater/main.go Normal file
View file

@ -0,0 +1,40 @@
package main
import (
"embed"
"log"
"os"
"os/signal"
"syscall"
"github.com/nemunaire/repeater/internal/app"
)
//go:embed all:static
var assets embed.FS
func main() {
// Create application instance
application := app.New(assets)
// Initialize the application
if err := application.Initialize(); err != nil {
log.Fatalf("Failed to initialize application: %v", err)
}
defer application.Shutdown()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Shutting down gracefully...")
application.Shutdown()
os.Exit(0)
}()
// Start the server
if err := application.Run(":8080"); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

535
cmd/repeater/static/app.js Normal file
View file

@ -0,0 +1,535 @@
// Application state
const appState = {
selectedWifi: null,
hotspotEnabled: true,
autoScrollLogs: true,
ws: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5
};
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
});
async function initializeApp() {
console.log('Initializing Travel Router Control Panel...');
// Load initial data
await Promise.all([
loadStatus(),
scanWifi(),
loadDevices(),
loadLogs()
]);
// Set up WebSocket for real-time logs
connectWebSocket();
// Start periodic updates
startPeriodicUpdates();
showToast('success', 'Connecté', 'Interface web chargée avec succès');
}
// ===== API Functions =====
async function loadStatus() {
try {
const response = await fetch('/api/status');
const status = await response.json();
updateStatusDisplay(status);
return status;
} catch (error) {
console.error('Error loading status:', error);
showToast('error', 'Erreur', 'Impossible de charger le statut');
}
}
async function scanWifi() {
const wifiList = document.getElementById('wifiList');
const scanBtn = document.querySelector('[onclick="scanWifi()"]');
if (scanBtn) {
scanBtn.disabled = true;
}
wifiList.innerHTML = '<div class="wifi-item loading"><span>Recherche des réseaux disponibles...</span></div>';
try {
const response = await fetch('/api/wifi/scan');
const networks = await response.json();
displayWifiNetworks(networks);
showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`);
} catch (error) {
console.error('Error scanning WiFi:', error);
wifiList.innerHTML = '<div class="wifi-item loading"><span style="color: var(--danger-color);">Erreur lors du scan</span></div>';
showToast('error', 'Erreur', 'Échec du scan WiFi');
} finally {
if (scanBtn) {
scanBtn.disabled = false;
}
}
}
async function connectToWifi() {
if (!appState.selectedWifi) {
showToast('warning', 'Attention', 'Veuillez sélectionner un réseau WiFi');
return;
}
const password = document.getElementById('wifiPassword').value;
if (appState.selectedWifi.security !== 'Open' && !password) {
showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau');
return;
}
showLoading(true);
try {
const response = await fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ssid: appState.selectedWifi.ssid,
password: password
})
});
const result = await response.json();
if (response.ok) {
showToast('success', 'Connecté', `Connexion établie avec ${appState.selectedWifi.ssid}`);
document.getElementById('wifiPassword').value = '';
await loadStatus();
} else {
throw new Error(result.error || 'Connection failed');
}
} catch (error) {
console.error('Error connecting to WiFi:', error);
showToast('error', 'Erreur', 'Échec de la connexion: ' + error.message);
} finally {
showLoading(false);
}
}
async function disconnectWifi() {
showLoading(true);
try {
const response = await fetch('/api/wifi/disconnect', {
method: 'POST'
});
if (response.ok) {
showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie');
await loadStatus();
} else {
throw new Error('Disconnection failed');
}
} catch (error) {
console.error('Error disconnecting WiFi:', error);
showToast('error', 'Erreur', 'Échec de la déconnexion');
} finally {
showLoading(false);
}
}
async function updateHotspot() {
const ssid = document.getElementById('hotspotName').value;
const password = document.getElementById('hotspotPassword').value;
const channel = parseInt(document.getElementById('hotspotChannel').value);
if (!ssid || ssid.length > 32) {
showToast('warning', 'Attention', 'SSID invalide (1-32 caractères)');
return;
}
if (!password || password.length < 8 || password.length > 63) {
showToast('warning', 'Attention', 'Mot de passe invalide (8-63 caractères)');
return;
}
showLoading(true);
try {
const response = await fetch('/api/hotspot/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password, channel })
});
if (response.ok) {
showToast('success', 'Configuration', 'Hotspot configuré avec succès');
} else {
throw new Error('Configuration failed');
}
} catch (error) {
console.error('Error configuring hotspot:', error);
showToast('error', 'Erreur', 'Échec de la configuration');
} finally {
showLoading(false);
}
}
async function toggleHotspot() {
const toggle = document.getElementById('hotspotToggle');
const enabled = toggle.checked;
showLoading(true);
try {
const response = await fetch('/api/hotspot/toggle', {
method: 'POST'
});
const result = await response.json();
if (response.ok) {
appState.hotspotEnabled = result.enabled;
toggle.checked = result.enabled;
showToast('success', 'Hotspot', result.enabled ? 'Hotspot activé' : 'Hotspot désactivé');
await loadStatus();
} else {
toggle.checked = !enabled;
throw new Error('Toggle failed');
}
} catch (error) {
console.error('Error toggling hotspot:', error);
toggle.checked = !enabled;
showToast('error', 'Erreur', 'Échec du basculement');
} finally {
showLoading(false);
}
}
async function loadDevices() {
try {
const response = await fetch('/api/devices');
const devices = await response.json();
displayDevices(devices);
document.getElementById('deviceCount').textContent = devices.length;
} catch (error) {
console.error('Error loading devices:', error);
const devicesList = document.getElementById('devicesList');
devicesList.innerHTML = '<div class="device-placeholder"><p>Erreur de chargement</p></div>';
}
}
async function loadLogs() {
try {
const response = await fetch('/api/logs');
const logs = await response.json();
const logContainer = document.getElementById('logContainer');
logContainer.innerHTML = '';
if (logs.length === 0) {
logContainer.innerHTML = '<div class="log-placeholder"><svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/></svg><p>Aucun log disponible</p></div>';
} else {
logs.forEach(log => addLogEntry(log));
}
} catch (error) {
console.error('Error loading logs:', error);
}
}
async function clearLogs() {
try {
const response = await fetch('/api/logs', { method: 'DELETE' });
if (response.ok) {
const logContainer = document.getElementById('logContainer');
logContainer.innerHTML = '<div class="log-placeholder"><svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/></svg><p>Aucun log disponible</p></div>';
showToast('success', 'Logs', 'Logs effacés');
}
} catch (error) {
console.error('Error clearing logs:', error);
showToast('error', 'Erreur', 'Échec de la suppression des logs');
}
}
// ===== Display Functions =====
function updateStatusDisplay(status) {
// Update WiFi status badge
const wifiStatus = document.getElementById('wifiStatus');
const wifiDot = wifiStatus.querySelector('.status-dot');
const wifiText = wifiStatus.querySelector('.status-text');
if (status.connected) {
wifiDot.className = 'status-dot active';
wifiText.textContent = `Connecté: ${status.connectedSSID}`;
} else {
wifiDot.className = 'status-dot offline';
wifiText.textContent = 'Déconnecté';
}
// Update hotspot status badge
const hotspotStatus = document.getElementById('hotspotStatus');
const hotspotDot = hotspotStatus.querySelector('.status-dot');
const hotspotText = hotspotStatus.querySelector('.status-text');
const hotspotToggle = document.getElementById('hotspotToggle');
if (status.hotspotEnabled) {
hotspotDot.className = 'status-dot active';
hotspotText.textContent = 'Hotspot actif';
hotspotToggle.checked = true;
} else {
hotspotDot.className = 'status-dot offline';
hotspotText.textContent = 'Hotspot inactif';
hotspotToggle.checked = false;
}
appState.hotspotEnabled = status.hotspotEnabled;
// Update stats
document.getElementById('connectedDevices').textContent = status.connectedCount;
document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`;
document.getElementById('uptime').textContent = formatUptime(status.uptime);
document.getElementById('currentNetwork').textContent = status.connectedSSID || '-';
}
function displayWifiNetworks(networks) {
const wifiList = document.getElementById('wifiList');
wifiList.innerHTML = '';
if (networks.length === 0) {
wifiList.innerHTML = '<div class="wifi-item loading"><span>Aucun réseau trouvé</span></div>';
return;
}
networks.forEach(network => {
const wifiItem = document.createElement('div');
wifiItem.className = 'wifi-item';
wifiItem.onclick = () => selectWifi(network, wifiItem);
wifiItem.innerHTML = `
<div class="wifi-info">
<div class="wifi-ssid">${escapeHtml(network.ssid)}</div>
<div class="wifi-details">
<span>${network.security}</span>
<span>Canal ${network.channel}</span>
<span>${network.bssid}</span>
</div>
</div>
<div class="wifi-signal">
${generateSignalBars(network.signal)}
</div>
`;
wifiList.appendChild(wifiItem);
});
}
function selectWifi(network, element) {
document.querySelectorAll('.wifi-item').forEach(item => item.classList.remove('selected'));
element.classList.add('selected');
appState.selectedWifi = network;
}
function displayDevices(devices) {
const devicesList = document.getElementById('devicesList');
devicesList.innerHTML = '';
if (devices.length === 0) {
devicesList.innerHTML = `
<div class="device-placeholder">
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" opacity="0.3" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
</svg>
<p>Aucun appareil connecté</p>
</div>
`;
return;
}
devices.forEach(device => {
const deviceCard = document.createElement('div');
deviceCard.className = 'device-card';
deviceCard.innerHTML = `
${getDeviceIcon(device.type)}
<div class="device-name">${escapeHtml(device.name)}</div>
<div class="device-type">${device.type}</div>
<div class="device-info">
<div>${device.ip}</div>
<div style="font-size: 0.75rem;">${device.mac}</div>
</div>
`;
devicesList.appendChild(deviceCard);
});
}
function addLogEntry(log) {
const logContainer = document.getElementById('logContainer');
// Remove placeholder if it exists
const placeholder = logContainer.querySelector('.log-placeholder');
if (placeholder) {
placeholder.remove();
}
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR');
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-source">[${escapeHtml(log.source)}]</span>
<span class="log-message">${escapeHtml(log.message)}</span>
`;
logContainer.appendChild(logEntry);
if (appState.autoScrollLogs) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
// ===== WebSocket Functions =====
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
try {
appState.ws = new WebSocket(wsUrl);
appState.ws.onopen = function() {
console.log('WebSocket connected');
appState.reconnectAttempts = 0;
};
appState.ws.onmessage = function(event) {
try {
const log = JSON.parse(event.data);
addLogEntry(log);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
appState.ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
appState.ws.onclose = function() {
console.log('WebSocket disconnected');
// Attempt to reconnect
if (appState.reconnectAttempts < appState.maxReconnectAttempts) {
appState.reconnectAttempts++;
setTimeout(connectWebSocket, 5000);
}
};
} catch (error) {
console.error('Error creating WebSocket:', error);
}
}
// ===== Utility Functions =====
function generateSignalBars(strength) {
const bars = [];
for (let i = 1; i <= 4; i++) {
const active = i <= strength ? 'active' : '';
bars.push(`<div class="signal-bar ${active}"></div>`);
}
return `<div class="signal-bars">${bars.join('')}</div>`;
}
function getDeviceIcon(type) {
const icons = {
mobile: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M17,19H7V5H17M17,1H7C5.89,1 5,1.89 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3C19,1.89 18.1,1 17,1Z"/></svg>`,
laptop: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z"/></svg>`,
tablet: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M19,18H5V6H19M21,4H3C1.89,4 1,4.89 1,6V18A2,2 0 0,0 3,20H21A2,2 0 0,0 23,18V6C23,4.89 22.1,4 21,4Z"/></svg>`,
desktop: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z"/></svg>`,
unknown: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2Z"/></svg>`
};
return icons[type] || icons.unknown;
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(type, title, message) {
const toast = document.getElementById('toast');
const toastIcon = document.getElementById('toastIcon');
const toastTitle = document.getElementById('toastTitle');
const toastMessage = document.getElementById('toastMessage');
const icons = {
success: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"/></svg>',
error: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
warning: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z"/></svg>',
info: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>'
};
toast.className = `toast ${type}`;
toastIcon.innerHTML = icons[type];
toastTitle.textContent = title;
toastMessage.textContent = message;
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => toast.classList.remove('show'), 5000);
}
function hideToast() {
document.getElementById('toast').classList.remove('show');
}
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (show) {
overlay.classList.add('show');
} else {
overlay.classList.remove('show');
}
}
function toggleAutoScroll() {
appState.autoScrollLogs = !appState.autoScrollLogs;
const btn = document.getElementById('autoScrollBtn');
if (appState.autoScrollLogs) {
btn.style.opacity = '1';
showToast('info', 'Auto-scroll', 'Auto-scroll activé');
} else {
btn.style.opacity = '0.5';
showToast('info', 'Auto-scroll', 'Auto-scroll désactivé');
}
}
// ===== Periodic Updates =====
function startPeriodicUpdates() {
// Update status every 5 seconds
setInterval(() => {
loadStatus();
}, 5000);
// Update devices every 10 seconds
setInterval(() => {
loadDevices();
}, 10000);
}

View file

@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travel Router Control Panel</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="header-content">
<h1>
<svg class="logo-icon" viewBox="0 0 24 24" width="36" height="36">
<path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20M16.59,7.58L10,14.17L7.41,11.59L6,13L10,17L18,9L16.59,7.58Z"/>
</svg>
Travel Router Control
</h1>
<div class="connection-info">
<div class="status-badge" id="wifiStatus">
<span class="status-dot offline"></span>
<span class="status-text">Déconnecté</span>
</div>
<div class="status-badge" id="hotspotStatus">
<span class="status-dot active"></span>
<span class="status-text">Hotspot actif</span>
</div>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="connectedDevices">0</div>
<div class="stat-label">Appareils connectés</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M5,9.2H19V10.9H5V9.2M5,13H19V14.8H5V13M5,16.8H19V18.6H5V16.8Z"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="dataUsage">0 MB</div>
<div class="stat-label">Données utilisées</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22A9,9 0 0,0 21,13A9,9 0 0,0 12,4M12.5,8H11V14L15.75,16.85L16.5,15.62L12.5,13.25V8Z"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="uptime">00:00:00</div>
<div class="stat-label">Temps de fonctionnement</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M1,9L2,11C4.97,8.03 9.03,6.17 13.5,6.17C17.97,6.17 22.03,8.03 25,11L23,9C19.68,5.68 15.09,3.5 10,3.5C4.91,3.5 0.32,5.68 -3,9L-1,11M12,21L18,15C16.34,13.34 13.34,12.34 10,12.34C6.66,12.34 3.66,13.34 2,15L8,21H12Z"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="currentNetwork">-</div>
<div class="stat-label">Réseau actuel</div>
</div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<h2>
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M1,9L2,11C4.97,8.03 9.03,6.17 13.5,6.17C17.97,6.17 22.03,8.03 25,11L23,9C19.68,5.68 15.09,3.5 10,3.5C4.91,3.5 0.32,5.68 -3,9L-1,11M12,21L18,15C16.34,13.34 13.34,12.34 10,12.34C6.66,12.34 3.66,13.34 2,15L8,21H12Z"/>
</svg>
Connexion WiFi Upstream
</h2>
<button class="btn-icon" onclick="scanWifi()" title="Scanner les réseaux">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/>
</svg>
</button>
</div>
<div class="form-group">
<label>Réseaux disponibles</label>
<div class="wifi-list" id="wifiList">
<div class="wifi-item loading">
<span>Chargement des réseaux...</span>
</div>
</div>
</div>
<div class="form-group">
<label for="wifiPassword">
<svg class="input-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z"/>
</svg>
Mot de passe
</label>
<input type="password" id="wifiPassword" placeholder="Entrez le mot de passe WiFi (laissez vide pour réseau ouvert)">
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="connectToWifi()">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M17,7L15.59,8.41L18.17,11H8V13H18.17L15.59,15.58L17,17L22,12M4,5H12V3H4C2.89,3 2,3.89 2,5V19A2,2 0 0,0 4,21H12V19H4V5Z"/>
</svg>
<span id="connectBtn">Se connecter</span>
</button>
<button class="btn btn-secondary" onclick="disconnectWifi()">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z"/>
</svg>
Déconnecter
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z"/>
</svg>
Configuration Hotspot
</h2>
<div class="toggle-switch">
<input type="checkbox" id="hotspotToggle" checked onchange="toggleHotspot()">
<label for="hotspotToggle"></label>
</div>
</div>
<div class="form-group">
<label for="hotspotName">
<svg class="input-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M12,6A10,10 0 0,0 2,16C2,16.5 2.04,17 2.11,17.5L6,13.61C6,13.61 9.26,15.45 9.26,15.45L11.12,13C11.12,13 12.19,13.53 12.19,13.53C12.19,13.53 13.72,12 13.72,12L12,10.28C12,10.28 14.97,8.12 14.97,8.12L22,15.15C22,14.77 22,14.39 22,14A10,10 0 0,0 12,6Z"/>
</svg>
Nom du réseau (SSID)
</label>
<input type="text" id="hotspotName" value="TravelRouter" placeholder="Nom du hotspot" maxlength="32">
</div>
<div class="form-group">
<label for="hotspotPassword">
<svg class="input-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z"/>
</svg>
Mot de passe (8-63 caractères)
</label>
<input type="password" id="hotspotPassword" value="travel123" placeholder="Mot de passe du hotspot" minlength="8" maxlength="63">
</div>
<div class="form-group">
<label for="hotspotChannel">
<svg class="input-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M9,10H7V17H9V10M13,10H11V17H13V10M17,10H15V17H17V10M19,3H18V1H16V3H8V1H6V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V8H19V19Z"/>
</svg>
Canal WiFi (2.4GHz)
</label>
<select id="hotspotChannel">
<option value="1">Canal 1 (2412 MHz)</option>
<option value="2">Canal 2 (2417 MHz)</option>
<option value="3">Canal 3 (2422 MHz)</option>
<option value="4">Canal 4 (2427 MHz)</option>
<option value="5">Canal 5 (2432 MHz)</option>
<option value="6" selected>Canal 6 (2437 MHz)</option>
<option value="7">Canal 7 (2442 MHz)</option>
<option value="8">Canal 8 (2447 MHz)</option>
<option value="9">Canal 9 (2452 MHz)</option>
<option value="10">Canal 10 (2457 MHz)</option>
<option value="11">Canal 11 (2462 MHz)</option>
</select>
</div>
<button class="btn btn-primary" onclick="updateHotspot()">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/>
</svg>
Mettre à jour la configuration
</button>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<h2>
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M16,17V19H2V17S2,13 9,13 16,17 16,17M12.5,7.5A3.5,3.5 0 0,1 9,11A3.5,3.5 0 0,1 5.5,7.5A3.5,3.5 0 0,1 9,4A3.5,3.5 0 0,1 12.5,7.5M15.94,13A5.32,5.32 0 0,1 18,17V19H22V17S22,13.37 15.94,13M15,4A3.39,3.39 0 0,0 13.07,4.59A5,5 0 0,1 13.07,10.41A3.39,3.39 0 0,0 15,11A3.5,3.5 0 0,0 18.5,7.5A3.5,3.5 0 0,0 15,4Z"/>
</svg>
Appareils connectés
</h2>
<span class="device-count" id="deviceCount">0</span>
</div>
<div class="devices-grid" id="devicesList">
<div class="device-placeholder">
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" opacity="0.3" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
</svg>
<p>Aucun appareil connecté</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z"/>
</svg>
Logs système
</h2>
<div class="log-controls">
<button class="btn-icon" onclick="clearLogs()" title="Effacer les logs">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
</svg>
</button>
<button class="btn-icon" onclick="toggleAutoScroll()" title="Auto-scroll" id="autoScrollBtn">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/>
</svg>
</button>
</div>
</div>
<div class="log-container" id="logContainer">
<div class="log-placeholder">
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/>
</svg>
<p>Aucun log disponible</p>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast">
<div class="toast-icon" id="toastIcon"></div>
<div class="toast-content">
<div class="toast-title" id="toastTitle"></div>
<div class="toast-message" id="toastMessage"></div>
</div>
<button class="toast-close" onclick="hideToast()">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
</svg>
</button>
</div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Chargement...</p>
</div>
<script src="/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,788 @@
/* CSS Variables for theming */
:root {
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--background: #f3f4f6;
--card-background: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--primary-gradient);
min-height: 100vh;
padding: 1.5rem;
color: var(--text-primary);
}
.container {
max-width: 1400px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
padding: 2rem;
box-shadow: var(--shadow-xl);
}
/* Header Styles */
.header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid var(--border-color);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 {
color: var(--text-primary);
font-size: 2rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
color: #667eea;
}
.connection-info {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
background: var(--background);
border: 1px solid var(--border-color);
}
.status-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.status-dot.active {
background: var(--success-color);
}
.status-dot.offline {
background: var(--text-secondary);
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-background);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
display: flex;
gap: 1rem;
align-items: center;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-icon {
width: 3rem;
height: 3rem;
border-radius: var(--radius-md);
background: var(--primary-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg {
color: white;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 1.875rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Grid Layout */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
/* Card Styles */
.card {
background: var(--card-background);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.card-header h2 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.icon {
color: #667eea;
}
/* Form Elements */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
font-size: 0.875rem;
}
.input-icon {
color: var(--text-secondary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.9375rem;
transition: all 0.2s ease;
background: var(--card-background);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
color: white;
}
.btn svg {
flex-shrink: 0;
}
.btn-primary {
background: var(--primary-gradient);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: var(--text-secondary);
}
.btn-secondary:hover {
background: #4b5563;
transform: translateY(-1px);
}
.btn-danger {
background: var(--danger-color);
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon {
background: transparent;
border: none;
padding: 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--background);
color: var(--text-primary);
}
.button-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch label {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-switch label:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + label {
background: var(--success-color);
}
.toggle-switch input:checked + label:before {
transform: translateX(24px);
}
/* WiFi List */
.wifi-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background);
}
.wifi-item {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--card-background);
}
.wifi-item:hover {
background: var(--background);
}
.wifi-item:last-child {
border-bottom: none;
}
.wifi-item.selected {
background: #eff6ff;
border-left: 4px solid #667eea;
}
.wifi-item.loading {
justify-content: center;
color: var(--text-secondary);
cursor: default;
}
.wifi-info {
flex: 1;
}
.wifi-ssid {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.wifi-details {
font-size: 0.8125rem;
color: var(--text-secondary);
display: flex;
gap: 1rem;
}
.wifi-signal {
display: flex;
align-items: center;
gap: 0.5rem;
}
.signal-bars {
display: flex;
gap: 2px;
align-items: flex-end;
height: 16px;
}
.signal-bar {
width: 3px;
background: var(--border-color);
border-radius: 2px;
}
.signal-bar:nth-child(1) { height: 25%; }
.signal-bar:nth-child(2) { height: 50%; }
.signal-bar:nth-child(3) { height: 75%; }
.signal-bar:nth-child(4) { height: 100%; }
.signal-bar.active {
background: var(--success-color);
}
/* Devices */
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
min-height: 200px;
}
.device-card {
background: var(--background);
border-radius: var(--radius-md);
padding: 1.25rem;
text-align: center;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.device-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.device-icon {
width: 48px;
height: 48px;
margin: 0 auto 0.75rem;
color: #667eea;
}
.device-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
font-size: 0.9375rem;
}
.device-type {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.device-info {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.device-placeholder {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.device-placeholder svg {
margin-bottom: 1rem;
}
.device-count {
background: var(--primary-gradient);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
/* Logs */
.log-container {
background: #1f2937;
color: #10b981;
padding: 1rem;
border-radius: var(--radius-md);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace;
font-size: 0.8125rem;
max-height: 400px;
overflow-y: auto;
line-height: 1.6;
min-height: 200px;
}
.log-entry {
margin-bottom: 0.5rem;
display: flex;
gap: 0.75rem;
}
.log-timestamp {
color: #6b7280;
flex-shrink: 0;
}
.log-source {
color: #3b82f6;
font-weight: 600;
flex-shrink: 0;
min-width: 80px;
}
.log-message {
color: #d1d5db;
}
.log-controls {
display: flex;
gap: 0.5rem;
}
.log-placeholder {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.log-placeholder svg {
margin-bottom: 1rem;
}
/* Toast Notifications */
.toast {
position: fixed;
top: 1.5rem;
right: 1.5rem;
max-width: 400px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: 1rem;
display: flex;
gap: 0.75rem;
align-items: start;
transform: translateX(calc(100% + 2rem));
transition: transform 0.3s ease;
z-index: 1000;
border: 1px solid var(--border-color);
}
.toast.show {
transform: translateX(0);
}
.toast-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast.success .toast-icon {
background: #d1fae5;
color: var(--success-color);
}
.toast.error .toast-icon {
background: #fee2e2;
color: var(--danger-color);
}
.toast.warning .toast-icon {
background: #fef3c7;
color: var(--warning-color);
}
.toast.info .toast-icon {
background: #dbeafe;
color: var(--info-color);
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.toast-message {
font-size: 0.875rem;
color: var(--text-secondary);
}
.toast-close {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.toast-close:hover {
background: var(--background);
color: var(--text-primary);
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
flex-direction: column;
gap: 1rem;
}
.loading-overlay.show {
display: flex;
}
.loading-overlay p {
color: white;
font-weight: 500;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Scrollbar Styling */
.wifi-list::-webkit-scrollbar,
.log-container::-webkit-scrollbar {
width: 8px;
}
.wifi-list::-webkit-scrollbar-track,
.log-container::-webkit-scrollbar-track {
background: transparent;
}
.wifi-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.log-container::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
}
.wifi-list::-webkit-scrollbar-thumb:hover,
.log-container::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive Design */
@media (max-width: 1024px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
.container {
padding: 1.5rem;
}
.header h1 {
font-size: 1.5rem;
}
.header-content {
flex-direction: column;
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
.button-group {
grid-template-columns: 1fr;
}
.toast {
left: 1rem;
right: 1rem;
max-width: none;
}
}
@media (max-width: 480px) {
.stat-card {
flex-direction: column;
text-align: center;
}
.devices-grid {
grid-template-columns: 1fr;
}
}