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(); } })();