Compare commits

3 Commits

Author SHA1 Message Date
benoit 51245db256 feat: gestion groupes sans serveur via IPC + lecture YAML directe
- Ajout du package yaml dans electron pour parser config.yaml
- Handlers IPC groups:list/create/update/delete lisent/écrivent config.yaml
  directement depuis le Main Process (sans serveur requis)
- fetchGroups() utilise toujours IPC (plus d'appel REST pour la lecture)
- editGroup/deleteGroup/addGroup : REST API si serveur actif, IPC sinon
- loadViewData : onglet Groupes chargé même si serveur arrêté
- Note affichée quand le serveur est arrêté : modifications appliquées
  au prochain démarrage
2026-07-01 13:36:12 +02:00
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
8 changed files with 532 additions and 25 deletions
+2 -2
View File
@@ -336,9 +336,9 @@ PORT=3001 npm start
### Priorité moyenne
- [ ] **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
- [ ] **Notifications desktop** : via Electron Notification API
- [x] **Export & import config** : bouton télécharger YAML et charger config (backup auto .bak)
### Priorité basse
- [ ] **Thème toggle** : dark/light mode
+117
View File
@@ -5,12 +5,29 @@
const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const http = require('http');
const https = require('https');
const QRCode = require('qrcode');
const yaml = require('yaml');
const setupHelper = require('./setup-helper');
const CONFIG_PATH = path.join(__dirname, '..', 'server', 'config', 'config.yaml');
function readConfig() {
return yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function writeConfig(config) {
fs.writeFileSync(CONFIG_PATH, yaml.stringify(config), 'utf8');
}
function slugify(text) {
return text.toString().normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w-]+/g, '').replace(/--+/g, '-');
}
// État de l'application
let mainWindow = null;
let tray = null;
@@ -365,6 +382,106 @@ app.whenReady().then(async () => {
return setupHelper.getNetworkIP();
});
// ========== Groupes (lecture/écriture YAML directe, sans serveur) ==========
ipcMain.handle('groups:list', () => {
try {
const config = readConfig();
return { groups: config.groups || [] };
} catch (error) {
return { groups: [], error: error.message };
}
});
ipcMain.handle('groups:create', (event, { name, audioBitrate }) => {
try {
const config = readConfig();
const id = slugify(name);
if ((config.groups || []).find(g => slugify(g.name) === id)) {
return { success: false, error: `Un groupe "${name}" existe déjà` };
}
const group = { name, ...(audioBitrate ? { audioBitrate } : {}) };
config.groups = [...(config.groups || []), group];
writeConfig(config);
return { success: true, group };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('groups:update', (event, { id, name, audioBitrate }) => {
try {
const config = readConfig();
const idx = (config.groups || []).findIndex(g => slugify(g.name) === id);
if (idx === -1) return { success: false, error: `Groupe ${id} introuvable` };
if (name !== undefined) config.groups[idx].name = name;
if (audioBitrate !== undefined) config.groups[idx].audioBitrate = audioBitrate;
writeConfig(config);
return { success: true, group: config.groups[idx] };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('groups:delete', (event, { id }) => {
try {
const config = readConfig();
const idx = (config.groups || []).findIndex(g => slugify(g.name) === id);
if (idx === -1) return { success: false, error: `Groupe ${id} introuvable` };
config.groups.splice(idx, 1);
writeConfig(config);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
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
createWindow();
createTray();
+2 -1
View File
@@ -55,6 +55,7 @@
},
"dependencies": {
"electron-store": "^8.1.0",
"qrcode": "^1.5.4"
"qrcode": "^1.5.4",
"yaml": "^2.9.0"
}
}
+14
View File
@@ -37,6 +37,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
// IP réseau locale (même détection que pour les certificats mkcert)
getNetworkIP: () => ipcRenderer.invoke('network:ip'),
// Export/import configuration YAML via dialog système
config: {
export: () => ipcRenderer.invoke('config:export'),
import: () => ipcRenderer.invoke('config:import')
},
// Groupes : lecture/écriture YAML directe (fonctionne sans serveur)
groups: {
list: () => ipcRenderer.invoke('groups:list'),
create: (data) => ipcRenderer.invoke('groups:create', data),
update: (data) => ipcRenderer.invoke('groups:update', data),
delete: (data) => ipcRenderer.invoke('groups:delete', data)
},
// Helpers
platform: process.platform,
version: process.env.npm_package_version || '0.3.0'
+299 -21
View File
@@ -302,28 +302,125 @@ async function fetchDevices() {
}
async function fetchGroups() {
const data = await apiCall('/admin/groups');
if (!data) return;
const container = document.getElementById('groups-list');
// Lecture directe depuis config.yaml via IPC (fonctionne sans serveur)
const data = await window.electronAPI.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 => `
const serverNote = serverRunning ? '' : '<p class="config-note" style="margin-bottom:1rem">Serveur arrêté — les modifications seront appliquées au prochain démarrage.</p>';
container.innerHTML = serverNote + data.groups.map(group => {
const id = slugify(group.name);
return `
<div class="group-item">
<div class="group-info">
<h4>${group.name}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}</p>
<h4>${escapeHtml(group.name)}</h4>
<p>Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(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>
<button class="btn btn-small btn-secondary"
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>
`).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 {
let ok, errorMsg;
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, audioBitrate: newBitrate })
});
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.update({ id, name: newName, audioBitrate: newBitrate });
ok = res.success;
errorMsg = res.error;
}
if (ok) {
showNotification('Groupe modifié', 'success');
await fetchGroups();
} else {
showNotification('Erreur: ' + (errorMsg || '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 {
let ok, errorMsg;
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups/${id}`, { method: 'DELETE' });
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.delete({ id });
ok = res.success;
errorMsg = res.error;
}
if (ok) {
showNotification('Groupe supprimé', 'success');
await fetchGroups();
} else {
showNotification('Erreur: ' + (errorMsg || 'Suppression échouée'), 'error');
}
} catch (error) {
console.error('Erreur delete group:', error);
showNotification('Erreur réseau', 'error');
}
}
async function fetchConfig() {
@@ -475,6 +572,12 @@ async function loadInitialData() {
}
async function loadViewData(view) {
// Les groupes sont lisibles même sans serveur (config.yaml direct)
if (view === 'groups') {
await fetchGroups();
return;
}
if (!serverRunning) return;
switch (view) {
@@ -487,9 +590,6 @@ async function loadViewData(view) {
await fetchDevices();
await fetchConfig();
break;
case 'groups':
await fetchGroups();
break;
case 'monitoring':
renderVUMeters();
break;
@@ -559,21 +659,42 @@ document.addEventListener('DOMContentLoaded', () => {
const btnAddGroup = document.getElementById('btn-add-group');
if (btnAddGroup) {
btnAddGroup.addEventListener('click', async () => {
const name = prompt('Nom du groupe:');
if (!name) return;
const result = await showModal({
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 {
const response = await fetch(`${API_BASE}/admin/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, audioBitrate: 96 })
});
let ok, errorMsg;
if (response.ok) {
if (serverRunning) {
const response = await fetch(`${API_BASE}/admin/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, audioBitrate })
});
ok = response.ok;
if (!ok) errorMsg = (await response.json().catch(() => ({}))).error;
} else {
const res = await window.electronAPI.groups.create({ name, audioBitrate });
ok = res.success;
errorMsg = res.error;
}
if (ok) {
showNotification('Groupe créé', 'success');
await fetchGroups();
} else {
showNotification('Erreur création groupe', 'error');
showNotification('Erreur: ' + (errorMsg || 'Création échouée'), 'error');
}
} catch (error) {
console.error('Erreur add group:', error);
@@ -581,10 +702,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 ==========
/**
* 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) {
if (!seconds) return '--';
+24
View File
@@ -123,6 +123,15 @@
<button class="btn btn-primary" id="btn-save-device">Appliquer</button>
</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">
<h3>🎚️ Paramètres Audio</h3>
<div class="form-group">
@@ -170,6 +179,7 @@
<h2>Logs Serveur</h2>
<div class="logs-controls">
<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">
<option value="">Tous les niveaux</option>
<option value="error">Erreurs</option>
@@ -186,6 +196,20 @@
</main>
</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 -->
<script src="app.js"></script>
</body>
+73
View File
@@ -121,6 +121,15 @@ body {
font-size: 0.8125rem;
}
.btn-danger {
background: var(--accent-error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
/* Main Content */
.main-content {
display: flex;
@@ -370,6 +379,70 @@ body {
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 {
display: flex;
+1 -1
View File
@@ -2,7 +2,7 @@ audio:
sampleRate: 48000
channels: 2
frameSize: 20
defaultBitrate: 96
defaultBitrate: 128
jitterBufferMs: 40
device:
inputDeviceId: Loopback Audio 4