const AUTO_REFRESH_MS = 5000;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
done: 'Проверено',
skipped: 'Пропущено',
error: 'Ошибка',
reverted: 'Оригинал',
};
const STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
skipped: 'ai-badge-skipped',
reverted: 'ai-badge-reverted',
};
let state = null;
let refreshTimer = null;
let busy = false;
function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d) return `${d} д ${h} ч`;
if (h) return `${h} ч ${m} мин`;
if (m) return `${m} мин ${sec} с`;
return `${sec} с`;
}
function fmtTime(t) {
if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
}
function ago(t) {
if (!t) return '—';
const diff = Date.now() - new Date(t).getTime();
if (Number.isNaN(diff)) return '—';
return fmtDur(diff) + ' назад';
}
function statusBadge(s) {
return `${esc(STATUS_LABELS[s] || s)}`;
}
function renderState() {
const w = state.worker || {};
const svc = state.service || {};
const enabled = state.enabled;
const processing = !!w.processing;
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
const label = !enabled
? 'Автопроверка выключена'
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('stateLine').innerHTML = ` ${esc(label)}`;
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
}
function renderCards() {
const w = state.worker || {};
const svc = state.service || {};
const cfg = w.config || {};
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
document.getElementById('workerCards').innerHTML = `
Состояние воркера
Режим${w.processing ? 'Обработка' : 'Ожидание'}
Текущая запись${w.current_id ? '#' + w.current_id : '—'}
Работает${fmtDur(w.uptime_ms)}
Запущен${fmtTime(w.started_at)}
Модель
Модель${esc(state.model)}
Сервис${svc.reachable ? '✅ доступен' : '❌ недоступен'}
Отклик${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}
Адрес${esc(state.ai_url || '')}
${svc.error ? `
${esc(svc.error)}
` : ''}
Итоги работы
Проверок${w.checks || 0}
Исправлено${w.corrected || 0} (${pct}%)
Без изменений${w.unchanged || 0}
Ошибок${w.errors || 0}
Активность${ago(w.last_at)}
${w.last_error ? `
${esc(w.last_error)}
` : ''}
Параметры (мин. нагрузка)
Пауза простоя${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}
Таймаут запроса${fmtDur(cfg.request_timeout_ms)}
Макс. текст${cfg.max_input_chars || 0} симв.
Мин. текст${cfg.min_text_chars || 0} симв.
Повторы при сбое${cfg.max_attempts || 0}
`;
}
function renderCounts() {
const c = state.counts || {};
const chips = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Проверяется', c.processing || 0],
['done', 'Проверено', c.done || 0],
['skipped', 'Пропущено', c.skipped || 0],
['error', 'Ошибка', c.error || 0],
['reverted', 'Оригинал', c.reverted || 0],
];
document.getElementById('workerCounts').innerHTML = chips
.map(([cls, label, n]) => `${label}: ${n}`)
.join('');
}
function renderPending() {
const rows = state.pending || [];
document.getElementById('pendingCount').textContent = rows.length;
const tbody = document.querySelector('#pendingTable tbody');
const empty = document.getElementById('pendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
| #${r.id} |
${esc(r.student_name)} |
${esc(r.group_name || '')} |
${fmtTime(r.created_at)} |
`).join('');
}
function renderRecent() {
const filter = document.getElementById('recentFilter').value;
const all = state.recent || [];
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
const tbody = document.querySelector('#recentTable tbody');
const empty = document.getElementById('recentEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
| ${fmtTime(r.ai_checked_at)} |
${esc(r.student_name)} |
${esc(r.group_name || '')} |
${statusBadge(r.ai_status)} |
${r.changed ? '✏️ исправлен' : '—'} |
`).join('');
}
function renderErrors() {
const rows = state.errors || [];
document.getElementById('errorsCount').textContent = rows.length;
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
| ${fmtTime(r.ai_checked_at)} |
#${r.id} |
${esc(r.student_name)} |
${esc(r.ai_error || '')} |
`).join('');
}
function render() {
if (!state) return;
renderState();
renderCards();
renderCounts();
renderPending();
renderRecent();
renderErrors();
}
async function load() {
if (busy) return;
busy = true;
try {
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
if (!res.ok) return;
state = await res.json();
render();
} catch {
} finally {
busy = false;
}
}
async function post(path, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function wake() {
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
}
async function toggleEnabled() {
const next = !(state && state.enabled);
const data = await post('/api/ai/enabled', { enabled: next });
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
}
async function requeueFailed() {
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
const data = await post('/api/ai/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
}
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', load);
document.getElementById('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', renderRecent);
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
load();
}
})();