'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-games'];
const lockSvg = ' ';
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',
games: 'games',
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 => `${esc(s.name)} `).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 = '
Тем нет
'; return; }
el.innerHTML = '' + rows.map(t => `
#${t.order_index}
${esc(t.name)}
${t.question_count} вопр.
`).join('') + '
';
if (window.lucide) lucide.createIcons({ nodes: [el] });
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
}
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 = 'Журнал пуст
'; 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 = `
Дата Админ Действие Цель Детали IP
${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 `
${ds}
${esc(r.admin_name || '—')}
${ACTION_LABELS[r.action] || r.action}
${esc(r.target || '')}
${esc(r.detail || '')}
${esc(r.ip || '')}
`;
}).join('')}
`;
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
}
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 = 'Журнал очищен
';
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 = 'Ошибок нет
'; 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'});
return `
${r.method || ''} ${esc(r.route || '')}
${ds}
${r.user_id ? `user:${r.user_id} ` : ''}
${esc(r.message)}
${r.stack ? `
Stack trace ${esc(r.stack)} ` : ''}
`;
}).join('');
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
}
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 = 'Журнал очищен
';
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 = 'Событий нет
'; return; }
el.innerHTML = `
Время Категория Событие Пользователь / email IP Маршрут Детали
${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) ? `${esc(r.email)}
` : '';
return `
${ds}
${SEC_CAT_LABELS[r.category]||r.category}
${SEC_EVENT_LABELS[r.event]||r.event}
${who}${sub}
${esc(r.ip||'')}
${esc(r.route||'')}
${esc(r.detail||'')}
`;
}).join('')}
`;
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
}
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 = 'Журнал очищен
';
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 = `${esc(e.message)}
`; }
}
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) => ``;
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?`
`:'';
const routeRows = (arr, valFn, valLabel) => (arr&&arr.length)? arr.map(r=>`
${esc(r.route)}
${valFn(r)}
`).join('')
: `нет данных
`;
const lagP = (v)=> (v||0)>200?'var(--pink)':(v||0)>70?'#facc15':'var(--text-1)';
metricsHtml = `
Метрики запросов (с рестарта · ${fmtUp(Math.floor((m.sinceMs||0)/1000))})
${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)')}
Статусы: 2xx ${sc['2xx']||0} · 3xx ${sc['3xx']||0} · 4xx ${sc['4xx']||0} · 5xx ${sc['5xx']||0}
${seg(sc['2xx'],'#4ade80')}${seg(sc['3xx'],'#60a5fa')}${seg(sc['4xx'],'#facc15')}${seg(sc['5xx'],'#f87171')}
Самые медленные
${routeRows(m.topSlow, r=>r.avgMs.toFixed(0)+' мс')}
Самые частые
${routeRows(m.topBusy, r=>r.count.toLocaleString('ru'))}
${(m.topErrors&&m.topErrors.length)?`
Маршруты с ошибками
${routeRows(m.topErrors, r=>r.errors+' ош. / '+r.count)}
`:''}
`;
}
// ── секция трендов (Level 3) ──
let trendsHtml = '';
if (m && m.history && m.history.length) {
const tc = (id, label) => ``;
trendsHtml = `
Тренды (${m.history.length} точек · 1/мин)
${tc('trend-mem','Память RSS')}${tc('trend-req','Запросы/мин')}${tc('trend-err','Ошибки 5xx')}${tc('trend-p95','Латентность p95')}
`;
}
// ── панель диагностики (Level 4): health-чеки + последние ошибки ──
let diagHtml = '';
{
const ch = h.checks || {};
const okCol = '#4ade80', badCol = 'var(--pink)';
const chip = (label, ok, extra) => ` ${label}${extra?` ${extra} `:''}
`;
const errs = h.recentErrorList || [];
const lvlCol = l => l==='error'||l==='fatal'?'var(--pink)':l==='warn'?'#facc15':'var(--text-3)';
diagHtml = `
Диагностика
${chip('База данных', !!ch.dbOk, ch.dbPingMs!=null?ch.dbPingMs.toFixed(2)+' мс':'')}
${chip('Запись на диск', !!ch.diskWritable, ch.diskWritable?'доступна':'НЕДОСТУПНА')}
Последние ошибки
${errs.length ? errs.map(e=>`
${esc((e.created_at||'').replace('T',' ').slice(5,16))}
${esc(e.level||'')}
${e.route?`${esc(e.method||'')} ${esc(e.route)} `:''}
${esc(e.message||'')}
`).join('') : `
Ошибок нет
`}
`;
}
el.innerHTML = `
${stLabel}
${h.reasons&&h.reasons.length?h.reasons.map(esc).join(' · '):'Все показатели в пределах нормы'}
${_healthLive?'● Live':'○ Авто-обновление'}
${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)+' своб.':'—', 'Диск')}
Платформа
Версия ${esc(h.version||'?')} ${h.commit?`${esc(h.commit)} `:''}
Окружение ${esc(h.env||'?')} · PID ${h.pid||'?'}
Node.js ${esc(h.node)}
OS / арх ${esc(h.platform)} ${esc(h.arch||'')}
CPU ядра · load ${h.cpus} · ${(h.loadavg||[0]).map(x=>x.toFixed(2)).join(' / ')}
RAM rss / heap ${fmtBytes(h.memory.rss)} / ${fmtBytes(h.memory.heapUsed)}
RAM система ${fmtBytes(h.totalMem-h.freeMem)} / ${fmtBytes(h.totalMem)}
${h.disk?`Диск ${fmtBytes(h.disk.freeBytes)} своб. / ${fmtBytes(h.disk.totalBytes)} `:''}
Данные и активность
Пользователей ${h.db.totalUsers}
Онлайн (SSE) ${h.sse?h.sse.users:0} польз. · ${h.sse?h.sse.connections:0} соед.
Всего сессий ${h.db.totalSessions}
Сессий сегодня ${h.db.todaySessions}
Вопросов в базе ${h.db.totalQuestions}
WAL ${fmtBytes(h.db.walBytes||0)}
${metricsHtml}
${trendsHtml}
${diagHtml}
Крупнейшие таблицы БД
${(h.db.tables||[]).map(t=>`
${esc(t.name)}
${t.rows.toLocaleString('ru')}
`).join('')}
`;
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 = 'Нет активных уроков
';
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 `
${esc(title)}
${esc(s.teacher_name)} · ${cls}
${s.online_count}
${s.message_count}
${dur}
Завершить
`;
}).join('');
} catch(e) {
el.innerHTML = `Ошибка: ${esc(e.message)}
`;
}
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 = '
';
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 = 'Нет завершённых уроков
';
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 `
${esc(title)}
${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}
${s.participant_count} уч.
${s.message_count} сообщ.
${dur}
`;
}).join('');
if (_crOpenDetailId) {
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
if (dr) loadCrSessionDetail(_crOpenDetailId);
}
renderCrPagination();
} catch(e) {
el.innerHTML = `Ошибка: ${esc(e.message)}
`;
}
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 = '`;
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 = '
';
try {
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
const dur = fmtDuration(stats.duration_sec);
inner.innerHTML = `
${stats.participant_count}
Участников
${stats.message_count}
Сообщений
${stats.page_count}
Страниц
${attendance.length ? `
Посещаемость
${attendance.map(a => `
${esc(a.user_name)}
${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}
${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : 'онлайн ')}
`).join('')}
` : ''}
${pages.length > 1 ? `
Страницы доски
${pages.map(p => `
Стр. ${p.page_num}
${p.stroke_count} штр.
`).join('')}
` : ''}
Экспорт чата
Удалить запись
`;
} catch(e) {
inner.innerHTML = `Ошибка: ${esc(e.message)}
`;
}
}
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 = 'Загрузка...
';
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 = ' Нет заявок на модерацию
';
if (window.lucide) lucide.createIcons();
return;
}
list.innerHTML = `${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
? `
`
: initials;
const newAvatar = `
`;
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
return `
${esc(r.user_name||r.user_email)}
${esc(r.user_email)} · ${d}
Одобрить
Отклонить
`;
}).join('')}
`;
if (window.lucide) lucide.createIcons();
} catch {
list.innerHTML = 'Ошибка загрузки
';
}
}
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);
}
})();