Complete Flask → FastAPI migration with: - FastAPI app with session auth, Azure AD SSO, rate limiting - SQLite-backed session store (survives restarts) - Bulk AI metadata generation with SSE progress - Admin panel (user management, audit log, AI usage) - Subpath deployment support (ROOT_PATH config) - Docker + deploy.sh for production deployment - Test suite (auth, upload, templates, imports, admin, sessions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
9 KiB
JavaScript
265 lines
9 KiB
JavaScript
// Admin Dashboard JavaScript
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadUsers();
|
|
});
|
|
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.admin-panel').forEach(p => p.style.display = 'none');
|
|
|
|
event.target.classList.add('active');
|
|
|
|
if (tab === 'users') {
|
|
document.getElementById('usersPanel').style.display = 'block';
|
|
loadUsers();
|
|
} else if (tab === 'audit') {
|
|
document.getElementById('auditPanel').style.display = 'block';
|
|
loadAuditLog();
|
|
} else if (tab === 'ai-usage') {
|
|
document.getElementById('aiUsagePanel').style.display = 'block';
|
|
loadAiUsage();
|
|
}
|
|
}
|
|
|
|
// --- Users ---
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const resp = await fetch(BASE_PATH + '/admin/users?include_inactive=true');
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
renderUsersTable(data.users);
|
|
populateAuditUserFilter(data.users);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load users:', err);
|
|
}
|
|
}
|
|
|
|
function renderUsersTable(users) {
|
|
const tbody = document.getElementById('usersTableBody');
|
|
if (!users.length) {
|
|
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#6b7280;">No users found</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr>
|
|
<td>${u.id}</td>
|
|
<td><strong>${escapeHtml(u.username)}</strong></td>
|
|
<td>${escapeHtml(u.email || '-')}</td>
|
|
<td><span class="badge badge-${u.role}">${u.role}</span></td>
|
|
<td>${u.auth_method || 'local'}</td>
|
|
<td>${u.last_login ? formatDate(u.last_login) : 'Never'}</td>
|
|
<td><span class="badge badge-${u.is_active ? 'active' : 'inactive'}">${u.is_active ? 'Active' : 'Inactive'}</span></td>
|
|
<td>
|
|
${u.is_active
|
|
? `<button class="btn-action danger" onclick="toggleUser(${u.id}, false)">Deactivate</button>`
|
|
: `<button class="btn-action" onclick="toggleUser(${u.id}, true)">Activate</button>`
|
|
}
|
|
<button class="btn-action" onclick="toggleRole(${u.id}, '${u.role}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function toggleUser(userId, activate) {
|
|
try {
|
|
const resp = await fetch(`${BASE_PATH}/admin/users/${userId}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({is_active: activate ? 1 : 0}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) loadUsers();
|
|
else alert(data.error || 'Failed to update user');
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function toggleRole(userId, currentRole) {
|
|
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
|
if (!confirm(`Change user role to "${newRole}"?`)) return;
|
|
try {
|
|
const resp = await fetch(`${BASE_PATH}/admin/users/${userId}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({role: newRole}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) loadUsers();
|
|
else alert(data.error || 'Failed to update role');
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function showCreateUserModal() {
|
|
document.getElementById('createUserModal').style.display = 'flex';
|
|
}
|
|
|
|
function closeCreateUserModal() {
|
|
document.getElementById('createUserModal').style.display = 'none';
|
|
document.getElementById('newUsername').value = '';
|
|
document.getElementById('newEmail').value = '';
|
|
document.getElementById('newFullName').value = '';
|
|
document.getElementById('newPassword').value = '';
|
|
document.getElementById('newRole').value = 'user';
|
|
document.getElementById('newAuthMethod').value = 'local';
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = document.getElementById('newUsername').value.trim();
|
|
if (!username) { alert('Username is required'); return; }
|
|
|
|
const payload = {
|
|
username,
|
|
email: document.getElementById('newEmail').value.trim(),
|
|
full_name: document.getElementById('newFullName').value.trim(),
|
|
password: document.getElementById('newPassword').value || null,
|
|
role: document.getElementById('newRole').value,
|
|
auth_method: document.getElementById('newAuthMethod').value,
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(BASE_PATH + '/admin/users', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
closeCreateUserModal();
|
|
loadUsers();
|
|
} else {
|
|
alert(data.error || 'Failed to create user');
|
|
}
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// --- Audit Log ---
|
|
|
|
function populateAuditUserFilter(users) {
|
|
const select = document.getElementById('auditUserFilter');
|
|
const currentVal = select.value;
|
|
select.innerHTML = '<option value="">All Users</option>';
|
|
users.forEach(u => {
|
|
select.innerHTML += `<option value="${u.id}">${escapeHtml(u.username)}</option>`;
|
|
});
|
|
select.value = currentVal;
|
|
}
|
|
|
|
async function loadAuditLog() {
|
|
const userId = document.getElementById('auditUserFilter').value;
|
|
let url = BASE_PATH + '/admin/audit?limit=200';
|
|
if (userId) url += `&user_id=${userId}`;
|
|
|
|
try {
|
|
const resp = await fetch(url);
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
renderAuditTable(data.entries);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load audit log:', err);
|
|
}
|
|
}
|
|
|
|
function renderAuditTable(entries) {
|
|
const tbody = document.getElementById('auditTableBody');
|
|
if (!entries.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#6b7280;">No audit entries</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = entries.map(e => `
|
|
<tr>
|
|
<td style="white-space:nowrap;">${formatDate(e.timestamp)}</td>
|
|
<td>${escapeHtml(e.username || 'Unknown')}</td>
|
|
<td><strong>${escapeHtml(e.action)}</strong></td>
|
|
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.details || '-')}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// --- AI Usage ---
|
|
|
|
async function loadAiUsage() {
|
|
try {
|
|
const resp = await fetch(BASE_PATH + '/admin/ai-usage');
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
renderAiStats(data.stats);
|
|
renderAiUsageTable(data.by_user);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load AI usage:', err);
|
|
}
|
|
}
|
|
|
|
function renderAiStats(stats) {
|
|
const grid = document.getElementById('aiStatsGrid');
|
|
grid.innerHTML = `
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${stats.total_requests || 0}</div>
|
|
<div class="ai-stat-label">Total Requests</div>
|
|
</div>
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${(stats.total_tokens || 0).toLocaleString()}</div>
|
|
<div class="ai-stat-label">Total Tokens</div>
|
|
</div>
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${stats.requests_24h || 0}</div>
|
|
<div class="ai-stat-label">Requests (24h)</div>
|
|
</div>
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${(stats.tokens_24h || 0).toLocaleString()}</div>
|
|
<div class="ai-stat-label">Tokens (24h)</div>
|
|
</div>
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${stats.requests_7d || 0}</div>
|
|
<div class="ai-stat-label">Requests (7d)</div>
|
|
</div>
|
|
<div class="ai-stat-card">
|
|
<div class="ai-stat-value">${(stats.tokens_7d || 0).toLocaleString()}</div>
|
|
<div class="ai-stat-label">Tokens (7d)</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAiUsageTable(byUser) {
|
|
const tbody = document.getElementById('aiUsageTableBody');
|
|
if (!byUser.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#6b7280;">No AI usage data</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = byUser.map(u => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(u.username)}</strong></td>
|
|
<td>${u.request_count}</td>
|
|
<td>${(u.total_tokens || 0).toLocaleString()}</td>
|
|
<td>${u.last_used ? formatDate(u.last_used) : '-'}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
try {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleString();
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
}
|