Заливка корпуса была чёрным ящиком: у источника только 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>
256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|