Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51245db256 | |||
| b3fbe31a2d | |||
| 955bfdfe07 |
+2
-2
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.1.0",
|
||||
"qrcode": "^1.5.4"
|
||||
"qrcode": "^1.5.4",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 '--';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@ audio:
|
||||
sampleRate: 48000
|
||||
channels: 2
|
||||
frameSize: 20
|
||||
defaultBitrate: 96
|
||||
defaultBitrate: 128
|
||||
jitterBufferMs: 40
|
||||
device:
|
||||
inputDeviceId: Loopback Audio 4
|
||||
|
||||
Reference in New Issue
Block a user