add admin audit log (11), hide stacktraces via NODE_ENV=production (13), validate spam_interval_min>=1 (16)
This commit is contained in:
+8
-1
@@ -296,4 +296,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
|
||||
.stat .label a:hover{text-decoration:underline}
|
||||
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
||||
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||
.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.audit-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
.audit-table tr:last-child td{border-bottom:none}
|
||||
.audit-table tr:hover td{background:rgba(37,99,235,.04)}
|
||||
.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
|
||||
|
||||
@@ -28,6 +28,7 @@ const NAV = [
|
||||
{ page: 'links', label: 'Ссылки' },
|
||||
{ page: 'students', label: 'Ученики' },
|
||||
{ page: 'groups', label: 'Группы' },
|
||||
{ page: 'audit', label: 'Аудит' },
|
||||
{ page: 'settings', label: 'Настройки' }
|
||||
];
|
||||
function buildSidebar(active) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Аудит — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
</head>
|
||||
<body data-page="audit">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Аудит-журнал</h2>
|
||||
<p class="page-sub">Действия администратора</p>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||
<button class="btn-link" onclick="loadAudit()" title="Обновить">Обновить</button>
|
||||
<span class="hint" id="auditCount"></span>
|
||||
</div>
|
||||
|
||||
<div class="audit-wrap" style="margin-top:14px">
|
||||
<table class="audit-table" id="auditTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Действие</th>
|
||||
<th>Детали</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="auditEmpty" style="display:none">Записей пока нет.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const ACTION_LABELS = {
|
||||
'settings.update': 'Изменены настройки',
|
||||
'group.create': 'Создана группа',
|
||||
'group.update': 'Изменена группа',
|
||||
'group.delete': 'Удалена группа',
|
||||
'group.photo.create': 'Загружена фото группы',
|
||||
'group.photo.update': 'Изменено фото группы',
|
||||
'group.photo.delete': 'Удалено фото группы',
|
||||
'student.create': 'Добавлен ученик',
|
||||
'student.update': 'Изменён ученик',
|
||||
'student.delete': 'Удалён ученик',
|
||||
'student.batch-group': 'Ученики перенесены в группу',
|
||||
'entry.update': 'Изменена запись',
|
||||
'entry.soft-delete': 'Запись в корзину',
|
||||
'entry.restore': 'Запись восстановлена',
|
||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||
'link.create': 'Создана ссылка',
|
||||
'link.update': 'Изменена ссылка',
|
||||
'link.delete': 'Удалена ссылка',
|
||||
'backup.download': 'Скачан бэкап',
|
||||
'backup.restore': 'Выполнено восстановление',
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
}
|
||||
|
||||
function dt(t) {
|
||||
if (!t) return '';
|
||||
try {
|
||||
const o = JSON.parse(t);
|
||||
return esc(JSON.stringify(o).slice(0, 120));
|
||||
} catch { return esc(String(t).slice(0, 120)); }
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
const res = await fetch(`${API}/api/audit?limit=500`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей: ${rows.length}` : '';
|
||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||
if (!filtered.length) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('auditEmpty').style.display = 'none';
|
||||
el.innerHTML = filtered.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||
<td style="color:var(--muted)">${dt(r.target)}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('auditFilter').addEventListener('input', loadAudit);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadAudit();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,12 +21,12 @@
|
||||
<div class="ic">🛡️</div>
|
||||
<h3>Защита от повторов</h3>
|
||||
</div>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label for="spamInterval">Интервал ожидания</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||
<span class="unit">минут</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ async function loadSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
|
||||
Reference in New Issue
Block a user