Compare commits

...

7 Commits

Author SHA1 Message Date
benoit 55d777319b Update TODO.md avec ajout config audio visuelle 2026-05-24 22:42:30 +02:00
benoit c3b6af7d30 fix: saveConfig ne sauvegarde plus les IDs dans le YAML
Problème : les IDs étaient sauvegardés dans config.yaml alors qu'ils doivent être générés dynamiquement.

Solution :
- saveConfig() nettoie maintenant les IDs avant sauvegarde
- Suppression récursive des 'id' pour groupes et canaux
- config.yaml restauré sans IDs (format propre)
- Les IDs sont uniquement en mémoire après loadConfig()

Garantit :
- config.yaml reste lisible et maintenable
- Pas de conflit d'IDs
- Source de vérité = nom du groupe/canal
- IDs cohérents et prévisibles (slugify)
2026-05-24 22:29:59 +02:00
benoit 6e9dd738d7 fix: correction édition/suppression groupes avec IDs générés dynamiquement
Problème : l'édition et la suppression de groupes ne fonctionnaient pas car les IDs n'étaient pas persistés dans le YAML.

Solution :
- loadConfig() génère maintenant les IDs à chaque chargement (slugify du nom)
- PUT /admin/groups/:id cherche le groupe par nom slugifié au lieu de l'ID
- DELETE /admin/groups/:id idem
- Les IDs ne sont jamais sauvegardés dans config.yaml (source de vérité = nom)
- Rechargement après mise à jour pour renvoyer les IDs corrects au client

Comportement :
- Création : génère l'ID depuis le nom
- Édition : trouve le groupe par son nom slugifié, sauvegarde sans ID, recharge
- Suppression : trouve le groupe par son nom slugifié
- Les IDs restent cohérents entre les rechargements
2026-05-24 22:28:43 +02:00
benoit 2bd0c27a71 refactor: harmonisation style admin avec app principale et suppression emojis
Changements de style :
- Utilisation des variables CSS globales (--color-*, --spacing-*)
- Cohérence visuelle avec App.css et index.css
- Suppression emojis du header et boutons (🎛️ ⚠️ ✏️ 🗑️ ✕)
- Remplacement par texte simple ou symboles HTML (×)
- Boutons 'Modifier' et 'Supprimer' au lieu d'icônes

Design system unifié :
- Couleurs : var(--color-primary, --color-surface, etc.)
- Espacements : var(--spacing-xs à --spacing-xl)
- Typographie et transitions cohérentes
- Border radius, padding, hover states alignés

Interface plus professionnelle et accessible sans dépendance externe.
2026-05-24 22:25:39 +02:00
benoit 8b05946632 fix: correction syntaxe JSX formulaire groupe (balise fermante manquante) 2026-05-24 22:21:00 +02:00
benoit a0839ed563 refactor: simplification structure des groupes
Simplification majeure de la configuration des groupes :
- Suppression des champs redondants 'id' et 'description'
- Le nom du groupe sert maintenant d'identifiant (converti en slug automatiquement)
- Génération automatique des IDs pour groupes et canaux via fonction slugify()

Backend (server/) :
- Ajout fonction slugify() pour génération d'IDs à partir des noms
- Génération automatique des IDs au chargement de la config (index.js)
- API admin adaptée : POST/PUT /admin/groups génèrent les IDs automatiquement
- Pas besoin de fournir l'ID lors de la création/modification

Frontend (client/src/Admin.jsx + Admin.css) :
- Suppression champs ID et description du formulaire
- Simplification interface : nom + bitrate + canaux
- Mise à jour layout CSS canal (4 colonnes au lieu de 5)
- Cartes de groupe épurées (plus d'affichage d'ID)

Configuration (config.yaml) :
- Format simplifié : groupes avec 'name', 'channels' et 'audioBitrate' optionnel
- Exemple : "Production" au lieu de id/name/description séparés
- Plus lisible et maintenable

Les IDs sont générés dynamiquement :
- Groupe "Production" → id: "production"
- Canal "Principal" → id: "production-principal"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-24 20:32:24 +02:00
benoit 637cc3e3a7 feat: interface admin complète pour gestion système (Phase 2.3)
Implémentation complète de l'interface d'administration web :

Backend (server/api/admin.js) :
- Endpoints CRUD pour gestion des groupes (GET/POST/PUT/DELETE /admin/groups)
- Gestion utilisateurs connectés en temps réel (GET/DELETE /admin/users)
- Monitoring statistiques système (GET /admin/stats)
- Affichage logs serveur avec filtrage (GET /admin/logs)
- Configuration audio globale (PUT /admin/config/audio)
- Système de tracking des connexions/déconnexions
- Export fonctions registerUser, unregisterUser, addLog

Frontend (client/src/Admin.jsx + Admin.css) :
- Interface admin complète avec 4 onglets (Groupes, Utilisateurs, Stats, Logs)
- Gestion groupes : création, modification, suppression avec formulaires
- Gestion canaux audio par groupe (inputs/outputs)
- Liste utilisateurs connectés avec déconnexion forcée
- Dashboard statistiques temps réel (connexions, uptime, mémoire)
- Viewer logs avec code couleur par niveau (debug/info/warn/error)
- Rafraîchissement auto toutes les 3s
- Design responsive et mode sombre

Intégration système :
- Routes admin montées sous /admin dans index.js
- Enregistrement automatique des utilisateurs lors de la génération de token
- Logs serveur centralisés dans le système admin
- Routing simple frontend pour /admin (main.jsx)

🎛️ Interface accessible via https://localhost:5173/admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-24 20:13:20 +02:00
7 changed files with 1739 additions and 55 deletions
+51 -11
View File
@@ -1,7 +1,7 @@
# TODO.md - Plan de développement PTT Live
**Dernière mise à jour** : 2026-05-23
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours)
**Dernière mise à jour** : 2026-05-24
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours - Phase 2.5 Configuration audio visuelle)
---
@@ -159,11 +159,46 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
- [ ] Préférences utilisateur (mode par défaut)
### 2.3 Interface admin
- [ ] Page admin web (/admin)
- [ ] Gestion groupes (CRUD)
- [ ] Gestion utilisateurs connectés
- [ ] Monitoring temps réel (latence, qualité)
- [ ] Logs serveur (affichage live)
- [x] Page admin web (/admin)
- [x] Gestion groupes (CRUD)
- [x] Gestion utilisateurs connectés
- [x] Monitoring temps réel (latence, qualité)
- [x] Logs serveur (affichage live)
### 2.5 Configuration audio visuelle (PRIORITÉ)
#### Détection et sélection carte son
- [ ] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
- [ ] API POST /api/audio/device (sélection + config sample rate/buffer)
- [ ] Page admin : dropdown sélection carte son
- [ ] Page admin : affichage infos carte (entrées/sorties, sample rate)
- [ ] Backend : reload bridge audio sans redémarrer serveur
#### Nommage des canaux
- [ ] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [ ] API GET /api/audio/channels/names (récupération noms)
- [ ] Page admin : formulaire nommage canaux (inputs/outputs)
- [ ] Page admin : filtre "canaux nommés uniquement"
- [ ] Sauvegarde automatique dans config.yaml
#### Matrice de routing (style Dante Controller)
- [ ] API GET /api/audio/routing (récupération routing actuel)
- [ ] API POST /api/audio/routing (sauvegarde routing)
- [ ] Component React : AudioRoutingMatrix.jsx
- [ ] Matrice inputs → groups (checkboxes)
- [ ] Matrice groups → outputs (checkboxes)
- [ ] Dropdowns gain par route (-12dB à +6dB)
- [ ] Indicateurs niveaux temps réel (WebSocket)
- [ ] Backend : GroupAudioRouter.js (routing par groupe)
- [ ] Mix canaux physiques multiples → groupe
- [ ] Distribution groupe → canaux physiques multiples
- [ ] Gestion gains individuels
- [ ] Support canaux partagés (mixage additif)
- [ ] Backend : ConfigManager.js (lecture/écriture YAML)
- [ ] Méthodes update pour device/channels/routing
- [ ] Sauvegarde atomique avec backup auto
- [ ] Émission événement config-updated
- [ ] WebSocket audio-levels (monitoring temps réel)
- [ ] Tests : routing multi-canaux, canaux partagés
### 2.4 Notifications
- [ ] Web Push : appels privés
@@ -203,12 +238,17 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
## Prochaines actions immédiates
### Phase 2 - Suite
### Phase 2 - Suite (PRIORITÉS)
1. ✅ Multi-groupes avec sélection dynamique (2.1)
2. ✅ Mode PTT continu par appui long (2.2)
3. ⏭️ Préférences utilisateur pour mode PTT par défaut
4. ⏭️ Interface admin web (/admin) pour gestion groupes (2.3)
5. ⏭️ Web Push notifications pour appels privés (2.4)
3. ✅ Interface admin web (/admin) pour gestion groupes (2.3)
4. 🎯 **Configuration audio visuelle (2.5)** ← PRIORITÉ ABSOLUE
- Détection/sélection carte son via interface admin
- Nommage canaux (inputs/outputs)
- Matrice routing style Dante Controller
- Sauvegarde automatique dans YAML
5. ⏭️ Préférences utilisateur pour mode PTT par défaut (2.2)
6. ⏭️ Web Push notifications pour appels privés (2.4)
### Phase 3 - Préparation
- Support Linux (JACK/PipeWire backends)
+627
View File
@@ -0,0 +1,627 @@
/* Admin Interface - Utilise les mêmes variables que l'app principale */
.admin-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg);
color: var(--color-text);
overflow: hidden;
}
/* Header */
.admin-header {
background: var(--color-surface);
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.btn-back {
background: var(--color-surface-hover);
color: var(--color-text-secondary);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 6px;
text-decoration: none;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-back:hover {
background: var(--color-border);
color: var(--color-text);
}
/* Tabs */
.admin-tabs {
background: var(--color-surface);
padding: 0 var(--spacing-lg);
display: flex;
gap: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.admin-tabs button {
background: none;
border: none;
color: var(--color-text-secondary);
padding: var(--spacing-md) var(--spacing-lg);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
font-size: 0.95rem;
white-space: nowrap;
}
.admin-tabs button:hover {
color: var(--color-text);
background: var(--color-surface-hover);
}
.admin-tabs button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* Content */
.admin-content {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
}
.admin-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--color-danger);
color: var(--color-danger);
padding: var(--spacing-md);
border-radius: 8px;
margin-bottom: var(--spacing-lg);
}
.empty-state {
text-align: center;
color: var(--color-text-secondary);
padding: var(--spacing-xl);
font-size: 1rem;
}
/* Tab Headers */
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.tab-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Buttons */
.btn-primary {
background: var(--color-primary);
color: white;
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--color-surface-hover);
color: var(--color-text-secondary);
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.2s;
}
.btn-secondary:hover {
background: var(--color-border);
color: var(--color-text);
}
.btn-small {
background: var(--color-primary);
color: white;
border: none;
padding: var(--spacing-xs) var(--spacing-md);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-small:hover {
background: var(--color-primary-hover);
}
.btn-danger {
background: var(--color-danger);
color: white;
border: none;
padding: var(--spacing-xs) var(--spacing-md);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-danger-small {
background: var(--color-danger);
color: white;
border: none;
padding: 0.3rem var(--spacing-sm);
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.btn-danger-small:hover {
background: #dc2626;
}
.btn-edit {
background: var(--color-primary);
color: white;
border: none;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-edit:hover {
background: var(--color-primary-hover);
}
.btn-delete {
background: var(--color-danger);
color: white;
border: none;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-delete:hover {
background: #dc2626;
}
/* Groups */
.groups-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.group-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-lg);
transition: border-color 0.2s;
}
.group-card:hover {
border-color: var(--color-primary);
}
.group-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.group-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.group-actions {
display: flex;
gap: var(--spacing-sm);
}
.group-info {
display: flex;
gap: var(--spacing-lg);
color: var(--color-text-secondary);
font-size: 0.9rem;
margin-bottom: var(--spacing-md);
}
.channels-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.channel-badge {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: 6px;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* Group Form */
.group-form-container {
background: var(--color-surface);
border: 1px solid var(--color-primary);
border-radius: 12px;
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.group-form-container h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.25rem;
font-weight: 600;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
}
.group-form-container label {
display: block;
margin-bottom: var(--spacing-md);
color: var(--color-text-secondary);
font-size: 0.9rem;
font-weight: 500;
}
.group-form-container label input,
.group-form-container label select {
display: block;
width: 100%;
margin-top: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text);
font-size: 1rem;
transition: border-color 0.2s;
}
.group-form-container label input:focus,
.group-form-container label select:focus {
outline: none;
border-color: var(--color-primary);
}
.channels-section {
margin: var(--spacing-lg) 0;
}
.channels-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.channels-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.channel-item {
display: grid;
grid-template-columns: 2fr 80px 80px 50px;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
align-items: center;
}
.channel-item input {
padding: var(--spacing-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.9rem;
transition: border-color 0.2s;
}
.channel-item input:focus {
outline: none;
border-color: var(--color-primary);
}
.form-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
/* Users Table */
.users-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-border);
}
.users-table th {
background: var(--color-surface-hover);
padding: var(--spacing-md);
text-align: left;
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
.users-table td {
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.users-table tr:last-child td {
border-bottom: none;
}
.users-table tr:hover {
background: var(--color-surface-hover);
}
.group-badge {
background: var(--color-primary);
color: white;
padding: 0.25rem var(--spacing-sm);
border-radius: 4px;
font-size: 0.8rem;
display: inline-block;
font-weight: 500;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-lg);
text-align: center;
}
.stat-card h3 {
margin: 0 0 var(--spacing-md) 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-primary);
}
.audio-stats {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-lg);
margin-top: var(--spacing-xl);
}
.audio-stats h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.25rem;
font-weight: 600;
}
.stats-table {
width: 100%;
border-collapse: collapse;
}
.stats-table th {
background: var(--color-surface-hover);
padding: var(--spacing-md);
text-align: left;
font-size: 0.9rem;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
.stats-table td {
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
vertical-align: top;
font-size: 0.9rem;
}
.stats-table code {
display: block;
background: var(--color-bg);
padding: var(--spacing-sm);
border-radius: 6px;
font-size: 0.8rem;
max-height: 100px;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
}
/* Logs */
.logs-container {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-md);
max-height: 70vh;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
}
.log-entry {
padding: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
border-left: 3px solid var(--color-border);
background: var(--color-bg);
border-radius: 4px;
display: grid;
grid-template-columns: 180px 80px 1fr;
gap: var(--spacing-md);
align-items: start;
font-size: 0.85rem;
}
.log-entry.log-debug {
border-left-color: var(--color-primary);
}
.log-entry.log-info {
border-left-color: var(--color-success);
}
.log-entry.log-warn {
border-left-color: var(--color-warning);
}
.log-entry.log-error {
border-left-color: var(--color-danger);
}
.log-timestamp {
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
.log-entry.log-debug .log-level {
color: var(--color-primary);
}
.log-entry.log-info .log-level {
color: var(--color-success);
}
.log-entry.log-warn .log-level {
color: var(--color-warning);
}
.log-entry.log-error .log-level {
color: var(--color-danger);
}
.log-message {
word-break: break-word;
}
.log-meta {
grid-column: 3;
background: var(--color-surface);
padding: var(--spacing-sm);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-text-secondary);
display: block;
margin-top: var(--spacing-xs);
}
/* Responsive */
@media (max-width: 768px) {
.admin-content {
padding: var(--spacing-md);
}
.groups-list {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.channel-item {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
.log-entry {
grid-template-columns: 1fr;
gap: var(--spacing-xs);
}
.log-meta {
grid-column: 1;
}
.users-table {
font-size: 0.8rem;
}
.users-table th,
.users-table td {
padding: var(--spacing-sm);
}
}
@media (max-width: 640px) {
.admin-header h1 {
font-size: 1.2rem;
}
.admin-tabs {
padding: 0 var(--spacing-md);
}
.admin-tabs button {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.85rem;
}
}
+523
View File
@@ -0,0 +1,523 @@
import { useState, useEffect } from 'react';
import './Admin.css';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function Admin() {
const [activeTab, setActiveTab] = useState('groups');
const [groups, setGroups] = useState([]);
const [users, setUsers] = useState([]);
const [stats, setStats] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Gestion formulaire nouveau groupe
const [showGroupForm, setShowGroupForm] = useState(false);
const [editingGroup, setEditingGroup] = useState(null);
const [groupForm, setGroupForm] = useState({
name: '',
audioBitrate: 96,
channels: []
});
// Rafraîchissement automatique
useEffect(() => {
loadData();
const interval = setInterval(loadData, 3000); // Refresh toutes les 3s
return () => clearInterval(interval);
}, [activeTab]);
const loadData = async () => {
try {
setLoading(true);
if (activeTab === 'groups') {
await loadGroups();
} else if (activeTab === 'users') {
await loadUsers();
} else if (activeTab === 'stats') {
await loadStats();
} else if (activeTab === 'logs') {
await loadLogs();
}
setError(null);
} catch (err) {
console.error('Erreur chargement données:', err);
setError('Erreur de connexion au serveur');
} finally {
setLoading(false);
}
};
const loadGroups = async () => {
const res = await fetch(`${API_URL}/admin/groups`);
const data = await res.json();
setGroups(data.groups || []);
};
const loadUsers = async () => {
const res = await fetch(`${API_URL}/admin/users`);
const data = await res.json();
setUsers(data.users || []);
};
const loadStats = async () => {
const res = await fetch(`${API_URL}/admin/stats`);
const data = await res.json();
setStats(data);
};
const loadLogs = async () => {
const res = await fetch(`${API_URL}/admin/logs?limit=50`);
const data = await res.json();
setLogs(data.logs || []);
};
// ========== Gestion groupes ==========
const handleCreateGroup = async (e) => {
e.preventDefault();
try {
const res = await fetch(`${API_URL}/admin/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
});
if (res.ok) {
setShowGroupForm(false);
resetGroupForm();
await loadGroups();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur création groupe:', err);
alert('Erreur lors de la création du groupe');
}
};
const handleUpdateGroup = async (e) => {
e.preventDefault();
try {
const res = await fetch(`${API_URL}/admin/groups/${editingGroup}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
});
if (res.ok) {
setEditingGroup(null);
resetGroupForm();
await loadGroups();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur modification groupe:', err);
alert('Erreur lors de la modification du groupe');
}
};
const handleDeleteGroup = async (groupId) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce groupe ?')) {
return;
}
try {
const res = await fetch(`${API_URL}/admin/groups/${groupId}`, {
method: 'DELETE'
});
if (res.ok) {
await loadGroups();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur suppression groupe:', err);
alert('Erreur lors de la suppression du groupe');
}
};
const startEditGroup = (group) => {
setEditingGroup(group.id);
setGroupForm({
name: group.name,
audioBitrate: group.audioBitrate || 96,
channels: group.channels || []
});
setShowGroupForm(true);
};
const resetGroupForm = () => {
setGroupForm({
name: '',
audioBitrate: 96,
channels: []
});
setShowGroupForm(false);
setEditingGroup(null);
};
const addChannel = () => {
setGroupForm({
...groupForm,
channels: [
...groupForm.channels,
{ name: '', audioInput: 0, audioOutput: 0 }
]
});
};
const updateChannel = (index, field, value) => {
const newChannels = [...groupForm.channels];
newChannels[index] = {
...newChannels[index],
[field]: field === 'audioInput' || field === 'audioOutput' ? parseInt(value) : value
};
setGroupForm({ ...groupForm, channels: newChannels });
};
const removeChannel = (index) => {
const newChannels = [...groupForm.channels];
newChannels.splice(index, 1);
setGroupForm({ ...groupForm, channels: newChannels });
};
// ========== Gestion utilisateurs ==========
const handleDisconnectUser = async (identity) => {
if (!confirm('Déconnecter cet utilisateur ?')) {
return;
}
try {
const res = await fetch(`${API_URL}/admin/users/${identity}`, {
method: 'DELETE'
});
if (res.ok) {
await loadUsers();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur déconnexion utilisateur:', err);
alert('Erreur lors de la déconnexion');
}
};
// ========== Render ==========
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('fr-FR');
};
const formatUptime = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
};
return (
<div className="admin-container">
<header className="admin-header">
<h1>PTT Live - Administration</h1>
<a href="/" className="btn-back"> Retour</a>
</header>
<nav className="admin-tabs">
<button
className={activeTab === 'groups' ? 'active' : ''}
onClick={() => setActiveTab('groups')}
>
Groupes
</button>
<button
className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')}
>
Utilisateurs ({users.length})
</button>
<button
className={activeTab === 'stats' ? 'active' : ''}
onClick={() => setActiveTab('stats')}
>
Statistiques
</button>
<button
className={activeTab === 'logs' ? 'active' : ''}
onClick={() => setActiveTab('logs')}
>
Logs
</button>
</nav>
<main className="admin-content">
{error && (
<div className="admin-error">
{error}
</div>
)}
{/* TAB: Groupes */}
{activeTab === 'groups' && (
<div className="tab-groups">
<div className="tab-header">
<h2>Gestion des groupes</h2>
{!showGroupForm && (
<button className="btn-primary" onClick={() => setShowGroupForm(true)}>
+ Nouveau groupe
</button>
)}
</div>
{showGroupForm && (
<div className="group-form-container">
<form onSubmit={editingGroup ? handleUpdateGroup : handleCreateGroup}>
<h3>{editingGroup ? 'Modifier' : 'Nouveau'} groupe</h3>
<div className="form-row">
<label>
Nom du groupe
<input
type="text"
value={groupForm.name}
onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })}
placeholder="ex: Production, Technique..."
required
/>
</label>
<label>
Bitrate audio (kbps)
<input
type="number"
value={groupForm.audioBitrate}
onChange={(e) => setGroupForm({ ...groupForm, audioBitrate: parseInt(e.target.value) })}
min="32"
max="320"
/>
</label>
</div>
<div className="channels-section">
<div className="channels-header">
<h4>Canaux audio</h4>
<button type="button" onClick={addChannel} className="btn-small">
+ Canal
</button>
</div>
{groupForm.channels.map((channel, index) => (
<div key={index} className="channel-item">
<input
type="text"
placeholder="Nom canal (ex: Principal, Backup...)"
value={channel.name}
onChange={(e) => updateChannel(index, 'name', e.target.value)}
required
/>
<input
type="number"
placeholder="Input"
value={channel.audioInput}
onChange={(e) => updateChannel(index, 'audioInput', e.target.value)}
min="0"
/>
<input
type="number"
placeholder="Output"
value={channel.audioOutput}
onChange={(e) => updateChannel(index, 'audioOutput', e.target.value)}
min="0"
/>
<button type="button" onClick={() => removeChannel(index)} className="btn-danger">
×
</button>
</div>
))}
</div>
<div className="form-actions">
<button type="submit" className="btn-primary">
{editingGroup ? 'Modifier' : 'Créer'}
</button>
<button type="button" onClick={resetGroupForm} className="btn-secondary">
Annuler
</button>
</div>
</form>
</div>
)}
<div className="groups-list">
{groups.map(group => (
<div key={group.id} className="group-card">
<div className="group-header">
<h3>{group.name}</h3>
<div className="group-actions">
<button onClick={() => startEditGroup(group)} className="btn-edit">
Modifier
</button>
<button onClick={() => handleDeleteGroup(group.id)} className="btn-delete">
Supprimer
</button>
</div>
</div>
<div className="group-info">
<span>Bitrate: {group.audioBitrate || 96} kbps</span>
<span>Canaux: {group.channels?.length || 0}</span>
</div>
{group.channels && group.channels.length > 0 && (
<div className="channels-list">
{group.channels.map(channel => (
<div key={channel.id} className="channel-badge">
{channel.name} (I/O: {channel.audioInput}/{channel.audioOutput})
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* TAB: Utilisateurs */}
{activeTab === 'users' && (
<div className="tab-users">
<h2>Utilisateurs connectés ({users.length})</h2>
{users.length === 0 ? (
<p className="empty-state">Aucun utilisateur connecté</p>
) : (
<table className="users-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Groupe</th>
<th>Connecté depuis</th>
<th>Dernière activité</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.identity}>
<td>{user.username}</td>
<td><span className="group-badge">{user.groupId}</span></td>
<td>{formatDate(user.connectedAt)}</td>
<td>{formatDate(user.lastActivity)}</td>
<td>
<button
onClick={() => handleDisconnectUser(user.identity)}
className="btn-danger-small"
>
Déconnecter
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* TAB: Statistiques */}
{activeTab === 'stats' && stats && (
<div className="tab-stats">
<h2>Statistiques système</h2>
<div className="stats-grid">
<div className="stat-card">
<h3>Connexions totales</h3>
<div className="stat-value">{stats.totalConnections}</div>
</div>
<div className="stat-card">
<h3>Connexions actives</h3>
<div className="stat-value">{stats.activeConnections}</div>
</div>
<div className="stat-card">
<h3>Uptime</h3>
<div className="stat-value">{formatUptime(stats.uptime)}</div>
</div>
<div className="stat-card">
<h3>Mémoire</h3>
<div className="stat-value">
{Math.round(stats.memory.heapUsed / 1024 / 1024)} MB
</div>
</div>
</div>
{stats.audioStats && stats.audioStats.length > 0 && (
<div className="audio-stats">
<h3>Dernières stats audio</h3>
<table className="stats-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Type</th>
<th>Données</th>
</tr>
</thead>
<tbody>
{stats.audioStats.map((stat, index) => (
<tr key={index}>
<td>{formatDate(stat.timestamp)}</td>
<td>{stat.type || 'N/A'}</td>
<td><code>{JSON.stringify(stat, null, 2)}</code></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* TAB: Logs */}
{activeTab === 'logs' && (
<div className="tab-logs">
<h2>Logs serveur ({logs.length})</h2>
{logs.length === 0 ? (
<p className="empty-state">Aucun log disponible</p>
) : (
<div className="logs-container">
{logs.map((log, index) => (
<div key={index} className={`log-entry log-${log.level}`}>
<span className="log-timestamp">{formatDate(log.timestamp)}</span>
<span className="log-level">{log.level.toUpperCase()}</span>
<span className="log-message">{log.message}</span>
{log.meta && Object.keys(log.meta).length > 0 && (
<code className="log-meta">{JSON.stringify(log.meta)}</code>
)}
</div>
))}
</div>
)}
</div>
)}
</main>
</div>
);
}
export default Admin;
+5 -1
View File
@@ -1,10 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import Admin from './Admin.jsx';
import './index.css';
// Simple routing basé sur le path
const isAdminPage = window.location.pathname.startsWith('/admin');
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
{isAdminPage ? <Admin /> : <App />}
</React.StrictMode>
);
+476
View File
@@ -0,0 +1,476 @@
/**
* API Admin - Gestion groupes, utilisateurs, monitoring
* Phase 2.3
*/
import { Router } from 'express';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const router = Router();
/**
* Génère un ID slug à partir d'un nom
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
// État en mémoire des utilisateurs connectés
const connectedUsers = new Map(); // identity -> { username, groupId, roomName, connectedAt, lastActivity }
// Stats monitoring
const stats = {
totalConnections: 0,
activeConnections: 0,
audioStats: [],
logs: []
};
// Configuration file path
const configPath = join(__dirname, '..', 'config', 'config.yaml');
/**
* Charge la configuration depuis le fichier YAML
* et génère les IDs à partir des noms
*/
function loadConfig() {
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
// Générer les IDs pour les groupes et canaux
config.groups = config.groups.map(group => {
const groupId = slugify(group.name);
return {
...group,
id: groupId,
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
};
});
return config;
}
/**
* Sauvegarde la configuration dans le fichier YAML
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
*/
function saveConfig(config) {
// Nettoyer les IDs avant de sauvegarder
const cleanConfig = {
...config,
groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group;
return {
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
})
};
const yamlContent = YAML.stringify(cleanConfig);
writeFileSync(configPath, yamlContent, 'utf8');
}
/**
* Ajoute un log au système
*/
export function addLog(level, message, meta = {}) {
const log = {
timestamp: new Date().toISOString(),
level,
message,
meta
};
stats.logs.unshift(log);
// Garder max 1000 logs en mémoire
if (stats.logs.length > 1000) {
stats.logs = stats.logs.slice(0, 1000);
}
}
/**
* Enregistre une connexion utilisateur
*/
export function registerUser(identity, username, groupId, roomName) {
connectedUsers.set(identity, {
username,
groupId,
roomName,
connectedAt: new Date().toISOString(),
lastActivity: new Date().toISOString()
});
stats.totalConnections++;
stats.activeConnections = connectedUsers.size;
addLog('info', `User connected: ${username}`, { groupId, identity });
}
/**
* Déconnecte un utilisateur
*/
export function unregisterUser(identity) {
const user = connectedUsers.get(identity);
if (user) {
connectedUsers.delete(identity);
stats.activeConnections = connectedUsers.size;
addLog('info', `User disconnected: ${user.username}`, { groupId: user.groupId, identity });
}
}
/**
* Met à jour l'activité d'un utilisateur
*/
export function updateUserActivity(identity) {
const user = connectedUsers.get(identity);
if (user) {
user.lastActivity = new Date().toISOString();
}
}
/**
* Ajoute des statistiques audio
*/
export function addAudioStats(data) {
const stat = {
timestamp: new Date().toISOString(),
...data
};
stats.audioStats.unshift(stat);
// Garder max 100 stats
if (stats.audioStats.length > 100) {
stats.audioStats = stats.audioStats.slice(0, 100);
}
}
// ========== Routes Admin ==========
/**
* GET /admin/groups
* Liste tous les groupes avec détails
*/
router.get('/groups', (req, res) => {
try {
const config = loadConfig();
res.json({
groups: config.groups
});
} catch (error) {
console.error('Erreur GET /admin/groups:', error);
res.status(500).json({ error: 'Failed to load groups' });
}
});
/**
* POST /admin/groups
* Crée un nouveau groupe
* Body: { name, audioBitrate?, channels }
* L'ID est généré automatiquement à partir du nom
*/
router.post('/groups', (req, res) => {
try {
const { name, audioBitrate, channels } = req.body;
if (!name || !channels || !Array.isArray(channels)) {
return res.status(400).json({
error: 'Missing required fields: name, channels'
});
}
const config = loadConfig();
// Générer l'ID à partir du nom
const id = slugify(name);
// Vérifier que l'ID n'existe pas déjà
if (config.groups.find(g => g.id === id)) {
return res.status(409).json({
error: `Group "${name}" already exists (ID: ${id})`
});
}
// Générer les IDs pour les canaux
const channelsWithIds = channels.map(channel => ({
...channel,
id: channel.id || `${id}-${slugify(channel.name)}`
}));
// Créer le nouveau groupe
const newGroup = {
name,
audioBitrate: audioBitrate || config.audio.defaultBitrate,
channels: channelsWithIds
};
config.groups.push(newGroup);
saveConfig(config);
addLog('info', `Group created: ${name}`, { id });
res.status(201).json({
message: 'Group created',
group: { ...newGroup, id }
});
} catch (error) {
console.error('Erreur POST /admin/groups:', error);
res.status(500).json({ error: 'Failed to create group' });
}
});
/**
* PUT /admin/groups/:id
* Modifie un groupe existant
* Body: { name?, audioBitrate?, channels? }
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
*/
router.put('/groups/:id', (req, res) => {
try {
const { id } = req.params;
const { name, audioBitrate, channels } = req.body;
const config = loadConfig();
// Chercher le groupe par son nom (qui correspond à l'ID slugifié)
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
if (groupIndex === -1) {
return res.status(404).json({
error: `Group ${id} not found`
});
}
// Mettre à jour les champs fournis
if (name !== undefined) config.groups[groupIndex].name = name;
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
if (channels !== undefined) {
// Pas besoin de générer les IDs ici, ils seront générés au chargement
config.groups[groupIndex].channels = channels.map(channel => ({
name: channel.name,
audioInput: channel.audioInput,
audioOutput: channel.audioOutput
}));
}
saveConfig(config);
addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
// Recharger pour obtenir les IDs générés
const updatedConfig = loadConfig();
const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name));
const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null;
res.json({
message: 'Group updated',
group: updatedGroup
});
} catch (error) {
console.error('Erreur PUT /admin/groups:', error);
res.status(500).json({ error: 'Failed to update group' });
}
});
/**
* DELETE /admin/groups/:id
* Supprime un groupe
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
*/
router.delete('/groups/:id', (req, res) => {
try {
const { id } = req.params;
const config = loadConfig();
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
if (groupIndex === -1) {
return res.status(404).json({
error: `Group ${id} not found`
});
}
const groupName = config.groups[groupIndex].name;
config.groups.splice(groupIndex, 1);
saveConfig(config);
addLog('info', `Group deleted: ${groupName}`, { id });
res.json({
message: 'Group deleted',
id
});
} catch (error) {
console.error('Erreur DELETE /admin/groups:', error);
res.status(500).json({ error: 'Failed to delete group' });
}
});
/**
* GET /admin/users
* Liste tous les utilisateurs connectés
*/
router.get('/users', (req, res) => {
try {
const users = Array.from(connectedUsers.entries()).map(([identity, data]) => ({
identity,
...data
}));
res.json({
users,
count: users.length
});
} catch (error) {
console.error('Erreur GET /admin/users:', error);
res.status(500).json({ error: 'Failed to load users' });
}
});
/**
* DELETE /admin/users/:identity
* Déconnecte un utilisateur (force disconnect)
*/
router.delete('/users/:identity', (req, res) => {
try {
const { identity } = req.params;
const user = connectedUsers.get(identity);
if (!user) {
return res.status(404).json({
error: `User ${identity} not found`
});
}
unregisterUser(identity);
addLog('warn', `User force disconnected: ${user.username}`, { identity });
res.json({
message: 'User disconnected',
identity
});
} catch (error) {
console.error('Erreur DELETE /admin/users:', error);
res.status(500).json({ error: 'Failed to disconnect user' });
}
});
/**
* GET /admin/stats
* Statistiques temps réel
*/
router.get('/stats', (req, res) => {
try {
res.json({
totalConnections: stats.totalConnections,
activeConnections: stats.activeConnections,
audioStats: stats.audioStats.slice(0, 20), // 20 dernières stats
uptime: process.uptime(),
memory: process.memoryUsage()
});
} catch (error) {
console.error('Erreur GET /admin/stats:', error);
res.status(500).json({ error: 'Failed to load stats' });
}
});
/**
* GET /admin/logs
* Logs serveur
* Query params: ?limit=100&level=info
*/
router.get('/logs', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const level = req.query.level;
let logs = stats.logs;
// Filtrer par niveau si spécifié
if (level) {
logs = logs.filter(log => log.level === level);
}
// Limiter le nombre
logs = logs.slice(0, limit);
res.json({
logs,
total: stats.logs.length
});
} catch (error) {
console.error('Erreur GET /admin/logs:', error);
res.status(500).json({ error: 'Failed to load logs' });
}
});
/**
* GET /admin/config
* Configuration serveur complète
*/
router.get('/config', (req, res) => {
try {
const config = loadConfig();
res.json(config);
} catch (error) {
console.error('Erreur GET /admin/config:', error);
res.status(500).json({ error: 'Failed to load config' });
}
});
/**
* PUT /admin/config/audio
* Met à jour la configuration audio globale
* Body: { sampleRate?, defaultBitrate?, jitterBufferMs? }
*/
router.put('/config/audio', (req, res) => {
try {
const { sampleRate, defaultBitrate, jitterBufferMs } = req.body;
const config = loadConfig();
if (sampleRate !== undefined) config.audio.sampleRate = sampleRate;
if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate;
if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs;
saveConfig(config);
addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs });
res.json({
message: 'Audio config updated',
audio: config.audio
});
} catch (error) {
console.error('Erreur PUT /admin/config/audio:', error);
res.status(500).json({ error: 'Failed to update audio config' });
}
});
export default router;
+12 -40
View File
@@ -1,63 +1,38 @@
# PTT Live - Configuration
# Phase 1: Configuration basique (1 groupe, support multi-canaux)
# Format simplifié : nom du groupe + canaux (les IDs sont générés automatiquement)
# Configuration audio globale
audio:
sampleRate: 48000
frameSize: 20 # ms
# Qualité Opus configurable
# Voix économique: 32-64 kbps (WiFi limité)
# Voix standard: 96 kbps (défaut)
# Voix HD: 128-192 kbps
# Musique: 256-320 kbps
defaultBitrate: 96 # kbps
# Jitter buffer
jitterBufferMs: 40
# Configuration des groupes
groups:
- id: production
name: "Équipe Production"
description: "Réalisateur, cadreurs, régisseur"
# Qualité audio spécifique (optionnel, sinon utilise defaultBitrate)
- name: "Production"
audioBitrate: 96
# Canaux audio associés
channels:
- id: prod-main
name: "Production principale"
audioInput: 0 # Index device CoreAudio/JACK
- name: "Principal"
audioInput: 0
audioOutput: 0
- id: prod-backup
name: "Production backup"
- name: "Backup"
audioInput: 1
audioOutput: 1
- id: technique
name: "Équipe Technique"
description: "Techniciens, électriciens, machinistes"
audioBitrate: 96
- name: "Technique"
channels:
- id: tech-main
name: "Technique général"
- name: "Général"
audioInput: 2
audioOutput: 2
- id: sonorisation
name: "Équipe Sonorisation"
description: "Ingénieurs son, retours"
audioBitrate: 128 # Qualité supérieure pour les ingénieurs son
- name: "Sonorisation"
audioBitrate: 128
channels:
- id: son-main
name: "Son principal"
- name: "Principal"
audioInput: 3
audioOutput: 3
- id: son-retours
name: "Retours scène"
- name: "Retours"
audioInput: 4
audioOutput: 4
@@ -65,14 +40,11 @@ groups:
server:
host: "0.0.0.0"
port: 3000
# LiveKit
livekit:
url: "ws://localhost:7880"
# API key/secret dans .env (LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
# Logging
logging:
level: "debug" # debug, info, warn, error
level: "debug"
logLatency: true
logAudioStats: true
+45 -3
View File
@@ -9,6 +9,7 @@ import { dirname, join } from 'path';
import { networkInterfaces } from 'os';
import YAML from 'yaml';
import { AccessToken } from 'livekit-server-sdk';
import adminRouter, { registerUser, addLog } from './api/admin.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -17,6 +18,38 @@ const configPath = join(__dirname, 'config', 'config.yaml');
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
/**
* Génère un ID slug à partir d'un nom
* Ex: "Équipe Production" -> "equipe-production"
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Retire les accents
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
// Générer les IDs pour les groupes et canaux s'ils n'existent pas
config.groups = config.groups.map(group => {
if (!group.id) {
group.id = slugify(group.name);
}
if (group.channels) {
group.channels = group.channels.map(channel => {
if (!channel.id) {
channel.id = `${group.id}-${slugify(channel.name)}`;
}
return channel;
});
}
return group;
});
/**
* Détecte l'IP réseau locale (WiFi/Ethernet)
* @returns {string|null} IP réseau ou null si non trouvée
@@ -83,6 +116,9 @@ function log(level, ...args) {
if (msgLevel >= configLevel) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level.toUpperCase()}]`, ...args);
// Ajouter au système de logs admin
addLog(level, args.join(' '));
}
}
@@ -174,6 +210,11 @@ app.use((req, res, next) => {
next();
});
// ========== Routes Admin ==========
// Monter les routes admin sous /admin
app.use('/admin', adminRouter);
// ========== Routes API ==========
/**
@@ -186,7 +227,6 @@ app.get('/config', (req, res) => {
groups: config.groups.map(g => ({
id: g.id,
name: g.name,
description: g.description,
channels: g.channels.map(c => ({
id: c.id,
name: c.name
@@ -213,8 +253,7 @@ app.get('/groups', (req, res) => {
try {
const groups = config.groups.map(g => ({
id: g.id,
name: g.name,
description: g.description
name: g.name
}));
res.json({ groups });
@@ -269,6 +308,9 @@ app.post('/token', async (req, res) => {
log('info', `Token généré: ${username}${groupId}`);
// Enregistrer l'utilisateur dans le système admin
registerUser(participantIdentity, username, groupId, roomName);
res.json({
token,
url: LIVEKIT_URL,