AI-Chat-Server / static /js /admin.js
NathMen12's picture
Upload 16 files
c84277e verified
Raw History Blame Contribute Delete
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());