Add AI auto-check worker, share link message/link fields, and update journal/settings UI
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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 `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
|
||||
}
|
||||
|
||||
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 = `<span class="worker-dot ${dotClass}"></span> ${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 = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Состояние воркера</div>
|
||||
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Модель</div>
|
||||
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '✅ доступен' : '❌ недоступен'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
|
||||
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Итоги работы</div>
|
||||
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
|
||||
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Параметры (мин. нагрузка)</div>
|
||||
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
|
||||
.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 => `
|
||||
<tr>
|
||||
<td>#${r.id}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
</tr>`).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 => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||
<td>${statusBadge(r.ai_status)}</td>
|
||||
<td>${r.changed ? '✏️ исправлен' : '—'}</td>
|
||||
</tr>`).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 => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||
<td>#${r.id}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
|
||||
</tr>`).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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user