43df41287f
Глобальный репортер в 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>
1000 lines
57 KiB
JavaScript
1000 lines
57 KiB
JavaScript
'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);
|
||
}
|
||
})();
|