Files
PTT-Live/electron/ui/app.js
T
benoit 530c3a10b2 feat: application desktop Electron avec interface graphique complète
- Main Process spawn serveur automatiquement avec IPC sécurisé
- Dashboard temps réel : stats, utilisateurs, QR Code
- Configuration audio : devices, sample rate, bitrate, jitter buffer
- Gestion groupes : CRUD complet via API admin
- Monitoring : logs temps réel filtrables par niveau
- Notifications : toast visuelles avec auto-dismiss
- Packaging : electron-builder pour macOS (.dmg) et Linux (.deb/.AppImage)
- Documentation : README technique, QUICKSTART, CHANGELOG, guide utilisateur

Structure :
- electron/main.js (333 lignes) : Main Process + spawn serveur
- electron/preload.js (31 lignes) : IPC bridge sécurisé
- electron/ui/index.html (187 lignes) : interface dashboard
- electron/ui/styles.css (556 lignes) : dark theme
- electron/ui/app.js (626 lignes) : logic frontend

Total : 1733 lignes de code

Lancement : ./start-desktop.sh

API utilisées : /admin/stats, /admin/users, /admin/groups, /admin/config, /admin/devices/list

TODO : WebSocket VU-mètres, icônes, tray menu, graphiques monitoring
2026-06-19 11:04:29 +02:00

627 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PTT Live Desktop - Renderer Process Logic
*/
const API_BASE = 'http://localhost:3000';
// État global
let serverRunning = false;
let statsInterval = null;
let logsBuffer = [];
// ========== Initialisation ==========
document.addEventListener('DOMContentLoaded', async () => {
console.log('🚀 Interface Electron chargée');
// Setup navigation
setupNavigation();
// Setup contrôles serveur
setupServerControls();
// Setup logs listener
setupLogsListener();
// Vérifier le statut initial du serveur
await checkServerStatus();
// Charger les données initiales
loadInitialData();
});
// ========== Navigation ==========
function setupNavigation() {
const navItems = document.querySelectorAll('.nav-item');
const views = document.querySelectorAll('.view');
navItems.forEach(item => {
item.addEventListener('click', () => {
const targetView = item.dataset.view;
// Mettre à jour l'état actif
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
views.forEach(view => view.classList.remove('active'));
document.getElementById(`view-${targetView}`).classList.add('active');
// Charger les données de la vue
loadViewData(targetView);
});
});
}
// ========== Contrôles Serveur ==========
function setupServerControls() {
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
btnStart.addEventListener('click', async () => {
btnStart.disabled = true;
btnStart.textContent = 'Démarrage...';
try {
const result = await window.electronAPI.server.start();
console.log('Résultat démarrage:', result);
if (result.success) {
showNotification('Serveur démarré avec succès', 'success');
} else {
showNotification('Erreur démarrage: ' + result.message, 'error');
}
} catch (error) {
console.error('Erreur démarrage serveur:', error);
showNotification('Erreur démarrage serveur', 'error');
}
btnStart.disabled = false;
btnStart.textContent = 'Démarrer';
await checkServerStatus();
});
btnStop.addEventListener('click', async () => {
btnStop.disabled = true;
btnStop.textContent = 'Arrêt...';
try {
const result = await window.electronAPI.server.stop();
console.log('Résultat arrêt:', result);
if (result.success) {
showNotification('Serveur arrêté', 'info');
}
} catch (error) {
console.error('Erreur arrêt serveur:', error);
showNotification('Erreur arrêt serveur', 'error');
}
btnStop.disabled = false;
btnStop.textContent = 'Arrêter';
await checkServerStatus();
});
// Listener status depuis Main Process
window.electronAPI.server.onStatus((data) => {
console.log('Status update:', data);
updateServerStatus(data.running);
});
}
function setupLogsListener() {
window.electronAPI.server.onLog((logData) => {
addLogEntry(logData);
});
// Bouton clear logs
document.getElementById('btn-clear-logs').addEventListener('click', () => {
logsBuffer = [];
renderLogs();
});
// Filtre niveau de log
document.getElementById('log-level-filter').addEventListener('change', (e) => {
renderLogs(e.target.value);
});
}
async function checkServerStatus() {
try {
const status = await window.electronAPI.server.status();
console.log('Status:', status);
updateServerStatus(status.running);
if (status.running) {
startStatsPolling();
} else {
stopStatsPolling();
}
} catch (error) {
console.error('Erreur check status:', error);
updateServerStatus(false);
}
}
function updateServerStatus(running) {
serverRunning = running;
const indicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
if (running) {
indicator.textContent = '🟢';
statusText.textContent = 'Actif';
btnStart.disabled = true;
btnStop.disabled = false;
} else {
indicator.textContent = '⚪';
statusText.textContent = 'Arrêté';
btnStart.disabled = false;
btnStop.disabled = true;
}
}
// ========== Polling Stats ==========
function startStatsPolling() {
if (statsInterval) return;
// Poll toutes les 2 secondes
statsInterval = setInterval(async () => {
if (serverRunning) {
await fetchStats();
await fetchUsers();
}
}, 2000);
// Premier fetch immédiat
fetchStats();
fetchUsers();
}
function stopStatsPolling() {
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
// ========== API Calls ==========
async function apiCall(endpoint) {
try {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API Error (${endpoint}):`, error);
return null;
}
}
async function fetchStats() {
const data = await apiCall('/admin/stats');
if (!data) return;
// Mettre à jour les stats cards
document.getElementById('stat-uptime').textContent = formatUptime(data.uptime);
document.getElementById('stat-users').textContent = data.activeConnections || 0;
document.getElementById('stat-total-connections').textContent = data.totalConnections || 0;
// Groupes actifs (nécessite /admin/groups)
const groups = await apiCall('/admin/groups');
if (groups) {
document.getElementById('stat-groups').textContent = groups.groups?.length || 0;
}
}
async function fetchUsers() {
const data = await apiCall('/admin/users');
if (!data) return;
const container = document.getElementById('users-list');
if (!data.users || data.users.length === 0) {
container.innerHTML = '<p class="empty-state">Aucun utilisateur connecté</p>';
return;
}
container.innerHTML = data.users.map(user => `
<div class="user-item">
<div class="user-info">
<span class="user-status">👤</span>
<div class="user-details">
<h4>${user.username}</h4>
<p>Groupe: ${user.groupId} • Connecté: ${formatTime(user.connectedAt)}</p>
</div>
</div>
<div class="user-badge">${user.groupId}</div>
</div>
`).join('');
}
async function fetchDevices() {
const data = await apiCall('/admin/devices/list');
if (!data) return;
const inputSelect = document.getElementById('input-device');
const outputSelect = document.getElementById('output-device');
// Remplir les selects
inputSelect.innerHTML = data.inputs.map(device =>
`<option value="${device.id}" ${device.isDefault ? 'selected' : ''}>
${device.name} ${device.channels ? `(${device.channels}ch)` : ''}
</option>`
).join('');
outputSelect.innerHTML = data.outputs.map(device =>
`<option value="${device.id}" ${device.isDefault ? 'selected' : ''}>
${device.name} ${device.channels ? `(${device.channels}ch)` : ''}
</option>`
).join('');
}
async function fetchGroups() {
const data = await apiCall('/admin/groups');
if (!data) return;
const container = document.getElementById('groups-list');
if (!data.groups || data.groups.length === 0) {
container.innerHTML = '<p class="empty-state">Aucun groupe configuré</p>';
return;
}
container.innerHTML = data.groups.map(group => `
<div class="group-item">
<div class="group-info">
<h4>${group.name}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}</p>
</div>
<div class="group-actions">
<button class="btn btn-small btn-secondary">Modifier</button>
<button class="btn btn-small btn-secondary">Supprimer</button>
</div>
</div>
`).join('');
}
async function fetchConfig() {
const data = await apiCall('/admin/config');
if (!data) return;
// Remplir les champs de config audio
if (data.audio) {
const sampleRateSelect = document.getElementById('sample-rate');
if (sampleRateSelect) {
sampleRateSelect.value = data.audio.sampleRate || 48000;
}
const bitrateInput = document.getElementById('default-bitrate');
if (bitrateInput) {
bitrateInput.value = data.audio.defaultBitrate || 96;
}
const jitterInput = document.getElementById('jitter-buffer');
if (jitterInput) {
jitterInput.value = data.audio.jitterBufferMs || 40;
}
}
}
// ========== QR Code ==========
async function generateQRCode() {
// Récupérer l'IP réseau depuis le serveur
const status = await window.electronAPI.server.status();
if (!status || !status.running) {
document.getElementById('client-url').textContent = 'Serveur non démarré';
return;
}
// Récupérer l'URL depuis l'API
try {
const response = await fetch(`${API_BASE}/health`);
const data = await response.json();
// Détecter l'IP réseau (depuis hostname ou config)
const networkIP = await getNetworkIP();
const clientUrl = `https://${networkIP}:5173`; // Mode dev Vite
document.getElementById('client-url').textContent = clientUrl;
// Générer QR Code
const canvas = document.getElementById('qr-code');
if (canvas && window.QRCode) {
QRCode.toCanvas(canvas, clientUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
}, (error) => {
if (error) {
console.error('Erreur génération QR Code:', error);
} else {
console.log('✅ QR Code généré');
}
});
}
} catch (error) {
console.error('Erreur récupération URL:', error);
document.getElementById('client-url').textContent = 'https://localhost:5173';
}
// Bouton copier URL (setup une seule fois)
const btnCopy = document.getElementById('btn-copy-url');
if (btnCopy && !btnCopy.dataset.initialized) {
btnCopy.dataset.initialized = 'true';
btnCopy.addEventListener('click', () => {
const url = document.getElementById('client-url').textContent;
navigator.clipboard.writeText(url);
showNotification('URL copiée !', 'success');
});
}
}
async function getNetworkIP() {
// Méthode 1 : depuis l'API serveur (qui détecte déjà l'IP)
try {
const config = await apiCall('/admin/config');
if (config && config.server && config.server.livekit && config.server.livekit.url) {
const url = config.server.livekit.url;
// Extraire l'IP depuis ws://IP:7880
const match = url.match(/ws:\/\/([^:]+):/);
if (match) return match[1];
}
} catch (error) {
console.error('Erreur détection IP:', error);
}
// Fallback : localhost
return 'localhost';
}
// ========== Logs ==========
function addLogEntry(logData) {
const entry = {
timestamp: new Date().toISOString(),
level: logData.level || 'info',
message: logData.message
};
logsBuffer.unshift(entry);
// Garder max 500 logs
if (logsBuffer.length > 500) {
logsBuffer = logsBuffer.slice(0, 500);
}
renderLogs();
}
function renderLogs(levelFilter = '') {
const container = document.getElementById('logs-container');
let logs = logsBuffer;
// Filtrer par niveau si nécessaire
if (levelFilter) {
logs = logs.filter(log => log.level === levelFilter);
}
if (logs.length === 0) {
container.innerHTML = '<p class="empty-state">Aucun log</p>';
return;
}
container.innerHTML = logs.map(log => `
<div class="log-entry">
<span class="log-time">${formatLogTime(log.timestamp)}</span>
<span class="log-level ${log.level}">${log.level}</span>
<span class="log-message">${escapeHtml(log.message)}</span>
</div>
`).join('');
// Scroll vers le bas (dernier log)
container.scrollTop = 0;
}
// ========== Chargement données ==========
async function loadInitialData() {
if (!serverRunning) return;
await fetchStats();
await fetchUsers();
await generateQRCode();
}
async function loadViewData(view) {
if (!serverRunning) return;
switch (view) {
case 'dashboard':
await fetchStats();
await fetchUsers();
await generateQRCode();
break;
case 'config':
await fetchDevices();
await fetchConfig();
break;
case 'groups':
await fetchGroups();
break;
case 'monitoring':
// TODO: charger VU-mètres WebSocket
break;
case 'logs':
renderLogs();
break;
}
}
// ========== Boutons de sauvegarde ==========
document.addEventListener('DOMContentLoaded', () => {
// Sauvegarder device audio
const btnSaveDevice = document.getElementById('btn-save-device');
if (btnSaveDevice) {
btnSaveDevice.addEventListener('click', async () => {
const inputDeviceId = document.getElementById('input-device').value;
const outputDeviceId = document.getElementById('output-device').value;
try {
const response = await fetch(`${API_BASE}/admin/audio/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inputDeviceId, outputDeviceId })
});
if (response.ok) {
showNotification('Périphérique audio configuré', 'success');
} else {
showNotification('Erreur configuration', 'error');
}
} catch (error) {
console.error('Erreur save device:', error);
showNotification('Erreur réseau', 'error');
}
});
}
// Sauvegarder config audio
const btnSaveAudio = document.getElementById('btn-save-audio');
if (btnSaveAudio) {
btnSaveAudio.addEventListener('click', async () => {
const sampleRate = parseInt(document.getElementById('sample-rate').value);
const defaultBitrate = parseInt(document.getElementById('default-bitrate').value);
const jitterBufferMs = parseInt(document.getElementById('jitter-buffer').value);
try {
const response = await fetch(`${API_BASE}/admin/config/audio`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sampleRate, defaultBitrate, jitterBufferMs })
});
if (response.ok) {
showNotification('Configuration sauvegardée', 'success');
} else {
showNotification('Erreur sauvegarde', 'error');
}
} catch (error) {
console.error('Erreur save config:', error);
showNotification('Erreur réseau', 'error');
}
});
}
// Ajouter groupe
const btnAddGroup = document.getElementById('btn-add-group');
if (btnAddGroup) {
btnAddGroup.addEventListener('click', async () => {
const name = prompt('Nom du groupe:');
if (!name) return;
try {
const response = await fetch(`${API_BASE}/admin/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, audioBitrate: 96 })
});
if (response.ok) {
showNotification('Groupe créé', 'success');
await fetchGroups();
} else {
showNotification('Erreur création groupe', 'error');
}
} catch (error) {
console.error('Erreur add group:', error);
showNotification('Erreur réseau', 'error');
}
});
}
});
// ========== Helpers ==========
function formatUptime(seconds) {
if (!seconds) return '--';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}h ${m}m ${s}s`;
}
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('fr-FR');
}
function formatLogTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showNotification(message, type = 'info') {
console.log(`[${type.toUpperCase()}] ${message}`);
const container = document.getElementById('toast-container');
if (!container) return;
// Icônes par type
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: '️'
};
// Créer le toast
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close">×</button>
`;
// Ajouter au container
container.appendChild(toast);
// Bouton fermer
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => {
toast.remove();
});
// Auto-remove après 5 secondes
setTimeout(() => {
if (toast.parentElement) {
toast.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => toast.remove(), 300);
}
}, 5000);
}