feat(admin-dash): P0 — honest zeros, refresh+timestamp, hero hierarchy, stuck-sessions alert
- fmtNum: 0 no longer renders as "—" (muted "0" via .ov-zero instead) - backend: classesTotal (renamed from activeClasses — was already full count, label fixed) - backend: abandonedSessions24h (was failedSessions24h status!=completed; now only status=abandoned) - backend: stuckSessions[] — in_progress > 1h with user/subject join, limit 5 - header: timestamp + manual refresh button (.ov-header flex layout), updates every 30s via interval - newSessions24h card promoted to hero (2.6rem value, 52px icon, 2fr column ≥720px) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 '<span class="ov-zero">0</span>';
|
||||
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 ? `
|
||||
<div class="ov-card danger" style="grid-column: span 2; padding-bottom: 14px">
|
||||
<div class="ov-card-icon"><i data-lucide="user-x" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val">${bannedCount}</div>
|
||||
@@ -105,18 +156,35 @@
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const failed = data.failedSessions24h > 0 ? `
|
||||
<div class="ov-card ${failedCls}">
|
||||
const abandonedCard = hasAbandoned ? `
|
||||
<div class="ov-card warn">
|
||||
<div class="ov-card-icon"><i data-lucide="alert-triangle" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val">${data.failedSessions24h}</div>
|
||||
<div class="ov-card-label">Незавершённых сессий за 24ч</div>
|
||||
<div class="ov-card-val">${abandoned}</div>
|
||||
<div class="ov-card-label">Брошено сессий за 24ч</div>
|
||||
</div>` : '';
|
||||
|
||||
const stuckCard = hasStuck ? `
|
||||
<div class="ov-card warn" style="padding-bottom: 14px">
|
||||
<div class="ov-card-icon"><i data-lucide="clock-alert" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val">${stuck.length}</div>
|
||||
<div class="ov-card-label" style="margin-bottom: 10px">Сессий висят >1ч</div>
|
||||
<div class="ov-stuck-list">
|
||||
${stuck.map(st => `
|
||||
<div class="ov-stuck-row">
|
||||
<span class="ov-st-name">${e(st.user_name || '—')}</span>
|
||||
<span class="ov-st-subj">${e(st.subject_name || '—')}</span>
|
||||
<span class="ov-st-since">${fmtFinished(st.started_at)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
alertsHtml = `
|
||||
<div class="ov-section-title">Требует внимания</div>
|
||||
<div class="ov-grid">${banned}${failed}</div>`;
|
||||
<div class="ov-grid">${bannedCard}${abandonedCard}${stuckCard}</div>`;
|
||||
}
|
||||
|
||||
/* ── top-5 rows ────────────────────────────────────────────── */
|
||||
const topRowsHtml = top.length ? `
|
||||
<table class="ov-top-table">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
|
||||
@@ -133,18 +201,28 @@
|
||||
</tbody>
|
||||
</table>` : '<div class="ov-empty">Нет завершённых сессий за последние 24 часа</div>';
|
||||
|
||||
const tsText = _lastLoadTs ? fmtAgo(_lastLoadTs) : 'только что';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ov-section-title">Активность за 24 часа</div>
|
||||
<div class="ov-grid">
|
||||
<div class="ov-card" style="--ov-top:var(--violet)">
|
||||
<div class="ov-card-icon"><i data-lucide="user-plus" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--violet)">${fmtNum(data.newUsers24h)}</div>
|
||||
<div class="ov-card-label">Новых регистраций</div>
|
||||
<div class="ov-header">
|
||||
<div class="ov-section-title">Активность за 24 часа</div>
|
||||
<div class="ov-refresh">
|
||||
<span class="ov-refresh-ts" id="ov-ts">${tsText}</span>
|
||||
<button class="ov-refresh-btn" onclick="AdminSections.overview.reload()" title="Обновить">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-grid ov-grid-main">
|
||||
<div class="ov-card hero" style="--ov-top:var(--violet)">
|
||||
<div class="ov-card-icon" style="background:rgba(155,93,229,0.1);color:var(--violet)"><i data-lucide="play-circle" style="width:24px;height:24px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--violet)">${fmtNum(data.newSessions24h)}</div>
|
||||
<div class="ov-card-label">Сессий запущено</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--cyan)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,224,0.1);color:var(--cyan)"><i data-lucide="play-circle" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--cyan)">${fmtNum(data.newSessions24h)}</div>
|
||||
<div class="ov-card-label">Сессий запущено</div>
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,224,0.1);color:var(--cyan)"><i data-lucide="user-plus" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--cyan)">${fmtNum(data.newUsers24h)}</div>
|
||||
<div class="ov-card-label">Новых регистраций</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--green)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,100,0.1);color:var(--green)"><i data-lucide="activity" style="width:18px;height:18px"></i></div>
|
||||
@@ -153,8 +231,8 @@
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--amber)">
|
||||
<div class="ov-card-icon" style="background:rgba(255,179,71,0.12);color:var(--amber)"><i data-lucide="users" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--amber)">${fmtNum(data.activeClasses)}</div>
|
||||
<div class="ov-card-label">Активных классов</div>
|
||||
<div class="ov-card-val" style="color:var(--amber)">${fmtNum(data.classesTotal)}</div>
|
||||
<div class="ov-card-label">Всего классов</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +258,7 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user