Migrate to a better architectured project
This commit is contained in:
parent
cc5ed5f23e
commit
b1b9eaa028
21 changed files with 2712 additions and 1540 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,3 @@
|
|||
internal/api/routes.gen.go
|
||||
internal/api/types.gen.go
|
||||
repeater
|
||||
/repeater
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.PHONY: build run clean install test
|
||||
|
||||
BINARY_NAME=repeater
|
||||
CMD_PATH=./cmd/repeater
|
||||
BUILD_DIR=.
|
||||
|
||||
build:
|
||||
go build -v -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH)
|
||||
|
||||
run: build
|
||||
sudo ./$(BINARY_NAME)
|
||||
|
||||
clean:
|
||||
go clean
|
||||
rm -f $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
install: build
|
||||
sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
all: fmt vet build
|
||||
102
README.md
Normal file
102
README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Travel Router Control
|
||||
|
||||
A Go application for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. The router can operate as a WiFi repeater, connecting to upstream networks while providing a hotspot for client devices.
|
||||
|
||||
## Features
|
||||
|
||||
- WiFi network scanning and connection management
|
||||
- Hotspot (access point) configuration and control
|
||||
- Connected device monitoring
|
||||
- Real-time system logs via WebSocket
|
||||
- RESTful API following OpenAPI 3.0 specification
|
||||
- Web interface for easy management
|
||||
|
||||
## Architecture
|
||||
|
||||
The application follows a clean architecture pattern:
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/
|
||||
│ └── repeater/ # Application entry point
|
||||
│ ├── main.go
|
||||
│ └── static/ # Embedded web assets
|
||||
├── internal/
|
||||
│ ├── api/ # HTTP API layer
|
||||
│ │ ├── router.go # Gin router setup
|
||||
│ │ └── handlers/ # HTTP handlers
|
||||
│ ├── app/ # Application logic & lifecycle
|
||||
│ ├── device/ # Device management
|
||||
│ ├── hotspot/ # Hotspot control
|
||||
│ ├── logging/ # Logging system
|
||||
│ ├── models/ # Data structures
|
||||
│ └── wifi/ # WiFi operations (wpa_supplicant via D-Bus)
|
||||
├── openapi.yaml # API specification
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
go build -o repeater ./cmd/repeater
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
sudo ./repeater
|
||||
```
|
||||
|
||||
The application requires root privileges to:
|
||||
- Access D-Bus system bus for wpa_supplicant
|
||||
- Control systemd services (hostapd)
|
||||
- Read DHCP leases and ARP tables
|
||||
|
||||
The server will start on port 8080.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### WiFi Operations
|
||||
- `GET /api/wifi/scan` - Scan for available networks
|
||||
- `POST /api/wifi/connect` - Connect to a network
|
||||
- `POST /api/wifi/disconnect` - Disconnect from current network
|
||||
|
||||
### Hotspot Operations
|
||||
- `POST /api/hotspot/config` - Configure hotspot settings
|
||||
- `POST /api/hotspot/toggle` - Enable/disable hotspot
|
||||
|
||||
### Device Management
|
||||
- `GET /api/devices` - Get connected devices
|
||||
|
||||
### System
|
||||
- `GET /api/status` - Get system status
|
||||
- `GET /api/logs` - Get system logs
|
||||
- `DELETE /api/logs` - Clear logs
|
||||
|
||||
### WebSocket
|
||||
- `GET /ws/logs` - Real-time log streaming
|
||||
|
||||
See `openapi.yaml` for complete API documentation.
|
||||
|
||||
## Configuration
|
||||
|
||||
The application uses the following system resources:
|
||||
|
||||
- **WiFi Interface**: `wlan0` (for upstream connection)
|
||||
- **AP Interface**: `wlan1` (for hotspot)
|
||||
- **Hostapd Config**: `/etc/hostapd/hostapd.conf`
|
||||
- **WPA Supplicant Config**: `/etc/wpa_supplicant/wpa_supplicant.conf`
|
||||
|
||||
These can be modified in the respective package constants.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Gin**: HTTP web framework
|
||||
- **godbus**: D-Bus client for wpa_supplicant control
|
||||
- **gorilla/websocket**: WebSocket support
|
||||
- **wpa_supplicant**: WiFi connection management
|
||||
- **hostapd**: Hotspot functionality
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
40
cmd/repeater/main.go
Normal file
40
cmd/repeater/main.go
Normal 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
535
cmd/repeater/static/app.js
Normal 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);
|
||||
}
|
||||
273
cmd/repeater/static/index.html
Normal file
273
cmd/repeater/static/index.html
Normal 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>
|
||||
788
cmd/repeater/static/style.css
Normal file
788
cmd/repeater/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module git.nemunai.re/nemunaire/repeater
|
||||
module github.com/nemunaire/repeater
|
||||
|
||||
go 1.24.4
|
||||
|
||||
|
|
|
|||
132
internal/api/handlers/handlers.go
Normal file
132
internal/api/handlers/handlers.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/device"
|
||||
"github.com/nemunaire/repeater/internal/hotspot"
|
||||
"github.com/nemunaire/repeater/internal/logging"
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
"github.com/nemunaire/repeater/internal/wifi"
|
||||
)
|
||||
|
||||
// ScanWiFi handles WiFi network scanning
|
||||
func ScanWiFi(c *gin.Context) {
|
||||
networks, err := wifi.ScanNetworks()
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
|
||||
c.JSON(http.StatusOK, networks)
|
||||
}
|
||||
|
||||
// ConnectWiFi handles WiFi connection requests
|
||||
func ConnectWiFi(c *gin.Context) {
|
||||
var req models.WiFiConnectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
|
||||
|
||||
err := wifi.Connect(req.SSID, req.Password)
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
// DisconnectWiFi handles WiFi disconnection
|
||||
func DisconnectWiFi(c *gin.Context) {
|
||||
logging.AddLog("WiFi", "Tentative de déconnexion")
|
||||
|
||||
err := wifi.Disconnect()
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Déconnexion réussie")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
// ConfigureHotspot handles hotspot configuration
|
||||
func ConfigureHotspot(c *gin.Context) {
|
||||
var config models.HotspotConfig
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
err := hotspot.Configure(config)
|
||||
if err != nil {
|
||||
logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
// ToggleHotspot handles hotspot enable/disable
|
||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
||||
status.HotspotEnabled = !status.HotspotEnabled
|
||||
enabled := status.HotspotEnabled
|
||||
|
||||
var err error
|
||||
if enabled {
|
||||
err = hotspot.Start()
|
||||
logging.AddLog("Hotspot", "Hotspot activé")
|
||||
} else {
|
||||
err = hotspot.Stop()
|
||||
logging.AddLog("Hotspot", "Hotspot désactivé")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logging.AddLog("Hotspot", "Erreur: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": enabled})
|
||||
}
|
||||
|
||||
// GetDevices returns connected devices
|
||||
func GetDevices(c *gin.Context) {
|
||||
devices, err := device.GetConnectedDevices()
|
||||
if err != nil {
|
||||
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
|
||||
// GetStatus returns system status
|
||||
func GetStatus(c *gin.Context, status *models.SystemStatus) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// GetLogs returns system logs
|
||||
func GetLogs(c *gin.Context) {
|
||||
logs := logging.GetLogs()
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
|
||||
// ClearLogs clears system logs
|
||||
func ClearLogs(c *gin.Context) {
|
||||
logging.ClearLogs()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
38
internal/api/handlers/websocket.go
Normal file
38
internal/api/handlers/websocket.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nemunaire/repeater/internal/logging"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// WebSocketLogs handles WebSocket connections for real-time logs
|
||||
func WebSocketLogs(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("Erreur WebSocket: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Register client
|
||||
logging.RegisterWebSocketClient(conn)
|
||||
defer logging.UnregisterWebSocketClient(conn)
|
||||
|
||||
// Keep connection alive
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
64
internal/api/router.go
Normal file
64
internal/api/router.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/api/handlers"
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
// SetupRouter creates and configures the Gin router
|
||||
func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine {
|
||||
// Set Gin to release mode (can be overridden with GIN_MODE env var)
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// WiFi endpoints
|
||||
wifi := api.Group("/wifi")
|
||||
{
|
||||
wifi.GET("/scan", handlers.ScanWiFi)
|
||||
wifi.POST("/connect", handlers.ConnectWiFi)
|
||||
wifi.POST("/disconnect", handlers.DisconnectWiFi)
|
||||
}
|
||||
|
||||
// Hotspot endpoints
|
||||
hotspot := api.Group("/hotspot")
|
||||
{
|
||||
hotspot.POST("/config", handlers.ConfigureHotspot)
|
||||
hotspot.POST("/toggle", func(c *gin.Context) {
|
||||
handlers.ToggleHotspot(c, status)
|
||||
})
|
||||
}
|
||||
|
||||
// Device endpoints
|
||||
api.GET("/devices", handlers.GetDevices)
|
||||
|
||||
// Status endpoint
|
||||
api.GET("/status", func(c *gin.Context) {
|
||||
handlers.GetStatus(c, status)
|
||||
})
|
||||
|
||||
// Log endpoints
|
||||
api.GET("/logs", handlers.GetLogs)
|
||||
api.DELETE("/logs", handlers.ClearLogs)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.GET("/ws/logs", handlers.WebSocketLogs)
|
||||
|
||||
// Serve static files
|
||||
sub, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic("Unable to access static directory: " + err.Error())
|
||||
}
|
||||
r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub))))
|
||||
|
||||
return r
|
||||
}
|
||||
102
internal/app/app.go
Normal file
102
internal/app/app.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/api"
|
||||
"github.com/nemunaire/repeater/internal/device"
|
||||
"github.com/nemunaire/repeater/internal/logging"
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
"github.com/nemunaire/repeater/internal/wifi"
|
||||
)
|
||||
|
||||
// App represents the application
|
||||
type App struct {
|
||||
Status models.SystemStatus
|
||||
StatusMutex sync.RWMutex
|
||||
StartTime time.Time
|
||||
Assets embed.FS
|
||||
}
|
||||
|
||||
// New creates a new application instance
|
||||
func New(assets embed.FS) *App {
|
||||
return &App{
|
||||
Status: models.SystemStatus{
|
||||
Connected: false,
|
||||
ConnectedSSID: "",
|
||||
HotspotEnabled: true,
|
||||
ConnectedCount: 0,
|
||||
DataUsage: 0.0,
|
||||
Uptime: 0,
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
Assets: assets,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize initializes the application
|
||||
func (a *App) Initialize() error {
|
||||
// Initialize WiFi D-Bus connection
|
||||
if err := wifi.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start periodic tasks
|
||||
go a.periodicStatusUpdate()
|
||||
go a.periodicDeviceUpdate()
|
||||
|
||||
logging.AddLog("Système", "Application initialisée")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server
|
||||
func (a *App) Run(addr string) error {
|
||||
router := api.SetupRouter(&a.Status, a.Assets)
|
||||
|
||||
logging.AddLog("Système", "Serveur API démarré sur "+addr)
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the application
|
||||
func (a *App) Shutdown() {
|
||||
wifi.Close()
|
||||
logging.AddLog("Système", "Application arrêtée")
|
||||
}
|
||||
|
||||
// periodicStatusUpdate updates WiFi connection status periodically
|
||||
func (a *App) periodicStatusUpdate() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
a.StatusMutex.Lock()
|
||||
a.Status.Connected = wifi.IsConnected()
|
||||
if !a.Status.Connected {
|
||||
a.Status.ConnectedSSID = ""
|
||||
}
|
||||
a.Status.Uptime = int64(time.Since(a.StartTime).Seconds())
|
||||
a.StatusMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// periodicDeviceUpdate updates connected devices list periodically
|
||||
func (a *App) periodicDeviceUpdate() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
devices, err := device.GetConnectedDevices()
|
||||
if err != nil {
|
||||
log.Printf("Error getting connected devices: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
a.StatusMutex.Lock()
|
||||
a.Status.ConnectedDevices = devices
|
||||
a.Status.ConnectedCount = len(devices)
|
||||
a.StatusMutex.Unlock()
|
||||
}
|
||||
}
|
||||
132
internal/device/device.go
Normal file
132
internal/device/device.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package device
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
// GetConnectedDevices returns a list of connected devices
|
||||
func GetConnectedDevices() ([]models.ConnectedDevice, error) {
|
||||
var devices []models.ConnectedDevice
|
||||
|
||||
// Read DHCP leases
|
||||
leases, err := parseDHCPLeases()
|
||||
if err != nil {
|
||||
return devices, err
|
||||
}
|
||||
|
||||
// Get ARP information
|
||||
arpInfo, err := getARPInfo()
|
||||
if err != nil {
|
||||
return devices, err
|
||||
}
|
||||
|
||||
for _, lease := range leases {
|
||||
device := models.ConnectedDevice{
|
||||
Name: lease.Hostname,
|
||||
MAC: lease.MAC,
|
||||
IP: lease.IP,
|
||||
Type: guessDeviceType(lease.Hostname, lease.MAC),
|
||||
}
|
||||
|
||||
// Check if the device is still connected via ARP
|
||||
if _, exists := arpInfo[lease.IP]; exists {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// parseDHCPLeases reads and parses DHCP lease file
|
||||
func parseDHCPLeases() ([]models.DHCPLease, error) {
|
||||
var leases []models.DHCPLease
|
||||
|
||||
file, err := os.Open("/var/lib/dhcp/dhcpd.leases")
|
||||
if err != nil {
|
||||
return leases, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var currentLease models.DHCPLease
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.HasPrefix(line, "lease ") {
|
||||
ip := strings.Fields(line)[1]
|
||||
currentLease = models.DHCPLease{IP: ip}
|
||||
} else if strings.Contains(line, "hardware ethernet") {
|
||||
mac := strings.Fields(line)[2]
|
||||
mac = strings.TrimSuffix(mac, ";")
|
||||
currentLease.MAC = mac
|
||||
} else if strings.Contains(line, "client-hostname") {
|
||||
hostname := strings.Fields(line)[1]
|
||||
hostname = strings.Trim(hostname, `";`)
|
||||
currentLease.Hostname = hostname
|
||||
} else if line == "}" {
|
||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
||||
leases = append(leases, currentLease)
|
||||
}
|
||||
currentLease = models.DHCPLease{}
|
||||
}
|
||||
}
|
||||
|
||||
return leases, nil
|
||||
}
|
||||
|
||||
// getARPInfo retrieves ARP table information
|
||||
func getARPInfo() (map[string]string, error) {
|
||||
arpInfo := make(map[string]string)
|
||||
|
||||
cmd := exec.Command("arp", "-a")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return arpInfo, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
|
||||
ip := matches[1]
|
||||
mac := matches[2]
|
||||
arpInfo[ip] = mac
|
||||
}
|
||||
}
|
||||
|
||||
return arpInfo, nil
|
||||
}
|
||||
|
||||
// guessDeviceType attempts to guess device type from hostname and MAC address
|
||||
func guessDeviceType(hostname, mac string) string {
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
|
||||
return "tablet"
|
||||
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
|
||||
return "laptop"
|
||||
}
|
||||
|
||||
// Guess by MAC prefix (OUI)
|
||||
if len(mac) >= 8 {
|
||||
macPrefix := strings.ToUpper(mac[:8])
|
||||
switch macPrefix {
|
||||
case "00:50:56", "00:0C:29", "00:05:69": // VMware
|
||||
return "laptop"
|
||||
case "08:00:27": // VirtualBox
|
||||
return "laptop"
|
||||
default:
|
||||
return "mobile"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
47
internal/hotspot/hotspot.go
Normal file
47
internal/hotspot/hotspot.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package hotspot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
AP_INTERFACE = "wlan1"
|
||||
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
|
||||
)
|
||||
|
||||
// Configure updates the hotspot configuration
|
||||
func Configure(config models.HotspotConfig) error {
|
||||
hostapdConfig := fmt.Sprintf(`interface=%s
|
||||
driver=nl80211
|
||||
ssid=%s
|
||||
hw_mode=g
|
||||
channel=%d
|
||||
wmm_enabled=0
|
||||
macaddr_acl=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
wpa=2
|
||||
wpa_passphrase=%s
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=TKIP
|
||||
rsn_pairwise=CCMP
|
||||
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
|
||||
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
|
||||
}
|
||||
|
||||
// Start starts the hotspot
|
||||
func Start() error {
|
||||
cmd := exec.Command("systemctl", "start", "hostapd")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// Stop stops the hotspot
|
||||
func Stop() error {
|
||||
cmd := exec.Command("systemctl", "stop", "hostapd")
|
||||
return cmd.Run()
|
||||
}
|
||||
94
internal/logging/logging.go
Normal file
94
internal/logging/logging.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
logEntries []models.LogEntry
|
||||
logMutex sync.RWMutex
|
||||
websocketClients = make(map[*websocket.Conn]bool)
|
||||
clientsMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// AddLog adds a new log entry
|
||||
func AddLog(source, message string) {
|
||||
logMutex.Lock()
|
||||
entry := models.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Source: source,
|
||||
Message: message,
|
||||
}
|
||||
logEntries = append(logEntries, entry)
|
||||
|
||||
// Keep only the last 100 logs
|
||||
if len(logEntries) > 100 {
|
||||
logEntries = logEntries[len(logEntries)-100:]
|
||||
}
|
||||
logMutex.Unlock()
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
broadcastToWebSockets(entry)
|
||||
|
||||
// Log to console
|
||||
log.Printf("[%s] %s", source, message)
|
||||
}
|
||||
|
||||
// GetLogs returns all log entries
|
||||
func GetLogs() []models.LogEntry {
|
||||
logMutex.RLock()
|
||||
defer logMutex.RUnlock()
|
||||
|
||||
logs := make([]models.LogEntry, len(logEntries))
|
||||
copy(logs, logEntries)
|
||||
return logs
|
||||
}
|
||||
|
||||
// ClearLogs clears all log entries
|
||||
func ClearLogs() {
|
||||
logMutex.Lock()
|
||||
logEntries = []models.LogEntry{}
|
||||
logMutex.Unlock()
|
||||
|
||||
AddLog("Système", "Logs effacés")
|
||||
}
|
||||
|
||||
// RegisterWebSocketClient registers a new WebSocket client
|
||||
func RegisterWebSocketClient(conn *websocket.Conn) {
|
||||
clientsMutex.Lock()
|
||||
websocketClients[conn] = true
|
||||
clientsMutex.Unlock()
|
||||
|
||||
// Send existing logs to the new client
|
||||
logMutex.RLock()
|
||||
for _, entry := range logEntries {
|
||||
conn.WriteJSON(entry)
|
||||
}
|
||||
logMutex.RUnlock()
|
||||
}
|
||||
|
||||
// UnregisterWebSocketClient removes a WebSocket client
|
||||
func UnregisterWebSocketClient(conn *websocket.Conn) {
|
||||
clientsMutex.Lock()
|
||||
delete(websocketClients, conn)
|
||||
clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
|
||||
func broadcastToWebSockets(entry models.LogEntry) {
|
||||
clientsMutex.RLock()
|
||||
defer clientsMutex.RUnlock()
|
||||
|
||||
for client := range websocketClients {
|
||||
err := client.WriteJSON(entry)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
delete(websocketClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
internal/models/models.go
Normal file
58
internal/models/models.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WiFiNetwork represents a discovered WiFi network
|
||||
type WiFiNetwork struct {
|
||||
SSID string `json:"ssid"`
|
||||
Signal int `json:"signal"`
|
||||
Security string `json:"security"`
|
||||
Channel int `json:"channel"`
|
||||
BSSID string `json:"bssid"`
|
||||
}
|
||||
|
||||
// ConnectedDevice represents a device connected to the hotspot
|
||||
type ConnectedDevice struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// HotspotConfig represents hotspot configuration
|
||||
type HotspotConfig struct {
|
||||
SSID string `json:"ssid"`
|
||||
Password string `json:"password"`
|
||||
Channel int `json:"channel"`
|
||||
}
|
||||
|
||||
// SystemStatus represents overall system status
|
||||
type SystemStatus struct {
|
||||
Connected bool `json:"connected"`
|
||||
ConnectedSSID string `json:"connectedSSID"`
|
||||
HotspotEnabled bool `json:"hotspotEnabled"`
|
||||
ConnectedCount int `json:"connectedCount"`
|
||||
DataUsage float64 `json:"dataUsage"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
|
||||
}
|
||||
|
||||
// WiFiConnectRequest represents a request to connect to WiFi
|
||||
type WiFiConnectRequest struct {
|
||||
SSID string `json:"ssid" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LogEntry represents a system log entry
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// DHCPLease represents a DHCP lease entry
|
||||
type DHCPLease struct {
|
||||
IP string
|
||||
MAC string
|
||||
Hostname string
|
||||
}
|
||||
273
internal/wifi/wifi.go
Normal file
273
internal/wifi/wifi.go
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
package wifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
WLAN_INTERFACE = "wlan0"
|
||||
WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf"
|
||||
|
||||
// D-Bus constants for wpa_supplicant
|
||||
WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1"
|
||||
WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1"
|
||||
WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1"
|
||||
WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface"
|
||||
WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS"
|
||||
WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network"
|
||||
)
|
||||
|
||||
var (
|
||||
dbusConn *dbus.Conn
|
||||
wpaSupplicant dbus.BusObject
|
||||
)
|
||||
|
||||
// Initialize initializes the WiFi service with D-Bus connection
|
||||
func Initialize() error {
|
||||
var err error
|
||||
dbusConn, err = dbus.SystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to D-Bus: %v", err)
|
||||
}
|
||||
|
||||
wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the D-Bus connection
|
||||
func Close() {
|
||||
if dbusConn != nil {
|
||||
dbusConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ScanNetworks scans for available WiFi networks
|
||||
func ScanNetworks() ([]models.WiFiNetwork, error) {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
// Trigger a scan
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")})
|
||||
if call.Err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan: %v", call.Err)
|
||||
}
|
||||
|
||||
// Wait for scan to complete
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Retrieve BSS list
|
||||
bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err)
|
||||
}
|
||||
|
||||
var networks []models.WiFiNetwork
|
||||
seenSSIDs := make(map[string]bool)
|
||||
|
||||
for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) {
|
||||
bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath)
|
||||
|
||||
// Get BSS properties
|
||||
var props map[string]dbus.Variant
|
||||
err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
network := models.WiFiNetwork{}
|
||||
|
||||
// Extract SSID
|
||||
if ssidBytes, ok := props["SSID"].Value().([]byte); ok {
|
||||
network.SSID = string(ssidBytes)
|
||||
}
|
||||
|
||||
// Skip duplicates and empty SSIDs
|
||||
if network.SSID == "" || seenSSIDs[network.SSID] {
|
||||
continue
|
||||
}
|
||||
seenSSIDs[network.SSID] = true
|
||||
|
||||
// Extract BSSID
|
||||
if bssidBytes, ok := props["BSSID"].Value().([]byte); ok {
|
||||
network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
||||
bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5])
|
||||
}
|
||||
|
||||
// Extract signal strength
|
||||
if signal, ok := props["Signal"].Value().(int16); ok {
|
||||
network.Signal = signalToStrength(int(signal))
|
||||
}
|
||||
|
||||
// Extract frequency and calculate channel
|
||||
if frequency, ok := props["Frequency"].Value().(uint16); ok {
|
||||
network.Channel = frequencyToChannel(int(frequency))
|
||||
}
|
||||
|
||||
// Determine security
|
||||
if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal {
|
||||
if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 {
|
||||
network.Security = "WPA"
|
||||
} else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 {
|
||||
network.Security = "WPA2"
|
||||
} else {
|
||||
network.Security = "WEP"
|
||||
}
|
||||
} else {
|
||||
network.Security = "Open"
|
||||
}
|
||||
|
||||
networks = append(networks, network)
|
||||
}
|
||||
|
||||
// Sort by signal strength
|
||||
sort.Slice(networks, func(i, j int) bool {
|
||||
return networks[i].Signal > networks[j].Signal
|
||||
})
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// Connect connects to a WiFi network using D-Bus
|
||||
func Connect(ssid, password string) error {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
|
||||
// Create a new network
|
||||
networkConfig := map[string]dbus.Variant{
|
||||
"ssid": dbus.MakeVariant(ssid),
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
networkConfig["psk"] = dbus.MakeVariant(password)
|
||||
}
|
||||
|
||||
var networkPath dbus.ObjectPath
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err)
|
||||
}
|
||||
|
||||
// Select the network
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la sélection du réseau: %v", err)
|
||||
}
|
||||
|
||||
// Wait for connection
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if IsConnected() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("timeout lors de la connexion")
|
||||
}
|
||||
|
||||
// Disconnect disconnects from the current WiFi network
|
||||
func Disconnect() error {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
|
||||
// Disconnect
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la déconnexion: %v", err)
|
||||
}
|
||||
|
||||
// Remove all networks
|
||||
var networks []dbus.ObjectPath
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks)
|
||||
if err == nil {
|
||||
for _, networkPath := range networks {
|
||||
wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected checks if WiFi is connected using D-Bus
|
||||
func IsConnected() bool {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
var state string
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == "completed"
|
||||
}
|
||||
|
||||
// IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback)
|
||||
func IsConnectedLegacy() bool {
|
||||
cmd := exec.Command("iwconfig", WLAN_INTERFACE)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(string(output), "Access Point:")
|
||||
}
|
||||
|
||||
// getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface
|
||||
func getWiFiInterfacePath() (dbus.ObjectPath, error) {
|
||||
var interfacePath dbus.ObjectPath
|
||||
err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err)
|
||||
}
|
||||
|
||||
return interfacePath, nil
|
||||
}
|
||||
|
||||
// frequencyToChannel converts WiFi frequency to channel number
|
||||
func frequencyToChannel(frequency int) int {
|
||||
if frequency >= 2412 && frequency <= 2484 {
|
||||
if frequency == 2484 {
|
||||
return 14
|
||||
}
|
||||
return (frequency-2412)/5 + 1
|
||||
} else if frequency >= 5170 && frequency <= 5825 {
|
||||
return (frequency - 5000) / 5
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// signalToStrength converts signal level (dBm) to strength (1-5)
|
||||
func signalToStrength(level int) int {
|
||||
if level >= -30 {
|
||||
return 5
|
||||
} else if level >= -50 {
|
||||
return 4
|
||||
} else if level >= -60 {
|
||||
return 3
|
||||
} else if level >= -70 {
|
||||
return 2
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
826
main.go
826
main.go
|
|
@ -1,826 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
//go:embed all:static
|
||||
var _assets embed.FS
|
||||
|
||||
// Structures de données
|
||||
type WiFiNetwork struct {
|
||||
SSID string `json:"ssid"`
|
||||
Signal int `json:"signal"`
|
||||
Security string `json:"security"`
|
||||
Channel int `json:"channel"`
|
||||
BSSID string `json:"bssid"`
|
||||
}
|
||||
|
||||
type ConnectedDevice struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type HotspotConfig struct {
|
||||
SSID string `json:"ssid"`
|
||||
Password string `json:"password"`
|
||||
Channel int `json:"channel"`
|
||||
}
|
||||
|
||||
type SystemStatus struct {
|
||||
Connected bool `json:"connected"`
|
||||
ConnectedSSID string `json:"connectedSSID"`
|
||||
HotspotEnabled bool `json:"hotspotEnabled"`
|
||||
ConnectedCount int `json:"connectedCount"`
|
||||
DataUsage float64 `json:"dataUsage"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
|
||||
}
|
||||
|
||||
type WiFiConnectRequest struct {
|
||||
SSID string `json:"ssid"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Variables globales
|
||||
var (
|
||||
currentStatus SystemStatus
|
||||
statusMutex sync.RWMutex
|
||||
logEntries []LogEntry
|
||||
logMutex sync.RWMutex
|
||||
websocketClients = make(map[*websocket.Conn]bool)
|
||||
clientsMutex sync.RWMutex
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
startTime = time.Now()
|
||||
dbusConn *dbus.Conn
|
||||
wpaSupplicant dbus.BusObject
|
||||
)
|
||||
|
||||
const (
|
||||
WLAN_INTERFACE = "wlan0"
|
||||
AP_INTERFACE = "wlan1"
|
||||
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
|
||||
WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf"
|
||||
|
||||
// D-Bus constantes pour wpa_supplicant
|
||||
WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1"
|
||||
WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1"
|
||||
WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1"
|
||||
WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface"
|
||||
WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS"
|
||||
WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialiser D-Bus
|
||||
var err error
|
||||
dbusConn, err = dbus.SystemBus()
|
||||
if err != nil {
|
||||
log.Fatalf("Erreur de connexion D-Bus: %v", err)
|
||||
}
|
||||
defer dbusConn.Close()
|
||||
|
||||
wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH))
|
||||
|
||||
// Initialiser le statut système
|
||||
initializeStatus()
|
||||
|
||||
// Démarrer les tâches périodiques
|
||||
go periodicStatusUpdate()
|
||||
go periodicDeviceUpdate()
|
||||
|
||||
// Configuration du routeur
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Routes API
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
api.HandleFunc("/wifi/scan", scanWiFiHandler).Methods("GET")
|
||||
api.HandleFunc("/wifi/connect", connectWiFiHandler).Methods("POST")
|
||||
api.HandleFunc("/wifi/disconnect", disconnectWiFiHandler).Methods("POST")
|
||||
api.HandleFunc("/hotspot/config", configureHotspotHandler).Methods("POST")
|
||||
api.HandleFunc("/hotspot/toggle", toggleHotspotHandler).Methods("POST")
|
||||
api.HandleFunc("/devices", getDevicesHandler).Methods("GET")
|
||||
api.HandleFunc("/status", getStatusHandler).Methods("GET")
|
||||
api.HandleFunc("/logs", getLogsHandler).Methods("GET")
|
||||
api.HandleFunc("/logs", clearLogsHandler).Methods("DELETE")
|
||||
|
||||
// WebSocket pour les logs en temps réel
|
||||
r.HandleFunc("/ws/logs", websocketHandler)
|
||||
|
||||
// Servir les fichiers statiques
|
||||
sub, err := fs.Sub(_assets, "static")
|
||||
if err != nil {
|
||||
log.Fatal("Unable to cd to static/ directory:", err)
|
||||
}
|
||||
Assets := http.FS(sub)
|
||||
r.PathPrefix("/").Handler(http.FileServer(Assets))
|
||||
|
||||
addLog("Système", "Serveur API démarré sur le port 8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", r))
|
||||
}
|
||||
|
||||
func initializeStatus() {
|
||||
statusMutex.Lock()
|
||||
defer statusMutex.Unlock()
|
||||
|
||||
currentStatus = SystemStatus{
|
||||
Connected: false,
|
||||
ConnectedSSID: "",
|
||||
HotspotEnabled: true,
|
||||
ConnectedCount: 0,
|
||||
DataUsage: 0.0,
|
||||
Uptime: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers API
|
||||
|
||||
func scanWiFiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
networks, err := scanWiFiNetworks()
|
||||
if err != nil {
|
||||
addLog("WiFi", fmt.Sprintf("Erreur lors du scan: %v", err))
|
||||
http.Error(w, "Erreur lors du scan WiFi", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
addLog("WiFi", fmt.Sprintf("Scan terminé - %d réseaux trouvés", len(networks)))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(networks)
|
||||
}
|
||||
|
||||
func connectWiFiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req WiFiConnectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Données invalides", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
addLog("WiFi", fmt.Sprintf("Tentative de connexion à %s", req.SSID))
|
||||
|
||||
err := connectToWiFiDBus(req.SSID, req.Password)
|
||||
if err != nil {
|
||||
addLog("WiFi", fmt.Sprintf("Échec de connexion: %v", err))
|
||||
http.Error(w, fmt.Sprintf("Échec de connexion: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
statusMutex.Lock()
|
||||
currentStatus.Connected = true
|
||||
currentStatus.ConnectedSSID = req.SSID
|
||||
statusMutex.Unlock()
|
||||
|
||||
addLog("WiFi", fmt.Sprintf("Connexion réussie à %s", req.SSID))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func disconnectWiFiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
addLog("WiFi", "Tentative de déconnexion")
|
||||
|
||||
err := disconnectWiFiDBus()
|
||||
if err != nil {
|
||||
addLog("WiFi", fmt.Sprintf("Erreur de déconnexion: %v", err))
|
||||
http.Error(w, fmt.Sprintf("Erreur de déconnexion: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
statusMutex.Lock()
|
||||
currentStatus.Connected = false
|
||||
currentStatus.ConnectedSSID = ""
|
||||
statusMutex.Unlock()
|
||||
|
||||
addLog("WiFi", "Déconnexion réussie")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func configureHotspotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var config HotspotConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
http.Error(w, "Données invalides", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := configureHotspot(config)
|
||||
if err != nil {
|
||||
addLog("Hotspot", fmt.Sprintf("Erreur de configuration: %v", err))
|
||||
http.Error(w, fmt.Sprintf("Erreur de configuration: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
addLog("Hotspot", fmt.Sprintf("Configuration mise à jour: %s (Canal %d)", config.SSID, config.Channel))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func toggleHotspotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
statusMutex.Lock()
|
||||
currentStatus.HotspotEnabled = !currentStatus.HotspotEnabled
|
||||
enabled := currentStatus.HotspotEnabled
|
||||
statusMutex.Unlock()
|
||||
|
||||
var err error
|
||||
if enabled {
|
||||
err = startHotspot()
|
||||
addLog("Hotspot", "Hotspot activé")
|
||||
} else {
|
||||
err = stopHotspot()
|
||||
addLog("Hotspot", "Hotspot désactivé")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
addLog("Hotspot", fmt.Sprintf("Erreur: %v", err))
|
||||
http.Error(w, fmt.Sprintf("Erreur: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled})
|
||||
}
|
||||
|
||||
func getDevicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
devices, err := getConnectedDevices()
|
||||
if err != nil {
|
||||
addLog("Système", fmt.Sprintf("Erreur lors de la récupération des appareils: %v", err))
|
||||
http.Error(w, "Erreur lors de la récupération des appareils", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(devices)
|
||||
}
|
||||
|
||||
func getStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
statusMutex.RLock()
|
||||
status := currentStatus
|
||||
status.Uptime = int64(time.Since(startTime).Seconds())
|
||||
statusMutex.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func getLogsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
logMutex.RLock()
|
||||
logs := make([]LogEntry, len(logEntries))
|
||||
copy(logs, logEntries)
|
||||
logMutex.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
func clearLogsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
logMutex.Lock()
|
||||
logEntries = []LogEntry{}
|
||||
logMutex.Unlock()
|
||||
|
||||
addLog("Système", "Logs effacés")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// Fonctions WiFi avec D-Bus
|
||||
|
||||
func scanWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
// Déclencher un scan
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")})
|
||||
if call.Err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan: %v", call.Err)
|
||||
}
|
||||
|
||||
// Attendre un peu pour que le scan se termine
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Récupérer la liste des BSS
|
||||
bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err)
|
||||
}
|
||||
|
||||
var networks []WiFiNetwork
|
||||
seenSSIDs := make(map[string]bool)
|
||||
|
||||
for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) {
|
||||
bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath)
|
||||
|
||||
// Récupérer les propriétés du BSS
|
||||
var props map[string]dbus.Variant
|
||||
err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
network := WiFiNetwork{}
|
||||
|
||||
// Extraire SSID
|
||||
if ssidBytes, ok := props["SSID"].Value().([]byte); ok {
|
||||
network.SSID = string(ssidBytes)
|
||||
}
|
||||
|
||||
// Éviter les doublons
|
||||
if network.SSID == "" || seenSSIDs[network.SSID] {
|
||||
continue
|
||||
}
|
||||
seenSSIDs[network.SSID] = true
|
||||
|
||||
// Extraire BSSID
|
||||
if bssidBytes, ok := props["BSSID"].Value().([]byte); ok {
|
||||
network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
||||
bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5])
|
||||
}
|
||||
|
||||
// Extraire la force du signal
|
||||
if signal, ok := props["Signal"].Value().(int16); ok {
|
||||
network.Signal = signalToStrength(int(signal))
|
||||
}
|
||||
|
||||
// Extraire la fréquence et calculer le canal
|
||||
if frequency, ok := props["Frequency"].Value().(uint16); ok {
|
||||
network.Channel = frequencyToChannel(int(frequency))
|
||||
}
|
||||
|
||||
// Déterminer la sécurité
|
||||
if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal {
|
||||
if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 {
|
||||
network.Security = "WPA"
|
||||
} else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 {
|
||||
network.Security = "WPA2"
|
||||
} else {
|
||||
network.Security = "WEP"
|
||||
}
|
||||
} else {
|
||||
network.Security = "Open"
|
||||
}
|
||||
|
||||
networks = append(networks, network)
|
||||
}
|
||||
|
||||
// Trier par force du signal
|
||||
sort.Slice(networks, func(i, j int) bool {
|
||||
return networks[i].Signal > networks[j].Signal
|
||||
})
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func connectToWiFiDBus(ssid, password string) error {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
|
||||
// Créer un nouveau réseau
|
||||
networkConfig := map[string]dbus.Variant{
|
||||
"ssid": dbus.MakeVariant(ssid),
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
networkConfig["psk"] = dbus.MakeVariant(password)
|
||||
}
|
||||
|
||||
var networkPath dbus.ObjectPath
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err)
|
||||
}
|
||||
|
||||
// Sélectionner le réseau
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la sélection du réseau: %v", err)
|
||||
}
|
||||
|
||||
// Attendre la connexion
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if isConnectedDBus() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("timeout lors de la connexion")
|
||||
}
|
||||
|
||||
func disconnectWiFiDBus() error {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
|
||||
// Déconnecter
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la déconnexion: %v", err)
|
||||
}
|
||||
|
||||
// Supprimer tous les réseaux
|
||||
var networks []dbus.ObjectPath
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks)
|
||||
if err == nil {
|
||||
for _, networkPath := range networks {
|
||||
wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getWiFiInterfacePath() (dbus.ObjectPath, error) {
|
||||
var interfacePath dbus.ObjectPath
|
||||
err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err)
|
||||
}
|
||||
|
||||
return interfacePath, nil
|
||||
}
|
||||
|
||||
func isConnectedDBus() bool {
|
||||
interfacePath, err := getWiFiInterfacePath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
|
||||
var state string
|
||||
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == "completed"
|
||||
}
|
||||
|
||||
func frequencyToChannel(frequency int) int {
|
||||
if frequency >= 2412 && frequency <= 2484 {
|
||||
if frequency == 2484 {
|
||||
return 14
|
||||
}
|
||||
return (frequency-2412)/5 + 1
|
||||
} else if frequency >= 5170 && frequency <= 5825 {
|
||||
return (frequency - 5000) / 5
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func signalToStrength(level int) int {
|
||||
if level >= -30 {
|
||||
return 5
|
||||
} else if level >= -50 {
|
||||
return 4
|
||||
} else if level >= -60 {
|
||||
return 3
|
||||
} else if level >= -70 {
|
||||
return 2
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func connectToWiFi(ssid, password string) error {
|
||||
// Créer la configuration wpa_supplicant
|
||||
config := fmt.Sprintf(`ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=FR
|
||||
|
||||
network={
|
||||
ssid="%s"
|
||||
psk="%s"
|
||||
}
|
||||
`, ssid, password)
|
||||
|
||||
err := os.WriteFile(WPA_CONF, []byte(config), 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Redémarrer wpa_supplicant
|
||||
cmd := exec.Command("systemctl", "restart", "wpa_supplicant")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Attendre la connexion
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if isConnected() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("timeout lors de la connexion")
|
||||
}
|
||||
|
||||
func isConnected() bool {
|
||||
cmd := exec.Command("iwconfig", WLAN_INTERFACE)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(string(output), "Access Point:")
|
||||
}
|
||||
|
||||
// Fonctions Hotspot
|
||||
|
||||
func configureHotspot(config HotspotConfig) error {
|
||||
hostapdConfig := fmt.Sprintf(`interface=%s
|
||||
driver=nl80211
|
||||
ssid=%s
|
||||
hw_mode=g
|
||||
channel=%d
|
||||
wmm_enabled=0
|
||||
macaddr_acl=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
wpa=2
|
||||
wpa_passphrase=%s
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=TKIP
|
||||
rsn_pairwise=CCMP
|
||||
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
|
||||
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
|
||||
}
|
||||
|
||||
func startHotspot() error {
|
||||
cmd := exec.Command("systemctl", "start", "hostapd")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func stopHotspot() error {
|
||||
cmd := exec.Command("systemctl", "stop", "hostapd")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// Fonctions pour les appareils connectés
|
||||
|
||||
func getConnectedDevices() ([]ConnectedDevice, error) {
|
||||
var devices []ConnectedDevice
|
||||
|
||||
// Lire les baux DHCP
|
||||
leases, err := parseDHCPLeases()
|
||||
if err != nil {
|
||||
return devices, err
|
||||
}
|
||||
|
||||
// Obtenir les informations ARP
|
||||
arpInfo, err := getARPInfo()
|
||||
if err != nil {
|
||||
return devices, err
|
||||
}
|
||||
|
||||
for _, lease := range leases {
|
||||
device := ConnectedDevice{
|
||||
Name: lease.Hostname,
|
||||
MAC: lease.MAC,
|
||||
IP: lease.IP,
|
||||
Type: guessDeviceType(lease.Hostname, lease.MAC),
|
||||
}
|
||||
|
||||
// Vérifier si l'appareil est toujours connecté via ARP
|
||||
if _, exists := arpInfo[lease.IP]; exists {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
type DHCPLease struct {
|
||||
IP string
|
||||
MAC string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func parseDHCPLeases() ([]DHCPLease, error) {
|
||||
var leases []DHCPLease
|
||||
|
||||
file, err := os.Open("/var/lib/dhcp/dhcpd.leases")
|
||||
if err != nil {
|
||||
return leases, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var currentLease DHCPLease
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.HasPrefix(line, "lease ") {
|
||||
ip := strings.Fields(line)[1]
|
||||
currentLease = DHCPLease{IP: ip}
|
||||
} else if strings.Contains(line, "hardware ethernet") {
|
||||
mac := strings.Fields(line)[2]
|
||||
mac = strings.TrimSuffix(mac, ";")
|
||||
currentLease.MAC = mac
|
||||
} else if strings.Contains(line, "client-hostname") {
|
||||
hostname := strings.Fields(line)[1]
|
||||
hostname = strings.Trim(hostname, `";`)
|
||||
currentLease.Hostname = hostname
|
||||
} else if line == "}" {
|
||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
||||
leases = append(leases, currentLease)
|
||||
}
|
||||
currentLease = DHCPLease{}
|
||||
}
|
||||
}
|
||||
|
||||
return leases, nil
|
||||
}
|
||||
|
||||
func getARPInfo() (map[string]string, error) {
|
||||
arpInfo := make(map[string]string)
|
||||
|
||||
cmd := exec.Command("arp", "-a")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return arpInfo, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
|
||||
ip := matches[1]
|
||||
mac := matches[2]
|
||||
arpInfo[ip] = mac
|
||||
}
|
||||
}
|
||||
|
||||
return arpInfo, nil
|
||||
}
|
||||
|
||||
func guessDeviceType(hostname, mac string) string {
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
|
||||
return "tablet"
|
||||
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
|
||||
return "laptop"
|
||||
}
|
||||
|
||||
// Deviner par préfixe MAC (OUI)
|
||||
macPrefix := strings.ToUpper(mac[:8])
|
||||
switch macPrefix {
|
||||
case "00:50:56", "00:0C:29", "00:05:69": // VMware
|
||||
return "laptop"
|
||||
case "08:00:27": // VirtualBox
|
||||
return "laptop"
|
||||
default:
|
||||
return "mobile"
|
||||
}
|
||||
}
|
||||
|
||||
// Fonctions de logging
|
||||
|
||||
func addLog(source, message string) {
|
||||
logMutex.Lock()
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Source: source,
|
||||
Message: message,
|
||||
}
|
||||
logEntries = append(logEntries, entry)
|
||||
|
||||
// Garder seulement les 100 derniers logs
|
||||
if len(logEntries) > 100 {
|
||||
logEntries = logEntries[len(logEntries)-100:]
|
||||
}
|
||||
logMutex.Unlock()
|
||||
|
||||
// Envoyer aux clients WebSocket
|
||||
broadcastToWebSockets(entry)
|
||||
|
||||
// Log vers la console
|
||||
log.Printf("[%s] %s", source, message)
|
||||
}
|
||||
|
||||
// WebSocket pour les logs en temps réel
|
||||
|
||||
func websocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Erreur WebSocket: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
clientsMutex.Lock()
|
||||
websocketClients[conn] = true
|
||||
clientsMutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
clientsMutex.Lock()
|
||||
delete(websocketClients, conn)
|
||||
clientsMutex.Unlock()
|
||||
}()
|
||||
|
||||
// Envoyer les logs existants
|
||||
logMutex.RLock()
|
||||
for _, entry := range logEntries {
|
||||
conn.WriteJSON(entry)
|
||||
}
|
||||
logMutex.RUnlock()
|
||||
|
||||
// Maintenir la connexion
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastToWebSockets(entry LogEntry) {
|
||||
clientsMutex.RLock()
|
||||
defer clientsMutex.RUnlock()
|
||||
|
||||
for client := range websocketClients {
|
||||
err := client.WriteJSON(entry)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
delete(websocketClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tâches périodiques
|
||||
|
||||
func periodicStatusUpdate() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
statusMutex.Lock()
|
||||
currentStatus.Connected = isConnected()
|
||||
if !currentStatus.Connected {
|
||||
currentStatus.ConnectedSSID = ""
|
||||
}
|
||||
statusMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func periodicDeviceUpdate() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
devices, err := getConnectedDevices()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusMutex.Lock()
|
||||
currentStatus.ConnectedDevices = devices
|
||||
currentStatus.ConnectedCount = len(devices)
|
||||
statusMutex.Unlock()
|
||||
}
|
||||
}
|
||||
239
static/app.js
239
static/app.js
|
|
@ -1,239 +0,0 @@
|
|||
// État global de l'application
|
||||
let appState = {
|
||||
selectedWifi: null,
|
||||
hotspotEnabled: true,
|
||||
connectedDevices: [],
|
||||
wifiNetworks: [],
|
||||
uptime: 0,
|
||||
dataUsage: 0
|
||||
};
|
||||
|
||||
// Simulation de données
|
||||
const mockDevices = [
|
||||
{ name: "iPhone 13", type: "mobile", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101" },
|
||||
{ name: "MacBook Pro", type: "laptop", mac: "11:22:33:44:55:66", ip: "192.168.1.102" },
|
||||
{ name: "iPad", type: "tablet", mac: "77:88:99:AA:BB:CC", ip: "192.168.1.103" }
|
||||
];
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeApp();
|
||||
startPeriodicUpdates();
|
||||
});
|
||||
|
||||
function initializeApp() {
|
||||
updateWifiList();
|
||||
updateDevicesList();
|
||||
updateStats();
|
||||
addLog("Système", "Interface web initialisée");
|
||||
}
|
||||
|
||||
async function updateWifiList() {
|
||||
const wifiList = document.getElementById('wifiList');
|
||||
wifiList.innerHTML = '';
|
||||
|
||||
(await (await fetch('/api/wifi/scan')).json()).forEach((network, index) => {
|
||||
const wifiItem = document.createElement('div');
|
||||
wifiItem.className = 'wifi-item';
|
||||
wifiItem.onclick = () => selectWifi(network, wifiItem);
|
||||
|
||||
wifiItem.innerHTML = `
|
||||
<div>
|
||||
<strong>${network.ssid}</strong>
|
||||
<div style="font-size: 0.8em; color: #666;">${network.security} • Canal ${network.channel}</div>
|
||||
</div>
|
||||
<div class="wifi-signal">
|
||||
${generateSignalBars(network.signal)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
wifiList.appendChild(wifiItem);
|
||||
});
|
||||
}
|
||||
|
||||
function generateSignalBars(strength) {
|
||||
const bars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const height = i * 3;
|
||||
const active = i <= strength ? 'active' : '';
|
||||
bars.push(`<div class="signal-bar ${active}" style="height: ${height}px;"></div>`);
|
||||
}
|
||||
return `<div class="signal-bars">${bars.join('')}</div>`;
|
||||
}
|
||||
|
||||
function selectWifi(network, element) {
|
||||
// Retirer la sélection précédente
|
||||
document.querySelectorAll('.wifi-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Ajouter la sélection
|
||||
element.classList.add('selected');
|
||||
appState.selectedWifi = network;
|
||||
|
||||
addLog("WiFi", `Réseau sélectionné: ${network.ssid}`);
|
||||
}
|
||||
|
||||
function updateDevicesList() {
|
||||
const devicesList = document.getElementById('devicesList');
|
||||
devicesList.innerHTML = '';
|
||||
|
||||
mockDevices.forEach(device => {
|
||||
const deviceCard = document.createElement('div');
|
||||
deviceCard.className = 'device-card';
|
||||
|
||||
const deviceIcon = getDeviceIcon(device.type);
|
||||
|
||||
deviceCard.innerHTML = `
|
||||
${deviceIcon}
|
||||
<div style="font-weight: 500;">${device.name}</div>
|
||||
<div style="font-size: 0.8em; color: #666;">${device.ip}</div>
|
||||
`;
|
||||
|
||||
devicesList.appendChild(deviceCard);
|
||||
});
|
||||
|
||||
document.getElementById('connectedDevices').textContent = mockDevices.length;
|
||||
}
|
||||
|
||||
function getDeviceIcon(type) {
|
||||
const icons = {
|
||||
mobile: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M17 1H7c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 18H7V5h10v14z"/></svg>',
|
||||
laptop: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg>',
|
||||
tablet: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M21 4H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H3V6h18v12z"/></svg>'
|
||||
};
|
||||
return icons[type] || icons.mobile;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
appState.uptime += 1;
|
||||
appState.dataUsage += Math.random() * 0.5;
|
||||
|
||||
const hours = Math.floor(appState.uptime / 3600);
|
||||
const minutes = Math.floor((appState.uptime % 3600) / 60);
|
||||
const seconds = appState.uptime % 60;
|
||||
|
||||
document.getElementById('uptime').textContent =
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
document.getElementById('dataUsage').textContent = `${appState.dataUsage.toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function addLog(source, message) {
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry';
|
||||
logEntry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span> ${source}: ${message}`;
|
||||
|
||||
logContainer.appendChild(logEntry);
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'success') {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `notification ${type}`;
|
||||
notification.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Fonctions d'action
|
||||
function scanWifi() {
|
||||
const scanBtn = document.getElementById('scanBtn');
|
||||
const originalText = scanBtn.textContent;
|
||||
|
||||
scanBtn.innerHTML = '<div class="loading"></div> Scan en cours...';
|
||||
|
||||
setTimeout(() => {
|
||||
updateWifiList();
|
||||
scanBtn.textContent = originalText;
|
||||
showNotification('Scan terminé - Réseaux mis à jour');
|
||||
addLog("WiFi", "Scan des réseaux terminé");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function connectToWifi() {
|
||||
if (!appState.selectedWifi) {
|
||||
showNotification('Veuillez sélectionner un réseau WiFi', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = document.getElementById('wifiPassword').value;
|
||||
if (!password && appState.selectedWifi.security !== 'Open') {
|
||||
showNotification('Mot de passe requis', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const originalText = connectBtn.textContent;
|
||||
|
||||
connectBtn.innerHTML = '<div class="loading"></div> Connexion...';
|
||||
|
||||
setTimeout(() => {
|
||||
connectBtn.textContent = originalText;
|
||||
showNotification(`Connecté à ${appState.selectedWifi.ssid}`);
|
||||
addLog("WiFi", `Connexion établie avec ${appState.selectedWifi.ssid}`);
|
||||
|
||||
// Mettre à jour le statut
|
||||
document.getElementById('connectionStatus').innerHTML = `
|
||||
<div class="status-dot"></div>
|
||||
<span>Connecté à ${appState.selectedWifi.ssid}</span>
|
||||
`;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function updateHotspot() {
|
||||
const name = document.getElementById('hotspotName').value;
|
||||
const password = document.getElementById('hotspotPassword').value;
|
||||
const channel = document.getElementById('hotspotChannel').value;
|
||||
|
||||
if (!name || !password) {
|
||||
showNotification('Nom et mot de passe requis', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showNotification('Configuration du hotspot mise à jour');
|
||||
addLog("Hotspot", `Configuration mise à jour: ${name} (Canal ${channel})`);
|
||||
}
|
||||
|
||||
function toggleHotspot() {
|
||||
appState.hotspotEnabled = !appState.hotspotEnabled;
|
||||
const btn = document.getElementById('hotspotBtn');
|
||||
|
||||
if (appState.hotspotEnabled) {
|
||||
btn.textContent = 'Arrêter le hotspot';
|
||||
showNotification('Hotspot activé');
|
||||
addLog("Hotspot", "Hotspot activé");
|
||||
} else {
|
||||
btn.textContent = 'Démarrer le hotspot';
|
||||
showNotification('Hotspot désactivé');
|
||||
addLog("Hotspot", "Hotspot désactivé");
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('logContainer').innerHTML = '';
|
||||
addLog("Système", "Logs effacés");
|
||||
}
|
||||
|
||||
// Mises à jour périodiques
|
||||
function startPeriodicUpdates() {
|
||||
setInterval(updateStats, 1000);
|
||||
setInterval(() => {
|
||||
// Simulation de nouveaux logs
|
||||
if (Math.random() > 0.95) {
|
||||
const events = [
|
||||
"Nouveau client connecté",
|
||||
"Paquet routé vers l'extérieur",
|
||||
"Vérification de la connexion",
|
||||
"Mise à jour des tables de routage"
|
||||
];
|
||||
const randomEvent = events[Math.floor(Math.random() * events.length)];
|
||||
addLog("Système", randomEvent);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WiFi Repeater Control</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🌐 WiFi Repeater Control</h1>
|
||||
<div class="status-indicator" id="connectionStatus">
|
||||
<div class="status-dot"></div>
|
||||
<span>En ligne</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="connectedDevices">0</div>
|
||||
<div class="stat-label">Appareils connectés</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="dataUsage">0 MB</div>
|
||||
<div class="stat-label">Données utilisées</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="uptime">00:00:00</div>
|
||||
<div class="stat-label">Temps de fonctionnement</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.07 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
|
||||
</svg>
|
||||
Connexion WiFi Externe
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Réseaux disponibles</label>
|
||||
<div class="wifi-list" id="wifiList">
|
||||
<!-- Liste des réseaux WiFi sera remplie par JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wifiPassword">Mot de passe</label>
|
||||
<input type="password" id="wifiPassword" placeholder="Entrez le mot de passe WiFi">
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="connectToWifi()">
|
||||
<span id="connectBtn">Se connecter</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" onclick="scanWifi()">
|
||||
<span id="scanBtn">Scanner les réseaux</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
Configuration Hotspot
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hotspotName">Nom du réseau (SSID)</label>
|
||||
<input type="text" id="hotspotName" value="MyRepeater" placeholder="Nom du hotspot">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hotspotPassword">Mot de passe</label>
|
||||
<input type="password" id="hotspotPassword" value="password123" placeholder="Mot de passe du hotspot">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hotspotChannel">Canal</label>
|
||||
<select id="hotspotChannel">
|
||||
<option value="1">Canal 1</option>
|
||||
<option value="6" selected>Canal 6</option>
|
||||
<option value="11">Canal 11</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="updateHotspot()">Mettre à jour</button>
|
||||
<button class="btn btn-danger" onclick="toggleHotspot()">
|
||||
<span id="hotspotBtn">Arrêter le hotspot</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
|
||||
</svg>
|
||||
Appareils connectés
|
||||
</h2>
|
||||
|
||||
<div class="devices-grid" id="devicesList">
|
||||
<!-- Liste des appareils sera remplie par JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||
</svg>
|
||||
Logs système
|
||||
</h2>
|
||||
|
||||
<div class="log-container" id="logContainer">
|
||||
<!-- Les logs seront ajoutés ici -->
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary" onclick="clearLogs()" style="margin-top: 15px;">
|
||||
Effacer les logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification" id="notification"></div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
338
static/style.css
338
static/style.css
|
|
@ -1,338 +0,0 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #333;
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 25px;
|
||||
font-weight: 500;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.4em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.wifi-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.wifi-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wifi-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.wifi-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wifi-item.selected {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.wifi-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.signal-strength {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signal-bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.signal-bar {
|
||||
width: 3px;
|
||||
background: #ccc;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.signal-bar.active {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 10px;
|
||||
fill: #667eea;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #1a1a1a;
|
||||
color: #00ff00;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.notification.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue