Spaces:
Sleeping
Sleeping
Download static/js/admin.js from NathMen12/AI-Chat-Server: direct link, hf CLI and curl.
- Browser
- Download file 14.4 kB
-
https://huggingface.co/spaces/NathMen12/AI-Chat-Server/resolve/main/static/js/admin.js
- Command line
-
hf download hf://spaces/NathMen12/AI-Chat-Server/static/js/admin.js
-
curl -L -o admin.js https://huggingface.co/spaces/NathMen12/AI-Chat-Server/resolve/main/static/js/admin.js
14.4 kB
| // ============================================================ | |
| // Admin Panel — V10 | |
| // ============================================================ | |
| let authToken = localStorage.getItem('chatia_token'); | |
| let charts = {}; | |
| function toast(msg, type = 'default') { | |
| const container = document.getElementById('toastContainer'); | |
| const el = document.createElement('div'); | |
| el.className = `toast ${type}`; | |
| el.textContent = msg; | |
| container.appendChild(el); | |
| setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 200); }, 3000); | |
| } | |
| async function api(path) { | |
| const resp = await fetch(path, { headers: { 'Authorization': `Bearer ${authToken}` } }); | |
| if (resp.status === 401) { | |
| document.getElementById('unauthorized').style.display = 'flex'; | |
| document.getElementById('adminContent').style.display = 'none'; | |
| throw new Error('Unauthorized'); | |
| } | |
| if (resp.status === 403) { | |
| document.getElementById('unauthorized').style.display = 'flex'; | |
| document.getElementById('unauthorized').querySelector('h1').textContent = 'Accès admin requis'; | |
| document.getElementById('unauthorized').querySelector('p').textContent = 'Votre compte n\'est pas administrateur.'; | |
| document.getElementById('adminContent').style.display = 'none'; | |
| throw new Error('Forbidden'); | |
| } | |
| return resp; | |
| } | |
| function fmtDate(ts) { | |
| if (!ts) return '—'; | |
| return new Date(ts * 1000).toLocaleString(); | |
| } | |
| function fmtNum(n) { | |
| if (!n && n !== 0) return '—'; | |
| return n.toLocaleString(); | |
| } | |
| const Admin = { | |
| async init() { | |
| if (!authToken) { | |
| document.getElementById('unauthorized').style.display = 'flex'; | |
| document.getElementById('adminContent').style.display = 'none'; | |
| return; | |
| } | |
| // Verify admin | |
| try { | |
| const meResp = await api('/me'); | |
| if (!meResp.ok) throw new Error('Not logged in'); | |
| const me = await meResp.json(); | |
| if (!me.is_admin) { | |
| document.getElementById('unauthorized').style.display = 'flex'; | |
| document.getElementById('unauthorized').querySelector('h1').textContent = 'Accès admin requis'; | |
| document.getElementById('unauthorized').querySelector('p').textContent = 'Votre compte n\'est pas administrateur.'; | |
| document.getElementById('adminContent').style.display = 'none'; | |
| return; | |
| } | |
| } catch (e) { | |
| document.getElementById('unauthorized').style.display = 'flex'; | |
| document.getElementById('adminContent').style.display = 'none'; | |
| return; | |
| } | |
| document.getElementById('adminContent').style.display = 'block'; | |
| await this.loadStats(); | |
| await this.loadUsers(); | |
| await this.loadUsage(); | |
| await this.loadAudit(); | |
| }, | |
| switchTab(ev, tabId) { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| ev.currentTarget.classList.add('active'); | |
| document.getElementById(tabId).classList.add('active'); | |
| }, | |
| async loadStats() { | |
| try { | |
| const resp = await api('/admin/stats'); | |
| if (!resp.ok) throw new Error('Failed'); | |
| const s = await resp.json(); | |
| document.getElementById('statTotalUsers').textContent = fmtNum(s.total_users); | |
| document.getElementById('statNew7').textContent = fmtNum(s.new_7d); | |
| document.getElementById('statNew20').textContent = fmtNum(s.new_20d); | |
| document.getElementById('statNew30').textContent = fmtNum(s.new_30d); | |
| document.getElementById('statActive24').textContent = fmtNum(s.active_24h); | |
| document.getElementById('statActive7').textContent = fmtNum(s.active_7d); | |
| document.getElementById('statBanned').textContent = fmtNum(s.banned); | |
| document.getElementById('statJobs').textContent = fmtNum(s.jobs_running); | |
| document.getElementById('statTokens30').textContent = fmtNum(s.tokens_30d); | |
| document.getElementById('statTokensTotal').textContent = fmtNum(s.tokens_total); | |
| const growthEl = document.getElementById('statGrowth'); | |
| if (s.growth_pct !== null) { | |
| const sign = s.growth_pct >= 0 ? '+' : ''; | |
| const cls = s.growth_pct > 0 ? 'up' : (s.growth_pct < 0 ? 'down' : 'neutral'); | |
| growthEl.className = `stat-trend ${cls}`; | |
| growthEl.innerHTML = `<i class="fas fa-arrow-${s.growth_pct > 0 ? 'up' : (s.growth_pct < 0 ? 'down' : 'right')}"></i> ${sign}${s.growth_pct}% vs 30j précédents`; | |
| } | |
| // Charts | |
| this.renderDailyChart(s.daily_new); | |
| this.renderHourlyChart(s.hourly_activity); | |
| this.renderTopModelsChart(s.top_models); | |
| } catch (e) { | |
| toast('Erreur chargement stats: ' + e.message, 'error'); | |
| } | |
| }, | |
| renderDailyChart(data) { | |
| const ctx = document.getElementById('chartDaily').getContext('2d'); | |
| if (charts.daily) charts.daily.destroy(); | |
| charts.daily = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: data.map(d => d.date), | |
| datasets: [{ | |
| label: 'Inscriptions', | |
| data: data.map(d => d.count), | |
| borderColor: '#7c5cff', | |
| backgroundColor: 'rgba(124, 92, 255, 0.1)', | |
| tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#7c5cff', | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { y: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { ticks: { color: '#a0a0b8', maxRotation: 45 }, grid: { color: 'rgba(255,255,255,0.05)' } } } | |
| } | |
| }); | |
| }, | |
| renderHourlyChart(data) { | |
| const ctx = document.getElementById('chartHourly').getContext('2d'); | |
| if (charts.hourly) charts.hourly.destroy(); | |
| const hours = Array.from({ length: 24 }, (_, i) => i); | |
| const counts = hours.map(h => { | |
| const found = data.find(d => d.hour === h); | |
| return found ? found.count : 0; | |
| }); | |
| charts.hourly = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: hours.map(h => h + 'h'), | |
| datasets: [{ | |
| label: 'Connexions', | |
| data: counts, | |
| backgroundColor: '#7c5cff', | |
| borderRadius: 4, | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { y: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { ticks: { color: '#a0a0b8' }, grid: { display: false } } } | |
| } | |
| }); | |
| }, | |
| renderTopModelsChart(data) { | |
| const ctx = document.getElementById('chartTopModels').getContext('2d'); | |
| if (charts.topModels) charts.topModels.destroy(); | |
| charts.topModels = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: data.map(d => d.model), | |
| datasets: [{ | |
| label: 'Appels', | |
| data: data.map(d => d.calls), | |
| backgroundColor: ['#7c5cff', '#00d4ff', '#4ade80', '#fbbf24', '#ff4d6d', '#ff8a3d', '#a78bfa', '#22d3ee'], | |
| borderRadius: 4, | |
| }] | |
| }, | |
| options: { | |
| indexAxis: 'y', | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { x: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { ticks: { color: '#a0a0b8' }, grid: { display: false } } } | |
| } | |
| }); | |
| }, | |
| async loadUsers() { | |
| try { | |
| const resp = await api('/admin/users'); | |
| if (!resp.ok) throw new Error('Failed'); | |
| const data = await resp.json(); | |
| const tbody = document.getElementById('usersTbody'); | |
| tbody.innerHTML = ''; | |
| data.users.forEach(u => { | |
| const tr = document.createElement('tr'); | |
| const status = u.is_banned ? '<span class="badge banned">Banni</span>' : '<span class="badge active">Actif</span>'; | |
| const role = u.is_admin ? '<span class="badge admin">Admin</span>' : '<span class="badge active">User</span>'; | |
| const banBtn = u.is_banned | |
| ? `<button class="btn-success-sm" onclick="Admin.unbanUser('${u.username}')"><i class="fas fa-unlock"></i> Débannir</button>` | |
| : `<button class="btn-danger-sm" onclick="Admin.banUser('${u.username}')"><i class="fas fa-ban"></i> Bannir</button>`; | |
| const deleteBtn = `<button class="btn-danger-sm" onclick="Admin.deleteUser('${u.username}')" style="margin-left:4px"><i class="fas fa-trash"></i></button>`; | |
| tr.innerHTML = ` | |
| <td><strong>${u.username}</strong></td> | |
| <td>${role}</td> | |
| <td class="mono">${fmtDate(u.created_at)}</td> | |
| <td class="mono">${fmtDate(u.last_login_at)}</td> | |
| <td>${fmtNum(u.total_calls)}</td> | |
| <td>${fmtNum(u.login_count)}</td> | |
| <td>${status}</td> | |
| <td>${banBtn}${deleteBtn}</td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| } catch (e) { | |
| toast('Erreur chargement users: ' + e.message, 'error'); | |
| } | |
| }, | |
| async banUser(username) { | |
| if (!confirm(`Bannir l'utilisateur "${username}" ?`)) return; | |
| const resp = await api(`/admin/users/${encodeURIComponent(username)}/ban`, { method: 'POST' }); | |
| if (resp.ok) { toast('Utilisateur banni', 'success'); this.loadUsers(); } | |
| else { toast('Erreur', 'error'); } | |
| }, | |
| async unbanUser(username) { | |
| const resp = await api(`/admin/users/${encodeURIComponent(username)}/unban`, { method: 'POST' }); | |
| if (resp.ok) { toast('Utilisateur débanni', 'success'); this.loadUsers(); } | |
| else { toast('Erreur', 'error'); } | |
| }, | |
| async deleteUser(username) { | |
| if (!confirm(`SUPPRIMER "${username}" et TOUTES ses données ? Cette action est irréversible.`)) return; | |
| if (!confirm(`Confirmez définitivement la suppression de "${username}" ?`)) return; | |
| const resp = await api(`/admin/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); | |
| if (resp.ok) { toast('Utilisateur supprimé', 'success'); this.loadUsers(); } | |
| else { toast('Erreur', 'error'); } | |
| }, | |
| async loadUsage() { | |
| try { | |
| const resp = await api('/admin/usage'); | |
| if (!resp.ok) throw new Error('Failed'); | |
| const data = await resp.json(); | |
| this.renderProviderChart(data.by_provider); | |
| this.renderTopUsersChart(data.top_users); | |
| this.renderDailyUsageChart(data.daily_usage); | |
| } catch (e) { | |
| toast('Erreur chargement usage: ' + e.message, 'error'); | |
| } | |
| }, | |
| renderProviderChart(data) { | |
| const ctx = document.getElementById('chartByProvider').getContext('2d'); | |
| if (charts.byProvider) charts.byProvider.destroy(); | |
| charts.byProvider = new Chart(ctx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: data.map(d => d.provider), | |
| datasets: [{ | |
| data: data.map(d => d.calls), | |
| backgroundColor: ['#7c5cff', '#00d4ff', '#4ade80', '#fbbf24', '#ff4d6d', '#a78bfa'], | |
| borderWidth: 0, | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { position: 'right', labels: { color: '#e4e4f0' } } } | |
| } | |
| }); | |
| }, | |
| renderTopUsersChart(data) { | |
| const ctx = document.getElementById('chartTopUsers').getContext('2d'); | |
| if (charts.topUsers) charts.topUsers.destroy(); | |
| charts.topUsers = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: data.map(d => d.username), | |
| datasets: [{ | |
| label: 'Appels API', | |
| data: data.map(d => d.calls), | |
| backgroundColor: '#00d4ff', | |
| borderRadius: 4, | |
| }] | |
| }, | |
| options: { | |
| indexAxis: 'y', | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { x: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { ticks: { color: '#a0a0b8' }, grid: { display: false } } } | |
| } | |
| }); | |
| }, | |
| renderDailyUsageChart(data) { | |
| const ctx = document.getElementById('chartDailyUsage').getContext('2d'); | |
| if (charts.dailyUsage) charts.dailyUsage.destroy(); | |
| charts.dailyUsage = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: data.map(d => d.date), | |
| datasets: [{ | |
| label: 'Appels API', | |
| data: data.map(d => d.calls), | |
| borderColor: '#4ade80', | |
| backgroundColor: 'rgba(74, 222, 128, 0.1)', | |
| tension: 0.4, fill: true, pointRadius: 3, | |
| }, { | |
| label: 'Tokens (×1000)', | |
| data: data.map(d => Math.round(d.tokens / 1000)), | |
| borderColor: '#fbbf24', | |
| backgroundColor: 'rgba(251, 191, 36, 0.1)', | |
| tension: 0.4, fill: true, pointRadius: 3, yAxisID: 'y1', | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { labels: { color: '#e4e4f0' } } }, | |
| scales: { | |
| y: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { color: 'rgba(255,255,255,0.05)' }, position: 'left' }, | |
| y1: { beginAtZero: true, ticks: { color: '#a0a0b8' }, grid: { display: false }, position: 'right' }, | |
| x: { ticks: { color: '#a0a0b8', maxRotation: 45 }, grid: { color: 'rgba(255,255,255,0.05)' } } | |
| } | |
| } | |
| }); | |
| }, | |
| async loadAudit() { | |
| try { | |
| const resp = await api('/admin/audit?limit=200'); | |
| if (!resp.ok) throw new Error('Failed'); | |
| const data = await resp.json(); | |
| const tbody = document.getElementById('auditTbody'); | |
| tbody.innerHTML = ''; | |
| data.logs.forEach(l => { | |
| const tr = document.createElement('tr'); | |
| let details = ''; | |
| try { if (l.details) details = JSON.stringify(JSON.parse(l.details)); } catch (e) { details = l.details || ''; } | |
| tr.innerHTML = ` | |
| <td class="mono">${fmtDate(l.created_at)}</td> | |
| <td>${l.username || '<em>—</em>'}</td> | |
| <td class="audit-action-cell">${l.action}</td> | |
| <td class="mono">${l.ip || '—'}</td> | |
| <td class="mono" style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${l.user_agent || '—'}</td> | |
| <td class="mono" style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${details || '—'}</td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| } catch (e) { | |
| toast('Erreur chargement audit: ' + e.message, 'error'); | |
| } | |
| }, | |
| }; | |
| window.addEventListener('DOMContentLoaded', () => Admin.init()); | |