Files
anti-plagiarism/services/frontend/src/pages/admin/Debug.tsx
jze9 78e27806b9
All checks were successful
Deploy / test (push) Successful in 3m56s
Deploy / deploy (push) Successful in 31s
feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
Заливка корпуса была чёрным ящиком: у источника только last_status
(idle/running/done/error), без «сколько из скольки», без причины падения и без
способа остановить начатое. Теперь каждый запуск создаёт строку parse_runs,
куда воркер раз в ~2с пишет стадию, счётчики и журнал событий.

Админка:
- шкала загрузки у каждого источника (0→50% выборка, 50→100% индексация),
  раскрытая строка — журнал прогона по шагам с таймингами;
- «Запустить всё» / «Остановить всё» и остановка по одному источнику
  (кооперативная отмена: воркер останавливается сам, не рвя запись в базу);
- пакетное добавление источников (тип + список тем), тип pmc в форме;
- загрузка PDF/DOCX/TXT прямо в базу сравнения (index.ingest_upload);
- страница «Отладка»: воркеры Celery и их текущие таски, очереди RabbitMQ,
  покрытие корпуса эмбеддингами, зависшие и упавшие прогоны, конфиг бэкендов.

Защита от краш-лупа по consumer_timeout RabbitMQ (docs/DR-HA.md §6), без неё
массовый запуск 170+ источников гарантированно ронял воркер:
- PARSER_TIME_BUDGET_S (1500с) — прогон закругляется сам и помечается partial;
- worker_prefetch_multiplier=1 — таймаут считается от ДОСТАВКИ сообщения, и с
  дефолтным префетчем очередь долгих run_parser убивала канал на задачах,
  которые ещё не начинались.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:40:23 +05:00

256 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Activity, AlertTriangle, Cpu, Database, Layers, RefreshCw, Settings2, ListTree,
} from 'lucide-react';
import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
interface ActiveTask { name: string; id: string; args: string; started_ago_s: number | null }
interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] }
interface Run {
id: number; source_id: number; status: string; stage: string; target: number;
fetched: number; processed: number; added: number; duplicates: number;
skipped: number; failed: number; error: string | null; percent: number;
started_at: string; heartbeat_at: string | null;
}
interface DebugData {
generated_at: string;
celery: { workers: Worker[]; error?: string };
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
corpus: {
documents: number; documents_embedded: number; documents_without_embedding: number;
fingerprints_estimate: number; elasticsearch_documents: number | null;
};
sources: {
by_status: Record<string, number>;
active_runs: Run[];
stale_run_ids: number[];
recent_problem_runs: Run[];
};
tasks_24h: Record<string, number>;
recent_failed_tasks: { public_id: string; type: string; error: string; created_at: string }[];
config: Record<string, string>;
}
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди', fetch: 'выборка', index: 'индексация', finished: 'завершено',
};
function fmtNum(n: number | null | undefined): string {
return n == null ? '—' : n.toLocaleString('ru-RU');
}
export function Debug() {
const { data, isFetching, error } = useQuery({
queryKey: ['admin-debug'],
queryFn: () => adminApi.debug().then((r) => r.data as DebugData),
refetchInterval: 5000,
});
if (error) {
return <div className="text-red-600 text-sm">Не удалось получить срез состояния: {String(error)}</div>;
}
if (!data) {
return <div className="text-gray-400 text-sm">Сбор данных…</div>;
}
const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
const queues = queuesErr ? {} : (data.queues as Record<string, { messages: number; unacked: number; consumers: number }>);
const embedPercent = data.corpus.documents
? (data.corpus.documents_embedded / data.corpus.documents) * 100
: 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
Отладка
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
</h1>
<span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span>
</div>
{/* Активные прогоны заливки */}
<Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}>
{data.sources.stale_run_ids.length > 0 && (
<div className="mb-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
Прогоны без признаков жизни больше 10 минут: {data.sources.stale_run_ids.join(', ')}.
Обычно это упавший или перезапущенный worker-indexer — проверьте его логи и очередь queue.index.
</span>
</div>
)}
{data.sources.active_runs.length ? (
<div className="space-y-3">
{data.sources.active_runs.map((r) => (
<div key={r.id} className="flex items-center gap-4">
<div className="w-40 shrink-0 text-sm text-gray-600 truncate">
#{r.id} · источник {r.source_id}
</div>
<ProgressBar
percent={r.percent}
status={r.status}
label={`${STAGE_LABELS[r.stage] || r.stage} · получено ${r.fetched}/${r.target} · +${r.added}`}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Сейчас ничего не заливается.</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(data.sources.by_status).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
</div>
</Card>
{/* Воркеры */}
<Card icon={Cpu} title="Воркеры Celery">
{data.celery.error && <p className="text-sm text-red-600 mb-2">{data.celery.error}</p>}
{data.celery.workers.length ? (
<div className="space-y-4">
{data.celery.workers.map((w) => (
<div key={w.name}>
<div className="flex items-baseline justify-between mb-1">
<span className="font-medium text-gray-800 text-sm">{w.name}</span>
<span className="text-xs text-gray-500">
параллельно: {w.concurrency ?? '—'} · в работе: {w.active.length} · зарезервировано: {w.reserved}
</span>
</div>
{w.active.length ? (
<div className="border border-gray-100 rounded-lg divide-y divide-gray-50 text-xs font-mono">
{w.active.map((t) => (
<div key={t.id} className="px-3 py-1.5 flex gap-3">
<span className="text-gray-400 tabular-nums shrink-0">
{t.started_ago_s != null ? `${t.started_ago_s}с` : '—'}
</span>
<span className="text-gray-800 shrink-0">{t.name}</span>
<span className="text-gray-400 truncate">{t.args}</span>
</div>
))}
</div>
) : <p className="text-xs text-gray-400">простаивает</p>}
</div>
))}
</div>
) : <p className="text-sm text-gray-400">Воркеры не отвечают на ping.</p>}
</Card>
{/* Очереди */}
<Card icon={Activity} title="Очереди RabbitMQ">
{queuesErr ? (
<p className="text-sm text-red-600">{queuesErr}</p>
) : (
<table className="w-full text-sm">
<thead className="text-gray-500 text-left">
<tr><th className="py-1">Очередь</th><th className="py-1">Ждут</th><th className="py-1">В работе</th><th className="py-1">Потребителей</th></tr>
</thead>
<tbody className="divide-y divide-gray-50">
{Object.entries(queues).map(([name, q]) => (
<tr key={name}>
<td className="py-1.5 text-gray-800">{name}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.messages)}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.unacked)}</td>
<td className={`py-1.5 tabular-nums ${q.consumers ? 'text-gray-600' : 'text-red-600'}`}>{q.consumers}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* Корпус */}
<Card icon={Database} title="Корпус сравнения">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Metric label="Документов" value={fmtNum(data.corpus.documents)} />
<Metric label="С эмбеддингом" value={fmtNum(data.corpus.documents_embedded)} />
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
</div>
<ProgressBar
percent={embedPercent}
status={data.corpus.documents_without_embedding > 0 ? 'partial' : 'done'}
label={`покрытие эмбеддингами (L3): без вектора ${fmtNum(data.corpus.documents_without_embedding)} документов`}
/>
</Card>
{/* Проблемные прогоны */}
{data.sources.recent_problem_runs.length > 0 && (
<Card icon={AlertTriangle} title="Последние проблемные прогоны">
<div className="space-y-2 text-sm">
{data.sources.recent_problem_runs.map((r) => (
<div key={r.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-gray-50 pb-2 last:border-0">
<span className="text-gray-800">#{r.id}</span>
<span className="text-gray-500">источник {r.source_id}</span>
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
<span className="text-gray-500">
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
</span>
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
</div>
))}
</div>
</Card>
)}
{/* Задачи пользователей */}
<Card icon={ListTree} title="Проверки за 24 часа">
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(data.tasks_24h).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
{!Object.keys(data.tasks_24h).length && <span className="text-sm text-gray-400">задач не было</span>}
</div>
{data.recent_failed_tasks.length > 0 && (
<div className="text-xs space-y-1">
<div className="text-gray-500 mb-1">Последние упавшие:</div>
{data.recent_failed_tasks.map((t) => (
<div key={t.public_id} className="flex gap-3">
<span className="text-gray-400 shrink-0">{new Date(t.created_at).toLocaleString('ru-RU')}</span>
<span className="text-gray-700 shrink-0">{t.type}</span>
<span className="text-red-600 font-mono truncate">{t.error || '—'}</span>
</div>
))}
</div>
)}
</Card>
{/* Конфигурация */}
<Card icon={Settings2} title="Конфигурация бэкендов">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 text-sm">
{Object.entries(data.config).map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 border-b border-gray-50 py-1">
<span className="text-gray-500">{k}</span>
<span className="text-gray-800 font-mono truncate" title={v}>{v}</span>
</div>
))}
</div>
</Card>
</div>
);
}
function Card({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-400" /> {title}
</h2>
{children}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xl font-bold text-gray-900 tabular-nums">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}