Files
Learn_System/frontend/js/admin/admin.js
T
Maxim Dolgolyov 43df41287f feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»
Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:17:04 +03:00

1000 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// admin.html — main orchestrator (thin shell after Phase 2 section split).
// Section modules live in /js/admin/sections/*.js — admin.js wires them
// to the router + handles tabs not yet extracted (topics/audit/errors/health/classroom/avatars).
// Order of operation preserved: loads after api.js + sidebar.js + router.js + _shared.js + sections/*.js
const { user, isTeacher, isAdmin } = LS.initPage();
if (!isTeacher) { window.location.href = '/dashboard'; throw new Error(); }
document.getElementById('page-sub').textContent =
isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики';
/* Populate shared context for section modules */
AdminCtx.user = user;
AdminCtx.isTeacher = isTeacher;
AdminCtx.isAdmin = isAdmin;
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-exams','btn-tab-games','btn-tab-assistant','btn-tab-imggen'];
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
ADMIN_ONLY_TABS.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.style.display = ''; // always visible now
if (!isAdmin) {
el.classList.add('locked');
el.title = 'Только для администраторов';
el.insertAdjacentHTML('beforeend', lockSvg);
}
});
// Система group: visible to everyone too
const sysGroup = document.getElementById('admin-nav-system-group');
if (sysGroup) sysGroup.style.display = '';
/* Collapsible nav groups — state persisted in localStorage */
window.toggleAdminGroup = function (slug) {
const g = document.querySelector(`.admin-nav-group[data-ng="${slug}"]`);
if (!g) return;
const collapsed = g.classList.toggle('collapsed');
try { localStorage.setItem('ls_adm_g_' + slug, collapsed ? '1' : '0'); } catch {}
};
// Restore collapsed state on page load
document.querySelectorAll('.admin-nav-group').forEach(g => {
const slug = g.dataset.ng;
try {
if (localStorage.getItem('ls_adm_g_' + slug) === '1') g.classList.add('collapsed');
} catch {}
});
LS.showBoardIfAllowed();
LS.hideDisabledFeatures?.();
LS.notif?.init();
/* ─── Tabs → section bridge ─── */
// Routes that map 1:1 to a section module (Phase 2-extracted).
const ROUTE_TO_SECTION = {
overview: 'overview',
questions: 'questions',
tests: 'tests',
assignments: 'assignments',
subjects: 'subjects',
users: 'users',
sessions: 'sessions',
permissions: 'permissions',
shop: 'shop',
gam: 'gam',
tpl: 'tpl',
sims: 'sims',
exams: 'exams',
games: 'games',
assistant: 'assistant',
imggen: 'imggen',
sublog: 'sublog',
access: 'access',
};
/* Phase 6: deep entity pages. When a route has a first param (#users/123),
* dispatch to the matching detail section instead of the list section.
* Detail sections render into hidden tab-panes (#tab-user-detail / #tab-session-detail)
* which are activated by activateDeepPane() below. The "parent" nav item
* (Пользователи / Тесты) stays highlighted so users know where they are. */
const DEEP_ROUTES = {
users: { section: 'user-detail', paneId: 'tab-user-detail', parentTab: 'users' },
sessions: { section: 'session-detail', paneId: 'tab-session-detail', parentTab: 'sessions' },
};
function activateDeepPane(deepInfo, params) {
// Activate the parent nav item visually (so user knows the section),
// but show the deep-page pane instead of the list pane.
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
const parentBtn = document.querySelector('.admin-nav-item[data-tab="' + deepInfo.parentTab + '"]');
if (parentBtn) parentBtn.classList.add('active');
const pane = document.getElementById(deepInfo.paneId);
if (pane) pane.classList.add('active');
const sec = AdminSections[deepInfo.section];
if (sec && typeof sec.init === 'function') {
// params: [id, subTab?]
sec.init(params[0], params[1]);
}
}
function switchTab(btn, opts) {
if (btn.classList.contains('locked')) {
LS.toast('Этот раздел доступен только администраторам', 'warn');
return;
}
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
const name = btn.dataset.tab;
document.getElementById('tab-' + name).classList.add('active');
btn.classList.add('active');
// Dispatch to section module for the 13 Phase-2 tabs
const secName = ROUTE_TO_SECTION[name];
if (secName && AdminSections[secName]) {
AdminSections[secName].init();
}
// System tabs (not yet extracted in Phase 2) — load inline
if (name === 'topics') loadTopics();
else if (name === 'audit') loadAuditLog();
else if (name === 'security') loadSecurityLog();
else if (name === 'errors') loadErrorLog();
else if (name === 'health') loadHealth();
else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
else if (name === 'avatars') loadAvatarRequests();
// Sync URL hash via router (silent so we don't recurse).
if (!(opts && opts.fromRouter) && window.AdminRouter) {
AdminRouter.navigate('#' + name, { silent: true });
}
}
window.switchTab = switchTab;
/* Cross-section orchestrator: navigate to Questions tab + open Q-modal pre-filled */
async function goAddQuestion(slug) {
const qBtn = document.querySelector('[data-tab="questions"]');
switchTab(qBtn);
document.getElementById('q-subject').value = slug;
await AdminSections.questions.reload();
AdminSections.questions.openModal();
document.getElementById('qf-subject').value = slug;
await AdminSections.questions.loadModalTopics();
}
window.goAddQuestion = goAddQuestion;
/* ═══ TOPICS ═══════════════════════════════════════════════════════ */
let _topicsSubjects = [];
async function loadTopicSubjects() {
if (_topicsSubjects.length) return;
try {
_topicsSubjects = await LS.getSubjects();
const sel = document.getElementById('topics-subj-filter');
sel.innerHTML = _topicsSubjects.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
} catch {}
}
async function loadTopics() {
await loadTopicSubjects();
const subjId = document.getElementById('topics-subj-filter').value;
const el = document.getElementById('topics-list');
el.innerHTML = LS.skeleton(4, 'row');
try {
const rows = await LS.api(`/api/admin/topics?subject_id=${subjId}`);
document.getElementById('topics-count').textContent = rows.length + ' тем';
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Тем нет</div>'; return; }
el.innerHTML = '<div style="display:flex;flex-direction:column;gap:6px">' + rows.map(t => `
<div class="adm-panel" style="padding:12px 18px;margin:0;display:flex;align-items:center;gap:14px">
<span style="font-size:0.75rem;color:var(--text-3);font-weight:700;min-width:28px">#${t.order_index}</span>
<span style="flex:1;font-weight:600">${esc(t.name)}</span>
<span style="font-size:0.78rem;color:var(--text-3)">${t.question_count} вопр.</span>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-2);padding:5px 12px" onclick="renameTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}')">
<i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i>
</button>
<button class="adm-btn adm-btn-small" style="background:rgba(241,91,181,0.1);color:var(--pink);padding:5px 12px" onclick="deleteTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}',${t.question_count})">
<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:-1px"></i>
</button>
</div>`).join('') + '</div>';
if (window.lucide) lucide.createIcons({ nodes: [el] });
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
function showAddTopic() { document.getElementById('topics-add-row').style.display = ''; document.getElementById('topics-new-name').focus(); }
async function createTopic() {
const name = document.getElementById('topics-new-name').value.trim();
if (!name) return;
const subjId = document.getElementById('topics-subj-filter').value;
try {
await LS.api('/api/admin/topics', { method:'POST', body: JSON.stringify({ subject_id: subjId, name }) });
document.getElementById('topics-new-name').value = '';
document.getElementById('topics-add-row').style.display = 'none';
LS.toast('Тема создана', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
async function renameTopic(id, oldName) {
const name = prompt('Новое название темы:', oldName);
if (!name || name === oldName) return;
try {
await LS.api(`/api/admin/topics/${id}`, { method:'PATCH', body: JSON.stringify({ name }) });
LS.toast('Тема переименована', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
async function deleteTopic(id, name, qcount) {
if (qcount > 0) { LS.toast(`Нельзя удалить тему с ${qcount} вопросами`, 'warn'); return; }
if (!await LS.confirm(`Удалить тему "${name}"?`, { danger: true })) return;
try {
await LS.api(`/api/admin/topics/${id}`, { method:'DELETE' });
LS.toast('Тема удалена', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
window.showAddTopic = showAddTopic;
window.createTopic = createTopic;
window.renameTopic = renameTopic;
window.deleteTopic = deleteTopic;
/* ═══ BROADCAST ═════════════════════════════════════════════════════ */
async function sendBroadcast() {
const message = document.getElementById('bc-message').value.trim();
if (!message) { LS.toast('Введите сообщение', 'warn'); return; }
const role = document.getElementById('bc-role').value;
const link = document.getElementById('bc-link').value.trim() || null;
try {
const r = await LS.api('/api/admin/broadcast', { method:'POST', body: JSON.stringify({ message, role, link }) });
document.getElementById('bc-result').textContent = `Отправлено ${r.sent} пользователям`;
document.getElementById('bc-message').value = '';
LS.toast(`Уведомление отправлено ${r.sent} пользователям`, 'success');
} catch (e) { LS.toast(e.message, 'error'); }
}
window.sendBroadcast = sendBroadcast;
/* ═══ AUDIT LOG ════════════════════════════════════════════════════ */
async function loadAuditLog() {
const el = document.getElementById('audit-list');
el.innerHTML = LS.skeleton(5, 'row');
try {
const rows = await LS.api('/api/admin/audit-log?limit=200');
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал пуст</div>'; return; }
const ACTION_LABELS = {
'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка',
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
'access.grant': 'Доступ открыт', 'access.deny': 'Доступ закрыт', 'access.inherit': 'Доступ сброшен',
'gam.award': 'Начисление XP/монет', 'gam.reset': 'Сброс прогресса',
'avatar.approve': 'Аватар одобрен', 'avatar.reject': 'Аватар отклонён',
};
const ACTION_COLORS = {
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
'gam.reset': 'var(--pink)', 'avatar.reject': 'var(--amber)', 'access.deny': 'var(--amber)',
'access.grant': 'var(--green)', 'gam.award': 'var(--green)', 'avatar.approve': 'var(--green)',
};
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
<tbody>${rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
const acol = ACTION_COLORS[r.action] || 'var(--violet)';
return `<tr>
<td><span class="sl-date">${ds}</span></td>
<td>${esc(r.admin_name || '—')}</td>
<td><span style="color:${acol};font-weight:700;font-size:0.82rem">${ACTION_LABELS[r.action] || r.action}</span></td>
<td style="font-size:0.82rem;color:var(--text-3)">${esc(r.target || '')}</td>
<td style="font-size:0.82rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail || '')}">${esc(r.detail || '')}</td>
<td style="font-size:0.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip || '')}</td>
</tr>`;
}).join('')}</tbody></table></div>`;
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
async function clearAuditLog() {
if (!await LS.confirm('Очистить весь аудит-лог?', { danger: true })) return;
try {
await LS.api('/api/admin/audit-log', { method:'DELETE' });
document.getElementById('audit-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.clearAuditLog = clearAuditLog;
/* ═══ ERROR LOG ════════════════════════════════════════════════════ */
async function loadErrorLog() {
const el = document.getElementById('errors-list');
el.innerHTML = LS.skeleton(3, 'row');
try {
const rows = await LS.api('/api/admin/error-log?limit=200');
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3);font-size:0.88rem">Ошибок нет</div>'; return; }
el.innerHTML = rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
const isClient = r.level === 'client';
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
const badge = isClient
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
: '';
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
${badge}
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
</div>
<div style="font-size:0.88rem;font-weight:600;color:var(--text);margin-bottom:4px">${esc(r.message)}</div>
${r.stack ? `<details><summary style="font-size:0.75rem;color:var(--text-3);cursor:pointer">Stack trace</summary><pre style="font-size:0.72rem;color:var(--text-3);white-space:pre-wrap;max-height:200px;overflow:auto;margin-top:6px;padding:8px;background:rgba(0,0,0,0.02);border-radius:8px">${esc(r.stack)}</pre></details>` : ''}
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
async function clearErrorLog() {
if (!await LS.confirm('Очистить журнал ошибок?', { danger: true })) return;
try {
await LS.api('/api/admin/error-log', { method:'DELETE' });
document.getElementById('errors-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.clearErrorLog = clearErrorLog;
/* ═══ SECURITY EVENT LOG ═══════════════════════════════════════════ */
let _secDebTimer = null;
function secSearchDebounce() {
clearTimeout(_secDebTimer);
_secDebTimer = setTimeout(loadSecurityLog, 300);
}
window.secSearchDebounce = secSearchDebounce;
const SEC_EVENT_LABELS = {
'login.success': 'Успешный вход',
'login.fail': 'Неудачный вход',
'register': 'Регистрация',
'password.change': 'Смена пароля',
'forbidden': 'Отказ по роли',
'perm_denied': 'Нет разрешения',
'rate_limited': 'Превышен лимит',
};
const SEC_CAT_LABELS = { auth: 'Вход', access_denied: 'Доступ', rate_limit: 'Лимит' };
// Цвет по тяжести: успех/регистрация — зелёный, лимит — янтарь, остальное (фейл/отказ) — розовый.
function secColor(ev, cat) {
if (ev === 'login.success' || ev === 'register') return 'var(--green)';
if (cat === 'rate_limit') return 'var(--amber)';
return 'var(--pink)';
}
async function loadSecurityLog() {
const el = document.getElementById('security-list');
const countEl = document.getElementById('sec-count');
el.innerHTML = LS.skeleton(5, 'row');
try {
const cat = document.getElementById('sec-cat-filter')?.value || '';
const q = (document.getElementById('sec-search')?.value || '').trim();
const params = new URLSearchParams({ limit: 300 });
if (cat) params.set('category', cat);
if (q) params.set('q', q);
const rows = await LS.api('/api/admin/security-log?' + params);
if (countEl) countEl.textContent = rows.length ? `${rows.length} событий` : '';
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Событий нет</div>'; return; }
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
<thead><tr><th>Время</th><th>Категория</th><th>Событие</th><th>Пользователь / email</th><th>IP</th><th>Маршрут</th><th>Детали</th></tr></thead>
<tbody>${rows.map(r => {
const raw = r.created_at || '';
const dt = new Date(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
const ds = isNaN(dt) ? esc(raw) : dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
const col = secColor(r.event, r.category);
const who = r.user_name ? esc(r.user_name) : (r.email ? esc(r.email) : (r.user_email ? esc(r.user_email) : '—'));
const sub = (r.user_name && r.email && r.email !== r.user_email) ? `<div style="font-size:.72rem;color:var(--text-3)">${esc(r.email)}</div>` : '';
return `<tr>
<td><span class="sl-date">${ds}</span></td>
<td><span class="sl-role-badge" style="background:rgba(155,93,229,.08);color:var(--violet)">${SEC_CAT_LABELS[r.category]||r.category}</span></td>
<td><span style="color:${col};font-weight:700;font-size:.82rem">${SEC_EVENT_LABELS[r.event]||r.event}</span></td>
<td>${who}${sub}</td>
<td style="font-size:.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip||'')}</td>
<td style="font-size:.78rem;color:var(--text-3);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc((r.method||'')+' '+(r.route||''))}">${esc(r.route||'')}</td>
<td style="font-size:.8rem;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail||'')}">${esc(r.detail||'')}</td>
</tr>`;
}).join('')}</tbody></table></div>`;
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
window.loadSecurityLog = loadSecurityLog;
async function clearSecurityLog() {
if (!await LS.confirm('Очистить журнал событий безопасности?', { danger: true })) return;
try {
await LS.api('/api/admin/security-log', { method:'DELETE' });
document.getElementById('security-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
const c = document.getElementById('sec-count'); if (c) c.textContent = '';
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.clearSecurityLog = clearSecurityLog;
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
let _healthLive = false, _healthTimer = null;
async function loadHealth() {
const el = document.getElementById('health-content');
el.innerHTML = LS.skeleton(3, 'row');
await refreshHealth();
setupHealthTimer();
}
function setupHealthTimer() {
if (_healthTimer) { clearInterval(_healthTimer); _healthTimer = null; }
if (_healthLive) {
_healthTimer = setInterval(() => {
const el = document.getElementById('health-content');
if (!el || !el.offsetParent) { clearInterval(_healthTimer); _healthTimer = null; return; }
refreshHealth();
}, 5000);
}
}
async function refreshHealth() {
try {
const [h, m] = await Promise.all([
LS.api('/api/admin/health'),
LS.api('/api/admin/metrics').catch(() => null),
]);
renderHealth(h, m);
} catch (e) { const el = document.getElementById('health-content'); if (el) el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
function renderHealth(h, m) {
const el = document.getElementById('health-content');
if (!el) return;
const fmtBytes = b => !b ? '0' : b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), mm=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${mm}m`:`${mm}m`; };
const stColor = h.status==='critical'?'var(--pink)':h.status==='warning'?'#facc15':'var(--green)';
const stLabel = h.status==='critical'?'Критическое состояние':h.status==='warning'?'Требует внимания':'Всё в норме';
const memPct = Math.round((h.memPercent||0)*100);
const memCol = memPct>92?'var(--pink)':memPct>80?'#facc15':'var(--green)';
const lag = h.eventLoopLagMs||0, lagCol = lag>200?'var(--pink)':lag>70?'#facc15':'var(--green)';
const card = (val, label, col) => `<div class="adm-panel" style="padding:16px;margin:0;text-align:center">
<div style="font-size:1.25rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${col||'var(--text-1)'}">${val}</div>
<div style="font-size:0.68rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">${label}</div></div>`;
const maxRows = Math.max(1, ...(h.db.tables||[]).map(t=>t.rows));
// ── секция метрик запросов (Level 2) ──
let metricsHtml = '';
if (m) {
const sc = m.statusClasses||{}, tot = Math.max(1, (sc['2xx']||0)+(sc['3xx']||0)+(sc['4xx']||0)+(sc['5xx']||0));
const seg = (n,col)=> n>0?`<div style="width:${(n/tot*100).toFixed(1)}%;background:${col}" title="${n}"></div>`:'';
const routeRows = (arr, valFn, valLabel) => (arr&&arr.length)? arr.map(r=>`<div style="display:flex;align-items:center;gap:8px;margin:3px 0;font-size:.78rem">
<div style="flex:1;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.route)}</div>
<div style="width:90px;text-align:right;font-weight:600;color:var(--text-3)">${valFn(r)}</div></div>`).join('')
: `<div style="color:var(--text-3);font-size:.78rem">нет данных</div>`;
const lagP = (v)=> (v||0)>200?'var(--pink)':(v||0)>70?'#facc15':'var(--text-1)';
metricsHtml = `
<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Метрики запросов <span style="color:var(--text-3);font-weight:400;font-size:.78rem">(с рестарта · ${fmtUp(Math.floor((m.sinceMs||0)/1000))})</span></div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:10px;margin:8px 0 14px">
${card(m.reqPerMin, 'Req/min', 'var(--green)')}
${card((m.total||0).toLocaleString('ru'), 'Всего')}
${card((m.avgMs||0).toFixed(0)+' мс', 'Средн.')}
${card((m.p95||0).toFixed(0)+' мс', 'p95', lagP(m.p95))}
${card((m.p99||0).toFixed(0)+' мс', 'p99', lagP(m.p99))}
${card(sc['5xx']||0, '5xx', (sc['5xx']||0)>0?'var(--pink)':'var(--green)')}
</div>
<div style="font-size:.72rem;color:var(--text-3);margin-bottom:5px">Статусы: 2xx ${sc['2xx']||0} · 3xx ${sc['3xx']||0} · 4xx ${sc['4xx']||0} · 5xx ${sc['5xx']||0}</div>
<div style="display:flex;height:9px;border-radius:5px;overflow:hidden;background:rgba(255,255,255,.06);margin-bottom:14px">
${seg(sc['2xx'],'#4ade80')}${seg(sc['3xx'],'#60a5fa')}${seg(sc['4xx'],'#facc15')}${seg(sc['5xx'],'#f87171')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div><div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bottom:4px">Самые медленные</div>${routeRows(m.topSlow, r=>r.avgMs.toFixed(0)+' мс')}</div>
<div><div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bottom:4px">Самые частые</div>${routeRows(m.topBusy, r=>r.count.toLocaleString('ru'))}</div>
</div>
${(m.topErrors&&m.topErrors.length)?`<div style="margin-top:12px"><div style="font-size:.72rem;color:var(--pink);font-weight:700;text-transform:uppercase;margin-bottom:4px">Маршруты с ошибками</div>${routeRows(m.topErrors, r=>r.errors+' ош. / '+r.count)}</div>`:''}
</div>`;
}
// ── секция трендов (Level 3) ──
let trendsHtml = '';
if (m && m.history && m.history.length) {
const tc = (id, label) => `<div><div style="font-size:.74rem;color:var(--text-3);margin-bottom:3px">${label}</div><canvas id="${id}" height="64" style="width:100%;height:64px;display:block"></canvas></div>`;
trendsHtml = `<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Тренды <span style="color:var(--text-3);font-weight:400;font-size:.78rem">(${m.history.length} точек · 1/мин)</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:8px">
${tc('trend-mem','Память RSS')}${tc('trend-req','Запросы/мин')}${tc('trend-err','Ошибки 5xx')}${tc('trend-p95','Латентность p95')}
</div></div>`;
}
// ── панель диагностики (Level 4): health-чеки + последние ошибки ──
let diagHtml = '';
{
const ch = h.checks || {};
const okCol = '#4ade80', badCol = 'var(--pink)';
const chip = (label, ok, extra) => `<div style="display:flex;align-items:center;gap:6px;font-size:.82rem"><span style="width:9px;height:9px;border-radius:50%;background:${ok?okCol:badCol}"></span>${label}${extra?` <span style="color:var(--text-3)">${extra}</span>`:''}</div>`;
const errs = h.recentErrorList || [];
const lvlCol = l => l==='error'||l==='fatal'?'var(--pink)':l==='warn'?'#facc15':'var(--text-3)';
diagHtml = `<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Диагностика</div>
<div style="display:flex;gap:20px;flex-wrap:wrap;margin:6px 0 14px">
${chip('База данных', !!ch.dbOk, ch.dbPingMs!=null?ch.dbPingMs.toFixed(2)+' мс':'')}
${chip('Запись на диск', !!ch.diskWritable, ch.diskWritable?'доступна':'НЕДОСТУПНА')}
</div>
<div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bottom:5px">Последние ошибки</div>
${errs.length ? errs.map(e=>`<div style="display:flex;align-items:baseline;gap:8px;font-size:.78rem;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.04)">
<span style="color:var(--text-3);white-space:nowrap;font-size:.72rem">${esc((e.created_at||'').replace('T',' ').slice(5,16))}</span>
<span style="color:${lvlCol(e.level)};font-weight:700;white-space:nowrap">${esc(e.level||'')}</span>
${e.route?`<span style="color:var(--text-3);white-space:nowrap">${esc(e.method||'')} ${esc(e.route)}</span>`:''}
<span style="flex:1;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(e.message||'')}</span>
</div>`).join('') : `<div style="color:var(--green);font-size:.82rem">Ошибок нет</div>`}
</div>`;
}
el.innerHTML = `
<div class="adm-panel" style="margin:0 0 16px;padding:14px 18px;display:flex;align-items:center;gap:14px;border-left:4px solid ${stColor}">
<div style="width:13px;height:13px;border-radius:50%;background:${stColor};box-shadow:0 0 12px ${stColor};flex-shrink:0"></div>
<div style="flex:1;min-width:0">
<div style="font-weight:800;font-family:'Unbounded',sans-serif;color:${stColor}">${stLabel}</div>
<div style="font-size:.78rem;color:var(--text-3);margin-top:2px">${h.reasons&&h.reasons.length?h.reasons.map(esc).join(' · '):'Все показатели в пределах нормы'}</div>
</div>
<button id="health-live-btn" style="height:30px;padding:0 14px;border-radius:8px;border:1.5px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:${_healthLive?'var(--green)':'var(--text-3)'};font-weight:700;font-size:.78rem;cursor:pointer;white-space:nowrap">${_healthLive?'● Live':'○ Авто-обновление'}</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:18px">
${card(fmtUp(h.uptime), 'Uptime', 'var(--green)')}
${card(fmtBytes(h.db.sizeBytes), 'База данных', 'var(--violet)')}
${card(fmtBytes(h.uploads.sizeBytes), 'Файлы')}
${card(h.recentErrors, 'Ошибок 24ч', h.recentErrors>0?'var(--pink)':'var(--green)')}
${card((h.sse?h.sse.connections:0), 'SSE онлайн', 'var(--green)')}
${card(memPct+'%', 'Память', memCol)}
${card(lag.toFixed(0)+' мс', 'Event-loop', lagCol)}
${card(h.disk?fmtBytes(h.disk.freeBytes)+' своб.':'—', 'Диск')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div class="adm-panel" style="margin:0">
<div class="adm-panel-title">Платформа</div>
<table style="width:100%;font-size:0.86rem">
<tr><td style="color:var(--text-3);padding:3px 0">Версия</td><td style="font-weight:600">${esc(h.version||'?')} ${h.commit?`<span style="color:var(--text-3);font-family:monospace">${esc(h.commit)}</span>`:''}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Окружение</td><td style="font-weight:600">${esc(h.env||'?')} · PID ${h.pid||'?'}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Node.js</td><td style="font-weight:600">${esc(h.node)}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">OS / арх</td><td style="font-weight:600">${esc(h.platform)} ${esc(h.arch||'')}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">CPU ядра · load</td><td style="font-weight:600">${h.cpus} · ${(h.loadavg||[0]).map(x=>x.toFixed(2)).join(' / ')}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">RAM rss / heap</td><td style="font-weight:600">${fmtBytes(h.memory.rss)} / ${fmtBytes(h.memory.heapUsed)}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">RAM система</td><td style="font-weight:600">${fmtBytes(h.totalMem-h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
${h.disk?`<tr><td style="color:var(--text-3);padding:3px 0">Диск</td><td style="font-weight:600">${fmtBytes(h.disk.freeBytes)} своб. / ${fmtBytes(h.disk.totalBytes)}</td></tr>`:''}
</table>
</div>
<div class="adm-panel" style="margin:0">
<div class="adm-panel-title">Данные и активность</div>
<table style="width:100%;font-size:0.86rem">
<tr><td style="color:var(--text-3);padding:3px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Онлайн (SSE)</td><td style="font-weight:600;color:var(--green)">${h.sse?h.sse.users:0} польз. · ${h.sse?h.sse.connections:0} соед.</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
<tr><td style="color:var(--text-3);padding:3px 0">WAL</td><td style="font-weight:600">${fmtBytes(h.db.walBytes||0)}</td></tr>
</table>
</div>
</div>
${metricsHtml}
${trendsHtml}
${diagHtml}
<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Крупнейшие таблицы БД</div>
${(h.db.tables||[]).map(t=>`<div style="display:flex;align-items:center;gap:10px;margin:4px 0">
<div style="width:170px;font-size:.8rem;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.name)}</div>
<div style="flex:1;height:7px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden"><div style="height:100%;width:${Math.round(t.rows/maxRows*100)}%;background:var(--violet);border-radius:4px"></div></div>
<div style="width:60px;text-align:right;font-size:.78rem;font-weight:600">${t.rows.toLocaleString('ru')}</div>
</div>`).join('')}
</div>`;
if (m && m.history && m.history.length) {
const draw = (id, key, color, fmt) => {
const c = document.getElementById(id); if (!c) return;
c.width = c.offsetWidth || 300;
drawTrend(c, m.history, key, color, fmt);
};
draw('trend-mem', 'rss', '#9B5DE5', v => (v/1e6).toFixed(0)+' МБ');
draw('trend-req', 'reqPerMin', '#34d399');
draw('trend-err', 'err5xx', '#f87171');
draw('trend-p95', 'p95', '#facc15', v => v.toFixed(0)+' мс');
}
const btn = document.getElementById('health-live-btn');
if (btn) btn.addEventListener('click', () => {
_healthLive = !_healthLive;
btn.textContent = _healthLive ? '● Live' : '○ Авто-обновление';
btn.style.color = _healthLive ? 'var(--green)' : 'var(--text-3)';
setupHealthTimer();
});
}
// Мини-график тренда (canvas): линия + заливка + подписи макс/последнее.
function drawTrend(canvas, points, key, color, fmt) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const vals = points.map(p => p[key] || 0);
if (vals.length < 2) {
ctx.fillStyle = '#555'; ctx.font = '11px Manrope,sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('накопление данных…', W/2, H/2); return;
}
let max = Math.max(...vals), min = Math.min(...vals);
if (max === min) max += 1;
const pad = 4, range = (max - min) || 1;
const X = i => pad + i/(vals.length-1)*(W-pad*2);
const Y = v => H-pad - (v-min)/range*(H-pad*2-10);
ctx.beginPath(); ctx.moveTo(X(0), H);
vals.forEach((v,i)=>ctx.lineTo(X(i), Y(v)));
ctx.lineTo(X(vals.length-1), H); ctx.closePath();
ctx.fillStyle = color + '22'; ctx.fill();
ctx.beginPath(); vals.forEach((v,i)=> i?ctx.lineTo(X(i),Y(v)):ctx.moveTo(X(i),Y(v)));
ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.stroke();
const lastV = vals[vals.length-1];
ctx.fillStyle = color; ctx.beginPath(); ctx.arc(X(vals.length-1), Y(lastV), 2.5, 0, Math.PI*2); ctx.fill();
ctx.font = '10px Manrope,sans-serif'; ctx.textBaseline = 'top';
ctx.fillStyle = '#888'; ctx.textAlign = 'left'; ctx.fillText('макс ' + (fmt?fmt(max):max), pad, 1);
ctx.fillStyle = color; ctx.textAlign = 'right'; ctx.fillText(fmt?fmt(lastV):lastV, W-pad, 1);
}
/* ════════════════════════════════════════════════
ОНЛАЙН-УРОКИ (classroom admin)
════════════════════════════════════════════════ */
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
let _crOpenDetailId = null, _crHistDebTimer = null;
async function loadCrModuleState() {
try {
const features = await LS.api('/api/admin/features');
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = features.classroom !== false;
} catch(e) { /* silent */ }
}
async function crMasterToggle(enabled) {
try {
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
} catch(e) {
LS.toast('Ошибка: ' + e.message, 'error');
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = !enabled;
}
}
window.crMasterToggle = crMasterToggle;
function fmtLiveDuration(createdAt) {
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
return AdminCtx.fmtDuration(sec);
}
async function loadCrActiveSessions() {
const el = document.getElementById('cr-live-list');
try {
const { sessions } = await LS.api('/api/classroom/admin/active');
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
return;
}
el.innerHTML = sessions.map(s => {
const dur = fmtLiveDuration(s.created_at);
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
return `<div class="cr-live-card">
<div class="cr-live-pulse"></div>
<div class="cr-live-info">
<div class="cr-live-title">${esc(title)}</div>
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
</div>
<div class="cr-live-badges">
<span class="cr-badge cr-badge-online">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
${s.online_count}
</span>
<span class="cr-badge cr-badge-msgs">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
${s.message_count}
</span>
<span class="cr-badge cr-badge-dur">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${dur}
</span>
</div>
<div class="cr-live-actions">
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
async function adminEndSession(id) {
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
try {
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
LS.toast('Урок завершён', 'success', 2500);
loadCrActiveSessions();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.adminEndSession = adminEndSession;
function crHistDebounce() {
clearTimeout(_crHistDebTimer);
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
}
window.crHistDebounce = crHistDebounce;
async function loadCrHistory(page) {
const { fmtDate, fmtDuration } = AdminCtx;
if (page) _crHistPage = page;
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
const el = document.getElementById('cr-hist-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
if (_crHistSearch) params.set('search', _crHistSearch);
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
_crHistTotal = total; _crHistPages = pages;
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
renderCrPagination();
return;
}
el.innerHTML = sessions.map(s => {
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
return `<div>
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
<div class="cr-hist-icon">
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-hist-main">
<div class="cr-hist-title">${esc(title)}</div>
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
</div>
<div class="cr-hist-chips">
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
<span class="cr-badge cr-badge-dur">${dur}</span>
</div>
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
<div class="spinner"></div>
</div>
</div>
</div>`;
}).join('');
if (_crOpenDetailId) {
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
if (dr) loadCrSessionDetail(_crOpenDetailId);
}
renderCrPagination();
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
window.loadCrHistory = loadCrHistory;
function renderCrPagination() {
const el = document.getElementById('cr-hist-pagination');
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
const p = _crHistPage, total = _crHistPages;
let html = '<div class="cr-pagination">';
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
</button>`;
const range = [];
for (let i=1;i<=total;i++) {
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
else if (range[range.length-1]!=='…') range.push('…');
}
range.forEach(r => {
if (r==='…') html += `<span class="cr-page-info">…</span>`;
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
});
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
</button></div>`;
el.innerHTML = html;
}
async function toggleCrDetail(id, rowEl) {
const wasOpen = _crOpenDetailId === id;
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
_crOpenDetailId = null;
if (wasOpen) return;
rowEl.classList.add('open');
const dr = document.getElementById(`cr-detail-${id}`);
if (dr) { dr.classList.add('open'); }
_crOpenDetailId = id;
await loadCrSessionDetail(id);
}
window.toggleCrDetail = toggleCrDetail;
async function loadCrSessionDetail(id) {
const { fmtDuration } = AdminCtx;
const inner = document.getElementById(`cr-detail-inner-${id}`);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
const dur = fmtDuration(stats.duration_sec);
inner.innerHTML = `
<div class="cr-detail-grid">
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
</div>
${attendance.length ? `
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
<div class="cr-attend-list">
${attendance.map(a => `
<div class="cr-attend-row">
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span class="cr-attend-name">${esc(a.user_name)}</span>
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
</div>
`).join('')}
</div>
` : ''}
${pages.length > 1 ? `
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
<div class="cr-pages-list">
${pages.map(p => `
<div class="cr-page-chip">
<span class="cr-page-num">Стр. ${p.page_num}</span>
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
</div>
`).join('')}
</div>
` : ''}
<div class="cr-detail-actions">
<button class="btn-cr-export" onclick="adminExportChat(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Экспорт чата
</button>
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Удалить запись
</button>
</div>`;
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function adminExportChat(id) {
window.open(`/api/classroom/${id}/chat/export`, '_blank');
}
window.adminExportChat = adminExportChat;
async function adminDeleteSession(id) {
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
try {
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
LS.toast('Урок удалён', 'success', 2500);
_crOpenDetailId = null;
loadCrHistory();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.adminDeleteSession = adminDeleteSession;
/* ── Avatar moderation ─────────────────────────────────────────────── */
async function loadAvatarRequests() {
const list = document.getElementById('av-list');
list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>';
try {
const rows = await LS.get('/api/avatar/pending');
const badge = document.getElementById('av-badge');
if (rows.length) {
badge.textContent = rows.length;
badge.style.display = 'inline-flex';
} else {
badge.style.display = 'none';
}
if (!rows.length) {
list.innerHTML = '<div class="av-empty"><i data-lucide="check-circle" style="width:36px;height:36px;opacity:.3;display:block;margin:0 auto 10px"></i>Нет заявок на модерацию</div>';
if (window.lucide) lucide.createIcons();
return;
}
list.innerHTML = `<div class="av-grid">${rows.map(r => {
const initials = (r.user_name||'LS').split(' ').slice(0,2).map(w=>(w[0]||'').toUpperCase()).join('') || 'LS';
const curAvatar = r.current_avatar
? `<img src="/avatars/${esc(r.current_avatar)}" alt="">`
: initials;
const newAvatar = `<img src="/avatars/${esc(r.filename)}" alt="" onerror="this.parentElement.textContent='?'">`;
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
return `<div class="av-card" id="av-card-${r.id}">
<div class="av-card-top">
<div class="av-imgs">
<div class="av-img-wrap">
<span>Сейчас</span>
<div class="av-img">${curAvatar}</div>
</div>
<svg class="av-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<div class="av-img-wrap">
<span>Новый</span>
<div class="av-img">${newAvatar}</div>
</div>
</div>
</div>
<div>
<div class="av-user-name">${esc(r.user_name||r.user_email)}</div>
<div class="av-date">${esc(r.user_email)} · ${d}</div>
</div>
<div class="av-actions">
<button class="av-approve" onclick="avatarApprove(${r.id})">Одобрить</button>
<button class="av-reject" onclick="avatarRejectPrompt(${r.id})">Отклонить</button>
</div>
</div>`;
}).join('')}</div>`;
if (window.lucide) lucide.createIcons();
} catch {
list.innerHTML = '<div class="av-empty">Ошибка загрузки</div>';
}
}
async function avatarApprove(id) {
const card = document.getElementById('av-card-' + id);
if (card) card.style.opacity = '0.5';
try {
await LS.post('/api/avatar/' + id + '/approve', {});
LS.toast('Аватар одобрен', 'success');
loadAvatarRequests();
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
}
window.avatarApprove = avatarApprove;
function avatarRejectPrompt(id) {
const reason = prompt('Причина отклонения (необязательно):') ?? null;
if (reason === null) return;
avatarReject(id, reason);
}
window.avatarRejectPrompt = avatarRejectPrompt;
async function avatarReject(id, reason) {
const card = document.getElementById('av-card-' + id);
if (card) card.style.opacity = '0.5';
try {
await LS.patch('/api/avatar/' + id + '/reject', { reason });
LS.toast('Аватар отклонён', 'info');
loadAvatarRequests();
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
}
window.avatarReject = avatarReject;
/* ─── init ─── */
// Initial #overview tab is .active in markup — section module will lazy-load on first switchTab.
AdminSections.overview.init();
loadAvatarRequests(); // load badge count on page open
if (window.lucide) lucide.createIcons();
/* ─── Hash router wiring ─── */
(function initAdminRouter() {
if (!window.AdminRouter) return;
function activate(route, params) {
const name = route || 'overview';
params = Array.isArray(params) ? params : [];
// Phase 6: deep page dispatch when route has a first param.
const deep = DEEP_ROUTES[name];
if (deep && params.length > 0 && AdminSections[deep.section]) {
activateDeepPane(deep, params);
return;
}
const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]');
if (!btn) {
console.warn('AdminRouter: unknown route', name);
AdminRouter.navigate('#overview', { replace: true, silent: true });
const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true });
return;
}
if (btn.classList.contains('locked')) {
LS.toast('Этот раздел доступен только администраторам', 'warn');
AdminRouter.navigate('#overview', { replace: true, silent: true });
const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true });
return;
}
switchTab(btn, { fromRouter: true });
}
AdminRouter.on('change', (r) => activate(r.route, r.params));
// Initial dispatch: respect existing hash, else default to #overview.
const initial = AdminRouter.current();
if (!initial.route) {
AdminRouter.navigate('#overview', { replace: true, silent: true });
} else if (initial.route !== 'overview' || initial.params.length > 0) {
activate(initial.route, initial.params);
}
})();