feat(admin): phase 3 — dashboard #overview landing

GET /api/admin/overview returns 24h digest (~0.08ms/call).

- adminController.getOverview: 7 prepared statements (users 24h, sessions 24h, active users, classes count, failed sessions, banned this week, top-5 sessions)

- new section frontend/js/admin/sections/overview.js (~205L): bento-grid cards, alerts (only when >0), top-5 table, quick-links

- nav-item + tab-pane reordered: #overview is now default; #stats remains routable

Auth: admin-only (inside requireRole('admin') block, sibling of /stats).

Backward compat: all 13 existing routes unchanged.

Known follow-ups (post-merge polish):

- activeClasses counts all (label could be 'Всего классов')

- failedSessions24h includes in_progress (could tighten to abandoned only)

- topSessions24h drops NULL-score completed rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-16 23:26:59 +03:00
parent fa67ad1294
commit 41acbdd0d0
9 changed files with 351 additions and 29 deletions
+54 -1
View File
@@ -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,