diff --git a/TODO.md b/TODO.md
index 8943b2e..3f05b63 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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.3 complétée)
---
@@ -159,11 +159,11 @@ 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.4 Notifications
- [ ] Web Push : appels privés
@@ -206,8 +206,8 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### Phase 2 - Suite
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)
+3. ✅ Interface admin web (/admin) pour gestion groupes (2.3)
+4. ⏭️ Préférences utilisateur pour mode PTT par défaut (2.2)
5. ⏭️ Web Push notifications pour appels privés (2.4)
### Phase 3 - Préparation
diff --git a/client/src/Admin.css b/client/src/Admin.css
new file mode 100644
index 0000000..104f0b8
--- /dev/null
+++ b/client/src/Admin.css
@@ -0,0 +1,544 @@
+/* Admin Interface Styles */
+
+.admin-container {
+ min-height: 100vh;
+ background: #1a1a1a;
+ color: #ffffff;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+}
+
+.admin-header {
+ background: #2a2a2a;
+ padding: 1rem 2rem;
+ border-bottom: 2px solid #3a3a3a;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.admin-header h1 {
+ margin: 0;
+ font-size: 1.5rem;
+}
+
+.btn-back {
+ background: #444;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ text-decoration: none;
+ transition: background 0.2s;
+}
+
+.btn-back:hover {
+ background: #555;
+}
+
+/* Tabs */
+.admin-tabs {
+ background: #2a2a2a;
+ padding: 0 2rem;
+ display: flex;
+ gap: 1rem;
+ border-bottom: 1px solid #3a3a3a;
+}
+
+.admin-tabs button {
+ background: none;
+ border: none;
+ color: #aaa;
+ padding: 1rem 1.5rem;
+ cursor: pointer;
+ border-bottom: 3px solid transparent;
+ transition: all 0.2s;
+ font-size: 1rem;
+}
+
+.admin-tabs button:hover {
+ color: #fff;
+ background: #333;
+}
+
+.admin-tabs button.active {
+ color: #4CAF50;
+ border-bottom-color: #4CAF50;
+}
+
+/* Content */
+.admin-content {
+ padding: 2rem;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.admin-error {
+ background: #d32f2f;
+ color: white;
+ padding: 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.empty-state {
+ text-align: center;
+ color: #666;
+ padding: 3rem;
+ font-size: 1.1rem;
+}
+
+/* Tab Headers */
+.tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+.tab-header h2 {
+ margin: 0;
+}
+
+/* Buttons */
+.btn-primary {
+ background: #4CAF50;
+ color: white;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background 0.2s;
+}
+
+.btn-primary:hover {
+ background: #45a049;
+}
+
+.btn-secondary {
+ background: #666;
+ color: white;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background 0.2s;
+}
+
+.btn-secondary:hover {
+ background: #777;
+}
+
+.btn-small {
+ background: #4CAF50;
+ color: white;
+ border: none;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.btn-danger {
+ background: #d32f2f;
+ color: white;
+ border: none;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.btn-danger-small {
+ background: #d32f2f;
+ color: white;
+ border: none;
+ padding: 0.3rem 0.6rem;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+
+.btn-edit {
+ background: #2196F3;
+ border: none;
+ padding: 0.4rem 0.6rem;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+.btn-delete {
+ background: #d32f2f;
+ border: none;
+ padding: 0.4rem 0.6rem;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+/* Groups */
+.groups-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 1.5rem;
+ margin-top: 2rem;
+}
+
+.group-card {
+ background: #2a2a2a;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ padding: 1.5rem;
+}
+
+.group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.group-header h3 {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.3rem;
+}
+
+.group-id {
+ color: #888;
+ font-size: 0.9rem;
+}
+
+.group-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.group-description {
+ color: #aaa;
+ margin: 0 0 1rem 0;
+}
+
+.group-info {
+ display: flex;
+ gap: 1rem;
+ color: #888;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+.channels-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.channel-badge {
+ background: #333;
+ padding: 0.4rem 0.8rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+}
+
+/* Group Form */
+.group-form-container {
+ background: #2a2a2a;
+ border: 2px solid #4CAF50;
+ border-radius: 8px;
+ padding: 2rem;
+ margin-bottom: 2rem;
+}
+
+.group-form-container h3 {
+ margin-top: 0;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.group-form-container label {
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.group-form-container label input,
+.group-form-container label select {
+ display: block;
+ width: 100%;
+ margin-top: 0.5rem;
+ padding: 0.6rem;
+ background: #1a1a1a;
+ border: 1px solid #444;
+ border-radius: 4px;
+ color: white;
+ font-size: 1rem;
+}
+
+.channels-section {
+ margin: 1.5rem 0;
+}
+
+.channels-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.channels-header h4 {
+ margin: 0;
+}
+
+.channel-item {
+ display: grid;
+ grid-template-columns: 1fr 1fr 80px 80px 50px;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ align-items: center;
+}
+
+.channel-item input {
+ padding: 0.5rem;
+ background: #1a1a1a;
+ border: 1px solid #444;
+ border-radius: 4px;
+ color: white;
+ font-size: 0.9rem;
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+/* Users Table */
+.users-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: #2a2a2a;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.users-table th {
+ background: #333;
+ padding: 1rem;
+ text-align: left;
+ font-weight: 600;
+ border-bottom: 2px solid #444;
+}
+
+.users-table td {
+ padding: 1rem;
+ border-bottom: 1px solid #333;
+}
+
+.users-table tr:last-child td {
+ border-bottom: none;
+}
+
+.users-table tr:hover {
+ background: #333;
+}
+
+.group-badge {
+ background: #4CAF50;
+ color: white;
+ padding: 0.3rem 0.8rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ display: inline-block;
+}
+
+/* Stats */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: #2a2a2a;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ padding: 1.5rem;
+ text-align: center;
+}
+
+.stat-card h3 {
+ margin: 0 0 1rem 0;
+ font-size: 0.95rem;
+ color: #aaa;
+ font-weight: 500;
+}
+
+.stat-value {
+ font-size: 2.5rem;
+ font-weight: bold;
+ color: #4CAF50;
+}
+
+.audio-stats {
+ background: #2a2a2a;
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-top: 2rem;
+}
+
+.audio-stats h3 {
+ margin-top: 0;
+}
+
+.stats-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.stats-table th {
+ background: #333;
+ padding: 0.8rem;
+ text-align: left;
+ border-bottom: 2px solid #444;
+}
+
+.stats-table td {
+ padding: 0.8rem;
+ border-bottom: 1px solid #333;
+ vertical-align: top;
+}
+
+.stats-table code {
+ display: block;
+ background: #1a1a1a;
+ padding: 0.5rem;
+ border-radius: 4px;
+ font-size: 0.85rem;
+ max-height: 100px;
+ overflow-y: auto;
+}
+
+/* Logs */
+.logs-container {
+ background: #1a1a1a;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ padding: 1rem;
+ max-height: 70vh;
+ overflow-y: auto;
+ font-family: 'Monaco', 'Menlo', monospace;
+}
+
+.log-entry {
+ padding: 0.6rem;
+ margin-bottom: 0.3rem;
+ border-left: 3px solid #666;
+ background: #2a2a2a;
+ border-radius: 3px;
+ display: grid;
+ grid-template-columns: 180px 80px 1fr;
+ gap: 1rem;
+ align-items: start;
+ font-size: 0.9rem;
+}
+
+.log-entry.log-debug {
+ border-left-color: #2196F3;
+}
+
+.log-entry.log-info {
+ border-left-color: #4CAF50;
+}
+
+.log-entry.log-warn {
+ border-left-color: #FF9800;
+}
+
+.log-entry.log-error {
+ border-left-color: #d32f2f;
+}
+
+.log-timestamp {
+ color: #888;
+ font-size: 0.85rem;
+}
+
+.log-level {
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.log-entry.log-debug .log-level {
+ color: #2196F3;
+}
+
+.log-entry.log-info .log-level {
+ color: #4CAF50;
+}
+
+.log-entry.log-warn .log-level {
+ color: #FF9800;
+}
+
+.log-entry.log-error .log-level {
+ color: #d32f2f;
+}
+
+.log-message {
+ word-break: break-word;
+}
+
+.log-meta {
+ grid-column: 3;
+ background: #1a1a1a;
+ padding: 0.5rem;
+ border-radius: 3px;
+ font-size: 0.8rem;
+ color: #888;
+ display: block;
+ margin-top: 0.3rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .admin-content {
+ padding: 1rem;
+ }
+
+ .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: 0.5rem;
+ }
+
+ .log-meta {
+ grid-column: 1;
+ }
+
+ .users-table {
+ font-size: 0.85rem;
+ }
+
+ .users-table th,
+ .users-table td {
+ padding: 0.6rem;
+ }
+}
diff --git a/client/src/Admin.jsx b/client/src/Admin.jsx
new file mode 100644
index 0000000..e24a08a
--- /dev/null
+++ b/client/src/Admin.jsx
@@ -0,0 +1,562 @@
+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({
+ id: '',
+ name: '',
+ description: '',
+ 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({
+ id: group.id,
+ name: group.name,
+ description: group.description || '',
+ audioBitrate: group.audioBitrate || 96,
+ channels: group.channels || []
+ });
+ setShowGroupForm(true);
+ };
+
+ const resetGroupForm = () => {
+ setGroupForm({
+ id: '',
+ name: '',
+ description: '',
+ audioBitrate: 96,
+ channels: []
+ });
+ setShowGroupForm(false);
+ setEditingGroup(null);
+ };
+
+ const addChannel = () => {
+ setGroupForm({
+ ...groupForm,
+ channels: [
+ ...groupForm.channels,
+ { id: '', 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 (
+
+
+
+
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* TAB: Groupes */}
+ {activeTab === 'groups' && (
+
+
+
Gestion des groupes
+ {!showGroupForm && (
+
+ )}
+
+
+ {showGroupForm && (
+
+ )}
+
+
+ {groups.map(group => (
+
+
+
+
{group.name}
+ #{group.id}
+
+
+
+
+
+
+
+ {group.description && (
+
{group.description}
+ )}
+
+
+ Bitrate: {group.audioBitrate || 96} kbps
+ Canaux: {group.channels?.length || 0}
+
+
+ {group.channels && group.channels.length > 0 && (
+
+ {group.channels.map(channel => (
+
+ {channel.name} (I/O: {channel.audioInput}/{channel.audioOutput})
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* TAB: Utilisateurs */}
+ {activeTab === 'users' && (
+
+
Utilisateurs connectés ({users.length})
+
+ {users.length === 0 ? (
+
Aucun utilisateur connecté
+ ) : (
+
+
+
+ | Utilisateur |
+ Groupe |
+ Connecté depuis |
+ Dernière activité |
+ Actions |
+
+
+
+ {users.map(user => (
+
+ | {user.username} |
+ {user.groupId} |
+ {formatDate(user.connectedAt)} |
+ {formatDate(user.lastActivity)} |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* TAB: Statistiques */}
+ {activeTab === 'stats' && stats && (
+
+
Statistiques système
+
+
+
+
Connexions totales
+
{stats.totalConnections}
+
+
+
+
Connexions actives
+
{stats.activeConnections}
+
+
+
+
Uptime
+
{formatUptime(stats.uptime)}
+
+
+
+
Mémoire
+
+ {Math.round(stats.memory.heapUsed / 1024 / 1024)} MB
+
+
+
+
+ {stats.audioStats && stats.audioStats.length > 0 && (
+
+
Dernières stats audio
+
+
+
+ | Timestamp |
+ Type |
+ Données |
+
+
+
+ {stats.audioStats.map((stat, index) => (
+
+ | {formatDate(stat.timestamp)} |
+ {stat.type || 'N/A'} |
+ {JSON.stringify(stat, null, 2)} |
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {/* TAB: Logs */}
+ {activeTab === 'logs' && (
+
+
Logs serveur ({logs.length})
+
+ {logs.length === 0 ? (
+
Aucun log disponible
+ ) : (
+
+ {logs.map((log, index) => (
+
+ {formatDate(log.timestamp)}
+ {log.level.toUpperCase()}
+ {log.message}
+ {log.meta && Object.keys(log.meta).length > 0 && (
+ {JSON.stringify(log.meta)}
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default Admin;
diff --git a/client/src/main.jsx b/client/src/main.jsx
index 7497ae8..8063bbd 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -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(
-
+ {isAdminPage ? : }
);
diff --git a/server/api/admin.js b/server/api/admin.js
new file mode 100644
index 0000000..50ddc76
--- /dev/null
+++ b/server/api/admin.js
@@ -0,0 +1,406 @@
+/**
+ * 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();
+
+// É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
+ */
+function loadConfig() {
+ const configFile = readFileSync(configPath, 'utf8');
+ return YAML.parse(configFile);
+}
+
+/**
+ * Sauvegarde la configuration dans le fichier YAML
+ */
+function saveConfig(config) {
+ const yamlContent = YAML.stringify(config);
+ 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: { id, name, description, audioBitrate?, channels }
+ */
+router.post('/groups', (req, res) => {
+ try {
+ const { id, name, description, audioBitrate, channels } = req.body;
+
+ if (!id || !name || !channels || !Array.isArray(channels)) {
+ return res.status(400).json({
+ error: 'Missing required fields: id, name, channels'
+ });
+ }
+
+ const config = loadConfig();
+
+ // 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 ${id} already exists`
+ });
+ }
+
+ // Créer le nouveau groupe
+ const newGroup = {
+ id,
+ name,
+ description: description || '',
+ audioBitrate: audioBitrate || config.audio.defaultBitrate,
+ channels
+ };
+
+ config.groups.push(newGroup);
+ saveConfig(config);
+
+ addLog('info', `Group created: ${name}`, { id });
+
+ res.status(201).json({
+ message: 'Group created',
+ group: newGroup
+ });
+
+ } 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?, description?, audioBitrate?, channels? }
+ */
+router.put('/groups/:id', (req, res) => {
+ try {
+ const { id } = req.params;
+ const { name, description, audioBitrate, channels } = req.body;
+
+ const config = loadConfig();
+ const groupIndex = config.groups.findIndex(g => g.id === 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 (description !== undefined) config.groups[groupIndex].description = description;
+ if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
+ if (channels !== undefined) config.groups[groupIndex].channels = channels;
+
+ saveConfig(config);
+
+ addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
+
+ res.json({
+ message: 'Group updated',
+ group: config.groups[groupIndex]
+ });
+
+ } 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
+ */
+router.delete('/groups/:id', (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const config = loadConfig();
+ const groupIndex = config.groups.findIndex(g => g.id === 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;
diff --git a/server/index.js b/server/index.js
index 9641a28..69747e8 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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));
@@ -83,6 +84,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 +178,11 @@ app.use((req, res, next) => {
next();
});
+// ========== Routes Admin ==========
+
+// Monter les routes admin sous /admin
+app.use('/admin', adminRouter);
+
// ========== Routes API ==========
/**
@@ -269,6 +278,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,