diff --git a/DESKTOP-APP.md b/DESKTOP-APP.md
index 9c7cf7c..ba38f9a 100644
--- a/DESKTOP-APP.md
+++ b/DESKTOP-APP.md
@@ -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
diff --git a/electron/main.js b/electron/main.js
index 918a261..edb5131 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -5,6 +5,7 @@
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');
@@ -365,6 +366,52 @@ app.whenReady().then(async () => {
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
createWindow();
createTray();
diff --git a/electron/preload.js b/electron/preload.js
index da83344..2dd75d4 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -37,6 +37,12 @@ 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')
+ },
+
// Helpers
platform: process.platform,
version: process.env.npm_package_version || '0.3.0'
diff --git a/electron/ui/app.js b/electron/ui/app.js
index c7048ff..07cd6d4 100644
--- a/electron/ui/app.js
+++ b/electron/ui/app.js
@@ -312,18 +312,82 @@ async function fetchGroups() {
return;
}
- container.innerHTML = data.groups.map(group => `
+ container.innerHTML = data.groups.map(group => {
+ const id = slugify(group.name);
+ return `
-
${group.name}
-
Bitrate: ${group.audioBitrate || 96} kbps • ID: ${group.id}
+
${escapeHtml(group.name)}
+
Bitrate: ${group.audioBitrate || 96} kbps • ID: ${escapeHtml(id)}
-
-
+
+
- `).join('');
+ `;
+ }).join('');
+}
+
+async function editGroup(id, currentName, currentBitrate) {
+ const newName = prompt('Nom du groupe:', currentName);
+ if (newName === null || newName.trim() === '') return;
+
+ const newBitrateStr = prompt('Bitrate (kbps, 32-320):', String(currentBitrate));
+ if (newBitrateStr === null) return;
+
+ const newBitrate = parseInt(newBitrateStr);
+ 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.trim(), 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) {
+ if (!confirm(`Supprimer le groupe "${name}" ?`)) 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() {
@@ -581,10 +645,91 @@ 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 ==========
+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 '--';
diff --git a/electron/ui/index.html b/electron/ui/index.html
index ee85cea..2e4335d 100644
--- a/electron/ui/index.html
+++ b/electron/ui/index.html
@@ -123,6 +123,15 @@
+
+
💾 Sauvegarde de configuration
+
+
+
+
+
L'import remplace config.yaml (backup automatique en .bak). Redémarrez le serveur pour appliquer.
+
+