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)
This commit is contained in:
2026-07-01 13:21:59 +02:00
parent a7a488403f
commit 955bfdfe07
7 changed files with 238 additions and 9 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
+47
View File
@@ -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();
+6
View File
@@ -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'
+151 -6
View File
@@ -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 `
<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 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 '--';
+10
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>
+21
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,18 @@ body {
border-color: var(--accent-primary);
}
/* 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