Compare commits

..

2 Commits

Author SHA1 Message Date
benoit b3fbe31a2d fix: remplacer prompt/confirm par modals HTML (non supportés dans Electron)
Electron bloque prompt() et confirm() dans le renderer process.
Ajout d'un showModal() générique (champs configurables, mode confirmation)
avec overlay, focus auto, Escape/Enter, et fermeture en cliquant l'overlay.
Tous les appels prompt/confirm dans editGroup, deleteGroup et addGroup
sont migrés vers showModal.
2026-07-01 13:28:08 +02:00
benoit 955bfdfe07 feat: activer boutons Groupes et ajouter export logs/config desktop
- Onglet Groupes : boutons Modifier et Supprimer fonctionnels via délégation
  d'événements et data-attributes ; slugify() côté client synchronisé avec
  le serveur ; classe CSS btn-danger pour Supprimer
- Export logs : bouton "Exporter JSON" dans l'onglet Logs (filtre niveau actif)
- Export/Import config.yaml : section dédiée dans Configuration avec dialog
  système (backup automatique .bak avant import) via IPC Electron
  (config:export / config:import dans main.js + preload.js)
2026-07-01 13:21:59 +02:00
7 changed files with 411 additions and 13 deletions
+2 -2
View File
@@ -336,9 +336,9 @@ PORT=3001 npm start
### Priorité moyenne ### Priorité moyenne
- [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante - [ ] **Graphiques monitoring** : Chart.js pour latence/bande passante
- [ ] **Export logs** : bouton télécharger CSV/JSON - [x] **Export logs** : bouton télécharger JSON (filtre niveau appliqué)
- [ ] **Matrice routing** : interface graphique drag & drop - [ ] **Matrice routing** : interface graphique drag & drop
- [ ] **Notifications desktop** : via Electron Notification API - [x] **Export & import config** : bouton télécharger YAML et charger config (backup auto .bak)
### Priorité basse ### Priorité basse
- [ ] **Thème toggle** : dark/light mode - [ ] **Thème toggle** : dark/light mode
+47
View File
@@ -5,6 +5,7 @@
const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron'); const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
@@ -365,6 +366,52 @@ app.whenReady().then(async () => {
return setupHelper.getNetworkIP(); return setupHelper.getNetworkIP();
}); });
ipcMain.handle('config:export', async () => {
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
try {
const content = fs.readFileSync(configPath, 'utf8');
const { filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Exporter la configuration',
defaultPath: 'config.yaml',
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }]
});
if (!filePath) return { success: false, cancelled: true };
fs.writeFileSync(filePath, content, 'utf8');
return { success: true, filePath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('config:import', async () => {
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Importer une configuration',
filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }],
properties: ['openFile']
});
if (!filePaths || filePaths.length === 0) return { success: false, cancelled: true };
try {
const content = fs.readFileSync(filePaths[0], 'utf8');
const configPath = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
// Backup de l'ancienne config avant remplacement
if (fs.existsSync(configPath)) {
fs.copyFileSync(configPath, configPath + '.bak');
}
fs.writeFileSync(configPath, content, 'utf8');
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Créer fenêtre // Créer fenêtre
createWindow(); createWindow();
createTray(); createTray();
+6
View File
@@ -37,6 +37,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
// IP réseau locale (même détection que pour les certificats mkcert) // IP réseau locale (même détection que pour les certificats mkcert)
getNetworkIP: () => ipcRenderer.invoke('network:ip'), getNetworkIP: () => ipcRenderer.invoke('network:ip'),
// Export/import configuration YAML via dialog système
config: {
export: () => ipcRenderer.invoke('config:export'),
import: () => ipcRenderer.invoke('config:import')
},
// Helpers // Helpers
platform: process.platform, platform: process.platform,
version: process.env.npm_package_version || '0.3.0' version: process.env.npm_package_version || '0.3.0'
+258 -10
View File
@@ -312,18 +312,97 @@ async function fetchGroups() {
return; return;
} }
container.innerHTML = data.groups.map(group => ` container.innerHTML = data.groups.map(group => {
const id = slugify(group.name);
return `
<div class="group-item"> <div class="group-item">
<div class="group-info"> <div class="group-info">
<h4>${group.name}</h4> <h4>${escapeHtml(group.name)}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}</p> <p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(id)}</p>
</div> </div>
<div class="group-actions"> <div class="group-actions">
<button class="btn btn-small btn-secondary">Modifier</button> <button class="btn btn-small btn-secondary"
<button class="btn btn-small btn-secondary">Supprimer</button> data-action="edit"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}"
data-bitrate="${group.audioBitrate || 96}">Modifier</button>
<button class="btn btn-small btn-danger"
data-action="delete"
data-id="${escapeHtml(id)}"
data-name="${escapeHtml(group.name)}">Supprimer</button>
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
}
async function editGroup(id, currentName, currentBitrate) {
const result = await showModal({
title: 'Modifier le groupe',
fields: [
{ name: 'name', label: 'Nom', default: currentName },
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: currentBitrate, min: 32, max: 320, step: 1 }
],
confirmLabel: 'Modifier'
});
if (!result) return;
const newName = result.name.trim();
const newBitrate = parseInt(result.bitrate);
if (!newName) { showNotification('Nom requis', 'error'); return; }
if (isNaN(newBitrate) || newBitrate < 32 || newBitrate > 320) {
showNotification('Bitrate invalide (32-320 kbps)', 'error');
return;
}
try {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, audioBitrate: newBitrate })
});
if (response.ok) {
showNotification('Groupe modifié', 'success');
await fetchGroups();
} else {
const err = await response.json().catch(() => ({}));
showNotification('Erreur: ' + (err.error || 'Modification échouée'), 'error');
}
} catch (error) {
console.error('Erreur edit group:', error);
showNotification('Erreur réseau', 'error');
}
}
async function deleteGroup(id, name) {
const confirmed = await showModal({
title: 'Supprimer le groupe',
message: `Supprimer le groupe "${name}" ? Cette action est irréversible.`,
confirmLabel: 'Supprimer',
confirmClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
method: 'DELETE'
});
if (response.ok) {
showNotification('Groupe supprimé', 'success');
await fetchGroups();
} else {
const err = await response.json().catch(() => ({}));
showNotification('Erreur: ' + (err.error || 'Suppression échouée'), 'error');
}
} catch (error) {
console.error('Erreur delete group:', error);
showNotification('Erreur réseau', 'error');
}
} }
async function fetchConfig() { async function fetchConfig() {
@@ -559,21 +638,33 @@ document.addEventListener('DOMContentLoaded', () => {
const btnAddGroup = document.getElementById('btn-add-group'); const btnAddGroup = document.getElementById('btn-add-group');
if (btnAddGroup) { if (btnAddGroup) {
btnAddGroup.addEventListener('click', async () => { btnAddGroup.addEventListener('click', async () => {
const name = prompt('Nom du groupe:'); const result = await showModal({
if (!name) return; title: 'Nouveau groupe',
fields: [
{ name: 'name', label: 'Nom du groupe' },
{ name: 'bitrate', label: 'Bitrate (kbps)', type: 'number', default: 96, min: 32, max: 320, step: 1 }
],
confirmLabel: 'Créer'
});
if (!result || !result.name.trim()) return;
const name = result.name.trim();
const audioBitrate = parseInt(result.bitrate) || 96;
try { try {
const response = await fetch(`${API_BASE}/admin/groups`, { const response = await fetch(`${API_BASE}/admin/groups`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, audioBitrate: 96 }) body: JSON.stringify({ name, audioBitrate })
}); });
if (response.ok) { if (response.ok) {
showNotification('Groupe créé', 'success'); showNotification('Groupe créé', 'success');
await fetchGroups(); await fetchGroups();
} else { } else {
showNotification('Erreur création groupe', 'error'); const err = await response.json().catch(() => ({}));
showNotification('Erreur: ' + (err.error || 'Création échouée'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Erreur add group:', error); console.error('Erreur add group:', error);
@@ -581,10 +672,167 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
// Exporter config.yaml
const btnExportConfig = document.getElementById('btn-export-config');
if (btnExportConfig) {
btnExportConfig.addEventListener('click', async () => {
const result = await window.electronAPI.config.export();
if (result.success) {
showNotification('Configuration exportée', 'success');
} else if (!result.cancelled) {
showNotification('Erreur export: ' + (result.error || 'Échec'), 'error');
}
});
}
// Importer config.yaml
const btnImportConfig = document.getElementById('btn-import-config');
if (btnImportConfig) {
btnImportConfig.addEventListener('click', async () => {
const result = await window.electronAPI.config.import();
if (result.success) {
showNotification('Configuration importée - Redémarrez le serveur pour appliquer', 'warning');
if (serverRunning) {
await fetchConfig();
}
} else if (!result.cancelled) {
showNotification('Erreur import: ' + (result.error || 'Échec'), 'error');
}
});
}
// Exporter les logs
const btnExportLogs = document.getElementById('btn-export-logs');
if (btnExportLogs) {
btnExportLogs.addEventListener('click', () => {
const levelFilter = document.getElementById('log-level-filter').value;
const logs = levelFilter ? logsBuffer.filter(l => l.level === levelFilter) : logsBuffer;
if (logs.length === 0) {
showNotification('Aucun log à exporter', 'info');
return;
}
const content = JSON.stringify(logs, null, 2);
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ptt-live-logs-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`${logs.length} logs exportés`, 'success');
});
}
// Délégation d'événements pour modifier/supprimer un groupe
const groupsList = document.getElementById('groups-list');
if (groupsList) {
groupsList.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
const name = btn.dataset.name;
if (action === 'edit') {
await editGroup(id, name, parseInt(btn.dataset.bitrate));
} else if (action === 'delete') {
await deleteGroup(id, name);
}
});
}
}); });
// ========== Helpers ========== // ========== Helpers ==========
/**
* Modal générique (remplace prompt/confirm, non supportés dans Electron).
* - fields[] → formulaire ; message → confirmation simple
* Retourne : objet {champ: valeur} | true (confirm) | null (annulé)
*/
function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClass = 'btn-primary', message = null }) {
return new Promise((resolve) => {
const overlay = document.getElementById('modal-overlay');
const titleEl = document.getElementById('modal-title');
const bodyEl = document.getElementById('modal-body');
const cancelBtn = document.getElementById('modal-cancel');
const confirmBtn = document.getElementById('modal-confirm');
titleEl.textContent = title;
confirmBtn.textContent = confirmLabel;
confirmBtn.className = `btn ${confirmClass}`;
if (message) {
bodyEl.innerHTML = `<p class="modal-message">${escapeHtml(message)}</p>`;
} else {
bodyEl.innerHTML = fields.map(field => `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
<input
type="${field.type || 'text'}"
id="modal-field-${field.name}"
class="form-control"
value="${escapeHtml(String(field.default ?? ''))}"
${field.min !== undefined ? `min="${field.min}"` : ''}
${field.max !== undefined ? `max="${field.max}"` : ''}
${field.step !== undefined ? `step="${field.step}"` : ''}>
</div>
`).join('');
}
overlay.classList.remove('hidden');
const firstInput = bodyEl.querySelector('input');
if (firstInput) { firstInput.focus(); firstInput.select(); }
function cleanup() {
overlay.classList.add('hidden');
cancelBtn.removeEventListener('click', onCancel);
confirmBtn.removeEventListener('click', onConfirm);
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
}
function onCancel() { cleanup(); resolve(null); }
function onConfirm() {
if (message) {
cleanup(); resolve(true);
} else {
const result = {};
fields.forEach(f => {
const input = document.getElementById(`modal-field-${f.name}`);
result[f.name] = input ? input.value : '';
});
cleanup(); resolve(result);
}
}
function onOverlayClick(e) { if (e.target === overlay) onCancel(); }
function onKeydown(e) {
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter' && document.activeElement?.tagName !== 'BUTTON') onConfirm();
}
cancelBtn.addEventListener('click', onCancel);
confirmBtn.addEventListener('click', onConfirm);
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
});
}
function slugify(text) {
return text.toString().normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-');
}
function formatUptime(seconds) { function formatUptime(seconds) {
if (!seconds) return '--'; if (!seconds) return '--';
+24
View File
@@ -123,6 +123,15 @@
<button class="btn btn-primary" id="btn-save-device">Appliquer</button> <button class="btn btn-primary" id="btn-save-device">Appliquer</button>
</div> </div>
<div class="section">
<h3>💾 Sauvegarde de configuration</h3>
<div class="config-actions">
<button class="btn btn-secondary" id="btn-export-config">Exporter config.yaml</button>
<button class="btn btn-secondary" id="btn-import-config">Importer config.yaml</button>
</div>
<p class="config-note">L'import remplace config.yaml (backup automatique en .bak). Redémarrez le serveur pour appliquer.</p>
</div>
<div class="section"> <div class="section">
<h3>🎚️ Paramètres Audio</h3> <h3>🎚️ Paramètres Audio</h3>
<div class="form-group"> <div class="form-group">
@@ -170,6 +179,7 @@
<h2>Logs Serveur</h2> <h2>Logs Serveur</h2>
<div class="logs-controls"> <div class="logs-controls">
<button class="btn btn-small" id="btn-clear-logs">Effacer</button> <button class="btn btn-small" id="btn-clear-logs">Effacer</button>
<button class="btn btn-small btn-secondary" id="btn-export-logs">Exporter JSON</button>
<select id="log-level-filter" class="form-control form-control-small"> <select id="log-level-filter" class="form-control form-control-small">
<option value="">Tous les niveaux</option> <option value="">Tous les niveaux</option>
<option value="error">Erreurs</option> <option value="error">Erreurs</option>
@@ -186,6 +196,20 @@
</main> </main>
</div> </div>
<!-- Modal générique -->
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title"></h3>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-cancel">Annuler</button>
<button class="btn btn-primary" id="modal-confirm">Confirmer</button>
</div>
</div>
</div>
<!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN --> <!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+73
View File
@@ -121,6 +121,15 @@ body {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.btn-danger {
background: var(--accent-error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
/* Main Content */ /* Main Content */
.main-content { .main-content {
display: flex; display: flex;
@@ -370,6 +379,70 @@ body {
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 1.125rem;
margin: 0;
}
.modal-body {
padding: 1.5rem;
}
.modal-message {
color: var(--text-primary);
line-height: 1.5;
margin: 0;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Config actions */
.config-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.config-note {
font-size: 0.8125rem;
color: var(--text-secondary);
}
/* Groups List */ /* Groups List */
.groups-list { .groups-list {
display: flex; display: flex;
+1 -1
View File
@@ -2,7 +2,7 @@ audio:
sampleRate: 48000 sampleRate: 48000
channels: 2 channels: 2
frameSize: 20 frameSize: 20
defaultBitrate: 96 defaultBitrate: 128
jitterBufferMs: 40 jitterBufferMs: 40
device: device:
inputDeviceId: Loopback Audio 4 inputDeviceId: Loopback Audio 4