diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 3e35b61..a572a4f 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -35,8 +35,18 @@ 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'), + abandonedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status = 'abandoned'"), + classesTotal: db.prepare('SELECT COUNT(*) AS n FROM classes'), + stuckSessions: db.prepare(` + SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.started_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 = 'in_progress' + AND ts.started_at < datetime('now', '-1 hour') + ORDER BY ts.started_at + LIMIT 5 + `), // 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 @@ -72,10 +82,11 @@ function getOverview(_req, res) { 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(), + classesTotal: overviewStmts.classesTotal.get().n, + abandonedSessions24h: overviewStmts.abandonedSessions24h.get().n, + stuckSessions: overviewStmts.stuckSessions.all(), + bannedThisWeek: overviewStmts.bannedThisWeek.all(), + topSessions24h: overviewStmts.topSessions24h.all(), }); } catch (err) { res.status(500).json({ error: err.message }); diff --git a/frontend/js/admin/sections/overview.js b/frontend/js/admin/sections/overview.js index bf15825..845dcd9 100644 --- a/frontend/js/admin/sections/overview.js +++ b/frontend/js/admin/sections/overview.js @@ -5,6 +5,8 @@ (function () { 'use strict'; let inited = false; + let _lastLoadTs = 0; + let _tsInterval = null; /* ── one-time CSS injection (overview-specific bento layout) ────────── */ function ensureOvStyles() { @@ -12,24 +14,45 @@ const s = document.createElement('style'); s.id = 'ov-style'; s.textContent = ` + /* ── main grid ─────────────────────────────────────────────── */ .ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; } + @media (min-width: 720px) { + .ov-grid.ov-grid-main { grid-template-columns: 2fr 1fr 1fr 1fr; } + } .ov-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 22px 20px; position: relative; overflow: hidden; } .ov-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--ov-top, var(--violet)); opacity: 0.7; } .ov-card-icon { width: 38px; height: 38px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; background: rgba(155,93,229,0.1); color: var(--violet); } + .ov-card.hero .ov-card-icon { width: 52px; height: 52px; border-radius: 14px; } .ov-card-val { font-family: 'Unbounded', sans-serif; font-size: 1.9rem; font-weight: 800; line-height: 1.1; margin-bottom: 4px; } + .ov-card.hero .ov-card-val { font-size: 2.6rem; } .ov-card-label { font-size: 0.82rem; color: var(--text-3); font-weight: 600; } + .ov-zero { color: var(--text-3); opacity: 0.55; } .ov-card.warn { border-color: rgba(255,179,71,0.4); } .ov-card.warn::before { background: var(--amber); } .ov-card.warn .ov-card-icon { background: rgba(255,179,71,0.12); color: var(--amber); } .ov-card.danger { border-color: rgba(241,91,181,0.35); } .ov-card.danger::before { background: var(--pink); } .ov-card.danger .ov-card-icon { background: rgba(241,91,181,0.1); color: var(--pink); } + /* ── section header ─────────────────────────────────────────── */ .ov-section-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 28px 0 12px; } + .ov-header { display: flex; justify-content: space-between; align-items: center; margin: 28px 0 12px; } + .ov-header .ov-section-title { margin: 0; } + .ov-refresh { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; color: var(--text-3); } + .ov-refresh-btn { background: transparent; border: 1px solid var(--border); border-radius: 8px; width: 28px; height: 28px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: color .12s, border-color .12s; } + .ov-refresh-btn:hover { color: var(--violet); border-color: rgba(155,93,229,0.35); } + /* ── banned / alert list ────────────────────────────────────── */ .ov-banned-list { display: flex; flex-direction: column; gap: 6px; } .ov-banned-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: rgba(241,91,181,0.06); border: 1px solid rgba(241,91,181,0.18); border-radius: 10px; font-size: 0.86rem; } .ov-banned-row .ov-bn-name { font-weight: 600; } .ov-banned-row .ov-bn-email { color: var(--text-3); font-size: 0.78rem; } .ov-banned-row .ov-bn-date { margin-left: auto; color: var(--text-3); font-size: 0.76rem; } + /* ── stuck-session list ─────────────────────────────────────── */ + .ov-stuck-list { display: flex; flex-direction: column; gap: 6px; } + .ov-stuck-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(255,179,71,0.07); border: 1px solid rgba(255,179,71,0.22); border-radius: 10px; font-size: 0.84rem; } + .ov-stuck-row .ov-st-name { font-weight: 600; flex: 1; } + .ov-stuck-row .ov-st-subj { color: var(--text-3); font-size: 0.78rem; } + .ov-stuck-row .ov-st-since { margin-left: auto; color: var(--text-3); font-size: 0.76rem; white-space: nowrap; } + /* ── top-5 table ────────────────────────────────────────────── */ .ov-top-table { width: 100%; border-collapse: collapse; } .ov-top-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); font-weight: 700; padding: 8px 10px; border-bottom: 1px solid var(--border); } .ov-top-table td { padding: 10px; font-size: 0.86rem; border-bottom: 1px solid var(--border); } @@ -38,10 +61,12 @@ .ov-pct.hi { color: var(--green); } .ov-pct.mid { color: var(--amber); } .ov-pct.lo { color: var(--pink); } + /* ── quick-nav ──────────────────────────────────────────────── */ .ov-quick-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } .ov-quick-btn { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 0.88rem; font-weight: 600; color: var(--text); text-align: left; transition: background .12s, border-color .12s, transform .12s; } .ov-quick-btn:hover { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.3); color: var(--violet); transform: translateY(-1px); } .ov-quick-btn svg { width: 16px; height: 16px; flex-shrink: 0; } + /* ── misc ───────────────────────────────────────────────────── */ .ov-empty { padding: 18px; text-align: center; color: var(--text-3); font-size: 0.85rem; } `; document.head.appendChild(s); @@ -53,7 +78,9 @@ } function fmtNum(n) { - return (n === 0 || n === null || n === undefined) ? '—' : String(n); + if (n === null || n === undefined) return '—'; + if (n === 0) return '0'; + return String(n); } function fmtBannedDate(s) { @@ -72,6 +99,24 @@ } catch { return s; } } + function fmtAgo(ms) { + const sec = Math.floor((Date.now() - ms) / 1000); + if (sec < 10) return 'только что'; + if (sec < 60) return sec + ' сек назад'; + const min = Math.floor(sec / 60); + if (min < 60) return min + ' мин назад'; + const hr = Math.floor(min / 60); + return hr + ' ч назад'; + } + + function startTsInterval() { + if (_tsInterval) return; + _tsInterval = setInterval(function () { + const el = document.getElementById('ov-ts'); + if (el && _lastLoadTs) el.textContent = fmtAgo(_lastLoadTs); + }, 30000); + } + function navigateTo(hash) { if (window.AdminRouter) AdminRouter.navigate(hash); else window.location.hash = hash; @@ -83,13 +128,19 @@ ensureOvStyles(); const e = LS.esc; - const failedCls = data.failedSessions24h > 0 ? 'warn' : ''; const bannedCount = Array.isArray(data.bannedThisWeek) ? data.bannedThisWeek.length : 0; const top = Array.isArray(data.topSessions24h) ? data.topSessions24h : []; + const stuck = Array.isArray(data.stuckSessions) ? data.stuckSessions : []; + const abandoned = data.abandonedSessions24h || 0; + /* ── alerts section ────────────────────────────────────────── */ let alertsHtml = ''; - if (bannedCount > 0 || data.failedSessions24h > 0) { - const banned = bannedCount > 0 ? ` + const hasBanned = bannedCount > 0; + const hasAbandoned = abandoned > 0; + const hasStuck = stuck.length > 0; + + if (hasBanned || hasAbandoned || hasStuck) { + const bannedCard = hasBanned ? `
${bannedCount}
@@ -105,18 +156,35 @@
` : ''; - const failed = data.failedSessions24h > 0 ? ` -
+ const abandonedCard = hasAbandoned ? ` +
-
${data.failedSessions24h}
-
Незавершённых сессий за 24ч
+
${abandoned}
+
Брошено сессий за 24ч
+
` : ''; + + const stuckCard = hasStuck ? ` +
+
+
${stuck.length}
+
Сессий висят >1ч
+
+ ${stuck.map(st => ` +
+ ${e(st.user_name || '—')} + ${e(st.subject_name || '—')} + ${fmtFinished(st.started_at)} +
+ `).join('')} +
` : ''; alertsHtml = `
Требует внимания
-
${banned}${failed}
`; +
${bannedCard}${abandonedCard}${stuckCard}
`; } + /* ── top-5 rows ────────────────────────────────────────────── */ const topRowsHtml = top.length ? ` @@ -133,18 +201,28 @@
УченикПредметСчёт%Завершён
` : '
Нет завершённых сессий за последние 24 часа
'; + const tsText = _lastLoadTs ? fmtAgo(_lastLoadTs) : 'только что'; + el.innerHTML = ` -
Активность за 24 часа
-
-
-
-
${fmtNum(data.newUsers24h)}
-
Новых регистраций
+
+
Активность за 24 часа
+
+ ${tsText} + +
+
+
+
+
+
${fmtNum(data.newSessions24h)}
+
Сессий запущено
-
-
${fmtNum(data.newSessions24h)}
-
Сессий запущено
+
+
${fmtNum(data.newUsers24h)}
+
Новых регистраций
@@ -153,8 +231,8 @@
-
${fmtNum(data.activeClasses)}
-
Активных классов
+
${fmtNum(data.classesTotal)}
+
Всего классов
@@ -180,7 +258,7 @@
`; - // Wire quick-links via event delegation + /* ── wire quick-links via event delegation ───────────────── */ el.querySelectorAll('.ov-quick-btn[data-go]').forEach(btn => { btn.addEventListener('click', () => navigateTo(btn.dataset.go)); }); @@ -194,7 +272,9 @@ LS.state.loading(el, 'Загружаю обзор…'); try { const data = await LS.adminGetOverview(); + _lastLoadTs = Date.now(); render(data); + startTsInterval(); } catch (e) { LS.state.error(el, e, () => load()); }