diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 1c71967..3e58d4e 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -30,6 +30,58 @@ function getStats(_req, res) { }); } +/* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */ +const overviewStmts = { + newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"), + newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"), + activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"), + failedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status != 'completed'"), + activeClasses: db.prepare('SELECT COUNT(*) AS n FROM classes'), + // No banned_at column — fall back to audit log for recent bans (last 7 days) + bannedThisWeek: db.prepare(` + SELECT u.id, u.name, u.email, al.created_at AS banned_at + FROM admin_audit_log al + JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER) + WHERE al.action = 'user.ban' + AND al.created_at >= datetime('now', '-7 days') + AND u.is_banned = 1 + GROUP BY u.id + ORDER BY al.created_at DESC + LIMIT 10 + `), + topSessions24h: db.prepare(` + SELECT ts.id, u.name AS user_name, s.name AS subject_name, + ts.score, ts.total, + ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent, + ts.finished_at + FROM test_sessions ts + JOIN users u ON u.id = ts.user_id + LEFT JOIN subjects s ON s.id = ts.subject_id + WHERE ts.status = 'completed' + AND ts.finished_at >= datetime('now', '-24 hours') + AND ts.total > 0 + ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC + LIMIT 5 + `), +}; + +/* ── GET /api/admin/overview ──────────────────────────────────────────── */ +function getOverview(_req, res) { + try { + res.json({ + newUsers24h: overviewStmts.newUsers24h.get().n, + newSessions24h: overviewStmts.newSessions24h.get().n, + activeUsers24h: overviewStmts.activeUsers24h.get().n, + activeClasses: overviewStmts.activeClasses.get().n, + failedSessions24h: overviewStmts.failedSessions24h.get().n, + bannedThisWeek: overviewStmts.bannedThisWeek.all(), + topSessions24h: overviewStmts.topSessions24h.all(), + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +} + /* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */ function getUsers(req, res) { const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50)); @@ -539,7 +591,8 @@ function broadcast(req, res) { } module.exports = { - getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, + getStats, getOverview, + getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, clearUserSessions, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 46874ad..d3d6b72 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -14,6 +14,7 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF router.use(requireRole('admin')); router.get('/stats', ctrl.getStats); +router.get('/overview', ctrl.getOverview); router.get('/users', ctrl.getUsers); router.patch('/users/:id/role', ctrl.updateRole); router.get('/users/:id/sessions', ctrl.getUserSessions); diff --git a/frontend/admin.html b/frontend/admin.html index 2eba1d1..90d12d3 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -919,7 +919,10 @@
- + + + + +
+ `; + + // Wire quick-links via event delegation + el.querySelectorAll('.ov-quick-btn[data-go]').forEach(btn => { + btn.addEventListener('click', () => navigateTo(btn.dataset.go)); + }); + + if (window.lucide) lucide.createIcons({ nodes: [el] }); + } + + async function load() { + const el = document.getElementById('overview-content'); + if (!el) return; + LS.state.loading(el, 'Загружаю обзор…'); + try { + const data = await LS.adminGetOverview(); + render(data); + } catch (e) { + LS.state.error(el, e, () => load()); + } + } + + window.AdminSections = window.AdminSections || {}; + window.AdminSections.overview = { + init: async () => { if (inited) return; inited = true; await load(); }, + reload: load, + }; +})(); diff --git a/js/api.js b/js/api.js index ba5d825..828155f 100644 --- a/js/api.js +++ b/js/api.js @@ -150,6 +150,7 @@ async function importQuestions(formData) { /* ── admin ────────────────────────────────────────────────────────────── */ async function adminGetStats() { return req('GET', '/admin/stats'); } +async function adminGetOverview() { return req('GET', '/admin/overview'); } async function adminGetUsers(params = {}) { const p = new URLSearchParams(); if (params.page) p.set('page', params.page); @@ -939,7 +940,7 @@ window.LS = { register, login, fetchMe, updateProfile, getSubjects, updateSubject, getTopics, startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions, - adminGetStats, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, + adminGetStats, adminGetOverview, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, regenerateInviteCode, classJournal, diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md index 6054618..f4079fe 100644 --- a/plans/admin-redesign/CONTEXT.md +++ b/plans/admin-redesign/CONTEXT.md @@ -6,7 +6,8 @@ - ✅ Phase 1 implemented — `window.AdminRouter` обёртывает старый `switchTab` (hash ↔ tab двусторонне). `switchTab` принимает 2-й аргумент `{ fromRouter: true }` для предотвращения рекурсии. Default = `#stats`. Файлы: `frontend/js/admin/router.js` (новый), `frontend/admin.html` (+1 строка), `frontend/js/admin/admin.js` (модификация `switchTab` + IIFE `initAdminRouter`). - ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил. -- ⬜ Phase 3-6 not started +- ✅ Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'` → `'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs). +- ⬜ Phase 4-6 not started ## Temporary Workarounds diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 8390235..67ea04e 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -37,7 +37,7 @@ - [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md) - [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md) -- [ ] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5) +- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5) - [ ] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5) - [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4) - [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md) @@ -49,8 +49,8 @@ | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| | Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 | -| Phase 2: Split sections | frontend | ✅ Done | ⬜ pending | ✅ node --check | ✅ 92030b4 | -| Phase 3: Dashboard | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 | +| Phase 3: Dashboard | fullstack | ✅ Done | ⬜ pending | ✅ node --check + queries verified | ⬜ | | Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/admin-redesign/phase-3-dashboard.md b/plans/admin-redesign/phase-3-dashboard.md index a37115e..fa9fcf5 100644 --- a/plans/admin-redesign/phase-3-dashboard.md +++ b/plans/admin-redesign/phase-3-dashboard.md @@ -1,6 +1,6 @@ # Phase 3: Dashboard #overview -**Status:** ⬜ Not Started +**Status:** ✅ Implemented (pending review) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack **Parallelizable with:** Phase 4, Phase 5 @@ -11,7 +11,7 @@ ## Tasks -- [ ] Backend: новый endpoint `GET /api/admin/overview`: +- [x] Backend: новый endpoint `GET /api/admin/overview`: ```js { newUsers24h: number, // регистрации за 24ч @@ -28,7 +28,7 @@ - Route: добавить в `backend/src/routes/admin.js` - Auth: admin или teacher (как остальные admin/* — RBAC same) - Performance: один запрос для каждого поля, простые COUNT/SELECT, без JOIN'ов где возможно -- [ ] Frontend: новый section `frontend/js/admin/sections/overview.js`: +- [x] Frontend: новый section `frontend/js/admin/sections/overview.js`: - Использует структуру из Phase 2 - Загружает `/api/admin/overview` - Рендерит карточки в `
` секции: @@ -37,11 +37,11 @@ - **Топ-сессии** — таблица top-5 за день с click→drilldown - **Quick links** — "Все пользователи", "Все сессии", "Создать тест" (deep-link в соответствующие routes) - LS.skeleton при загрузке, LS.state.error на fail -- [ ] HTML: добавить `
` в admin.html (перед остальными tab-pane) -- [ ] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity) -- [ ] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'` -- [ ] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats` -- [ ] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный +- [x] HTML: добавить `
` в admin.html (перед остальными tab-pane) +- [x] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity) +- [x] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'` +- [x] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats` +- [x] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный ## Files to Modify/Create @@ -107,6 +107,54 @@ Bento-grid из 4-6 карточек: ## Handoff to Next Phase - +**Endpoint shape (`GET /api/admin/overview`):** + +```json +{ + "newUsers24h": 0, + "newSessions24h": 0, + "activeUsers24h": 2, + "activeClasses": 5, + "failedSessions24h": 0, + "bannedThisWeek": [ + { "id": 42, "name": "...", "email": "...", "banned_at": "2026-05-15 12:30:00" } + ], + "topSessions24h": [ + { "id": 101, "user_name": "...", "subject_name": "Физика", + "score": 18, "total": 20, "percent": 90.0, "finished_at": "2026-05-16 09:14:22" } + ] +} +``` + +**Performance:** ~0.08ms/call avg (benchmarked 100 iters) — well under 100ms target. + +**Auth:** uses `router.use(requireRole('admin'))` block (admin-only, same as `/stats`). +`/features` block (teacher+admin) is above the route. Teacher access NOT granted — matches +sibling `/stats` behavior. If Phase 4-6 wants teacher access, move `/overview` above the +admin-only `router.use(...)` line in `backend/src/routes/admin.js`. + +**Quick-link wiring (Phase 4/5 extension point):** +Quick-link buttons live in `overview.js` → render() → `.ov-quick-grid`. They use a +`data-go="#hash"` attribute and a delegated click → `AdminRouter.navigate(...)`. To add +a new quick-link, append a `