feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
All checks were successful
Deploy / test (push) Successful in 3m56s
Deploy / deploy (push) Successful in 31s

Заливка корпуса была чёрным ящиком: у источника только 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>
This commit is contained in:
jze9
2026-08-27 17:40:23 +05:00
parent 95d2903766
commit 78e27806b9
29 changed files with 2118 additions and 168 deletions

View File

@@ -114,9 +114,28 @@ export const adminApi = {
sources: () => api.get('/admin/sources'),
createSource: (data: Record<string, unknown>) => api.post('/admin/sources', data),
createSourcesBulk: (data: Record<string, unknown>) => api.post('/admin/sources/bulk', data),
updateSource: (id: number, data: Record<string, unknown>) => api.patch(`/admin/sources/${id}`, data),
deleteSource: (id: number) => api.delete(`/admin/sources/${id}`),
runSource: (id: number) => api.post(`/admin/sources/${id}/run`),
cancelSource: (id: number) => api.post(`/admin/sources/${id}/cancel`),
runAllSources: (sourceType?: string) =>
api.post('/admin/sources/run-all', null, { params: sourceType ? { source_type: sourceType } : undefined }),
stopAllSources: () => api.post('/admin/sources/stop-all'),
sourceRuns: (id: number) => api.get(`/admin/sources/${id}/runs`),
activeRuns: () => api.get('/admin/runs/active'),
run: (runId: number) => api.get(`/admin/runs/${runId}`),
debug: () => api.get('/admin/debug'),
uploadDocuments: (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append('files', f));
return api.post('/admin/documents/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // пачка файлов грузится дольше одиночной проверки
});
},
staging: (params?: { status?: string; limit?: number; offset?: number }) =>
api.get('/admin/staging', { params }),

View File

@@ -0,0 +1,47 @@
import { clsx } from 'clsx';
/** Цвет шкалы = исход прогона: серый пока ждёт, синий в работе, дальше по итогу. */
const BAR_COLORS: Record<string, string> = {
queued: 'bg-gray-300',
running: 'bg-brand-500',
done: 'bg-emerald-500',
partial: 'bg-amber-500',
cancelled: 'bg-gray-400',
error: 'bg-red-500',
};
interface ProgressBarProps {
percent: number;
status?: string;
/** Подпись слева под шкалой (что именно сейчас происходит) */
label?: string;
/** Показывать процент справа */
showPercent?: boolean;
className?: string;
}
export function ProgressBar({
percent,
status = 'running',
label,
showPercent = true,
className,
}: ProgressBarProps) {
const value = Math.max(0, Math.min(100, percent));
return (
<div className={clsx('w-full', className)}>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-[width] duration-500', BAR_COLORS[status] || 'bg-brand-500')}
style={{ width: `${value}%` }}
/>
</div>
{(label || showPercent) && (
<div className="flex justify-between mt-1 text-[11px] text-gray-500">
<span className="truncate">{label}</span>
{showPercent && <span className="tabular-nums shrink-0">{value.toFixed(0)}%</span>}
</div>
)}
</div>
);
}

View File

@@ -25,6 +25,7 @@ import { Documents as AdminDocuments } from './pages/admin/Documents';
import { Storage as AdminStorage } from './pages/admin/Storage';
import { Sources as AdminSources } from './pages/admin/Sources';
import { Staging as AdminStaging } from './pages/admin/Staging';
import { Debug as AdminDebug } from './pages/admin/Debug';
import './index.css';
@@ -77,6 +78,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<Route path="storage" element={<AdminStorage />} />
<Route path="sources" element={<AdminSources />} />
<Route path="staging" element={<AdminStaging />} />
<Route path="debug" element={<AdminDebug />} />
<Route path="*" element={<Dashboard />} />
</Routes>
</AdminLayout>

View File

@@ -3,6 +3,7 @@ import { NavLink, useParams, useNavigate, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
LayoutDashboard, Users, FileText, Database, HardDrive, Download, Inbox, LogOut, ShieldAlert,
Bug,
} from 'lucide-react';
import { clsx } from 'clsx';
import { adminApi } from '../../api/client';
@@ -16,6 +17,7 @@ const NAV = [
{ to: 'storage', label: 'Хранилище', icon: HardDrive },
{ to: 'sources', label: 'Источники', icon: Download },
{ to: 'staging', label: 'Отстойник', icon: Inbox },
{ to: 'debug', label: 'Отладка', icon: Bug },
];
interface AdminLayoutProps {

View File

@@ -0,0 +1,255 @@
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>
);
}

View File

@@ -1,78 +1,290 @@
import { useState } from 'react';
import { Fragment, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react';
import {
Play, Trash2, Plus, Square, PlayCircle, StopCircle, ChevronDown, ChevronRight,
Upload, Layers, RefreshCw,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
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;
cancel_requested: boolean; error: string | null;
started_at: string; heartbeat_at: string | null; finished_at: string | null;
percent: number;
}
interface LogEntry { ts: string; elapsed: number; level: string; msg: string }
interface RunDetail extends Run { log: LogEntry[] }
interface Source {
id: number; source_type: string; name: string; query: string | null;
lang: string | null; year_from: number | null; year_to: number | null;
limit: number; enabled: boolean; last_status: string; last_error: string | null;
last_run_at: string | null; docs_added: number;
last_run_at: string | null; docs_added: number; last_run: Run | null;
}
const SOURCE_TYPES = [
{ value: 'openalex', label: 'OpenAlex' },
{ value: 'cyberleninka', label: 'КиберЛенинка' },
{ value: 'arxiv', label: 'arXiv' },
{ value: 'pmc', label: 'PubMed Central' },
];
const STATUS_COLORS: Record<string, string> = {
idle: 'bg-gray-100 text-gray-600',
queued: 'bg-gray-100 text-gray-600',
running: 'bg-blue-100 text-blue-700',
done: 'bg-emerald-100 text-emerald-700',
partial: 'bg-amber-100 text-amber-700',
cancelled: 'bg-gray-200 text-gray-600',
error: 'bg-red-100 text-red-700',
};
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди',
fetch: 'выборка из источника',
index: 'индексация в базу',
finished: 'завершено',
};
const ACTIVE = ['queued', 'running'];
/** Что происходит с источником прямо сейчас — подпись под шкалой. */
function runLabel(run: Run): string {
if (run.stage === 'fetch') return `${STAGE_LABELS.fetch}: ${run.fetched}/${run.target}`;
if (run.stage === 'index') return `${STAGE_LABELS.index}: ${run.processed}/${run.fetched}`;
return `+${run.added} новых · ${run.duplicates} дублей${run.failed ? ` · ${run.failed} ошибок` : ''}`;
}
export function Sources() {
const qc = useQueryClient();
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 });
const { data: sources } = useQuery({
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 1000 });
const [bulk, setBulk] = useState({ open: false, source_type: 'openalex', queries: '', lang: '', limit: 1000, run_now: false });
const [expanded, setExpanded] = useState<number | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { data: sources, isFetching } = useQuery({
queryKey: ['admin-sources'],
queryFn: () => adminApi.sources().then((r) => r.data as Source[]),
refetchInterval: 5000,
refetchInterval: 3000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin-sources'] });
const fail = (e: any) => toast.error(e.response?.data?.detail || 'Ошибка');
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSource(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Источник добавлен'); setForm({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
onSuccess: () => {
invalidate();
toast.success('Источник добавлен');
setForm({ source_type: form.source_type, name: '', query: '', lang: '', limit: form.limit });
},
onError: fail,
});
const createBulk = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSourcesBulk(data),
onSuccess: (r) => {
invalidate();
toast.success(`Добавлено источников: ${r.data.created}${r.data.started ? `, запущено ${r.data.started}` : ''}`);
setBulk({ ...bulk, queries: '' });
},
onError: fail,
});
const run = useMutation({
mutationFn: (id: number) => adminApi.runSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Парсинг запущен'); },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
onSuccess: () => { invalidate(); toast.success('Парсинг запущен'); },
onError: fail,
});
const cancel = useMutation({
mutationFn: (id: number) => adminApi.cancelSource(id),
onSuccess: () => { invalidate(); toast.success('Остановка запрошена'); },
onError: fail,
});
const runAll = useMutation({
mutationFn: () => adminApi.runAllSources(),
onSuccess: (r) => {
invalidate();
toast.success(`Запущено ${r.data.started} из ${r.data.total} (уже шли: ${r.data.skipped_active})`);
},
onError: fail,
});
const stopAll = useMutation({
mutationFn: () => adminApi.stopAllSources(),
onSuccess: (r) => { invalidate(); toast.success(`Остановка ${r.data.cancelled} прогонов запрошена`); },
onError: fail,
});
const del = useMutation({
mutationFn: (id: number) => adminApi.deleteSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Удалён'); },
onSuccess: () => { invalidate(); toast.success('Удалён'); },
onError: fail,
});
const upload = useMutation({
mutationFn: (files: File[]) => adminApi.uploadDocuments(files),
onSuccess: (r) => {
const { accepted, rejected } = r.data;
toast.success(`Принято файлов: ${accepted.length}${rejected.length ? `, отклонено ${rejected.length}` : ''}`);
rejected.forEach((x: { filename: string; reason: string }) => toast.error(`${x.filename}: ${x.reason}`));
},
onError: fail,
});
const list = sources || [];
const active = list.filter((s) => s.last_run && ACTIVE.includes(s.last_run.status));
const activeAdded = active.reduce((sum, s) => sum + (s.last_run?.added || 0), 0);
// Общая шкала = средняя готовность идущих прогонов: столько заливки осталось
const overall = active.length
? active.reduce((sum, s) => sum + (s.last_run?.percent || 0), 0) / active.length
: 0;
return (
<div className="space-y-5">
<h1 className="text-2xl font-bold text-gray-900">Источники парсинга</h1>
<div className="flex items-center justify-between flex-wrap gap-3">
<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>
<div className="flex gap-2">
<button
onClick={() => { if (confirm(`Запустить заливку по всем включённым источникам (${list.length})?`)) runAll.mutate(); }}
disabled={runAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<PlayCircle className="w-4 h-4" /> Запустить всё
</button>
<button
onClick={() => { if (confirm('Остановить все идущие прогоны?')) stopAll.mutate(); }}
disabled={!active.length || stopAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-40"
>
<StopCircle className="w-4 h-4" /> Остановить всё
</button>
</div>
</div>
{/* Общий прогресс заливки */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="font-semibold text-gray-800">Заливка корпуса</h2>
<span className="text-sm text-gray-500">
активных источников: <b className="text-gray-800">{active.length}</b> из {list.length}
{active.length > 0 && <> · добавлено в этом заходе: <b className="text-gray-800">{activeAdded}</b></>}
</span>
</div>
<ProgressBar
percent={overall}
status={active.length ? 'running' : 'done'}
label={active.length ? `${active.length} прогонов в работе` : 'все прогоны завершены'}
/>
</div>
{/* Форма добавления */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-3">Добавить источник</h2>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<select value={form.source_type} onChange={(e) => setForm({ ...form, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
<option value="openalex">OpenAlex</option>
<option value="cyberleninka">КиберЛенинка</option>
<option value="arxiv">arXiv</option>
</select>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="Запрос"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.lang} onChange={(e) => setForm({ ...form, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={form.limit} onChange={(e) => setForm({ ...form, limit: Number(e.target.value) })} placeholder="Лимит"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-800">Добавить источник</h2>
<button
onClick={() => setBulk({ ...bulk, open: !bulk.open })}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
>
<Layers className="w-4 h-4" /> {bulk.open ? 'обычное добавление' : 'пакетно (много тем)'}
</button>
</div>
<button
onClick={() => {
if (!form.name) { toast.error('Укажите название'); return; }
create.mutate({ ...form, lang: form.lang || null, query: form.query || null });
{!bulk.open ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<select value={form.source_type} onChange={(e) => setForm({ ...form, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="Запрос"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.lang} onChange={(e) => setForm({ ...form, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={form.limit} onChange={(e) => setForm({ ...form, limit: Number(e.target.value) })} placeholder="Лимит"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
</div>
<button
onClick={() => {
if (!form.name) { toast.error('Укажите название'); return; }
create.mutate({ ...form, lang: form.lang || null, query: form.query || null });
}}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
>
<Plus className="w-4 h-4" /> Добавить
</button>
</>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<select value={bulk.source_type} onChange={(e) => setBulk({ ...bulk, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={bulk.lang} onChange={(e) => setBulk({ ...bulk, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={bulk.limit} onChange={(e) => setBulk({ ...bulk, limit: Number(e.target.value) })} placeholder="Лимит на тему"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" checked={bulk.run_now} onChange={(e) => setBulk({ ...bulk, run_now: e.target.checked })} />
запустить сразу
</label>
</div>
<textarea
value={bulk.queries}
onChange={(e) => setBulk({ ...bulk, queries: e.target.value })}
placeholder={'Одна тема на строку:\nмашинное обучение\nэкономика труда\nгражданское право'}
rows={5}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono"
/>
<button
onClick={() => {
const queries = bulk.queries.split('\n').map((q) => q.trim()).filter(Boolean);
if (!queries.length) { toast.error('Добавьте хотя бы одну тему'); return; }
createBulk.mutate({
source_type: bulk.source_type, queries, lang: bulk.lang || null,
limit: bulk.limit, run_now: bulk.run_now,
});
}}
disabled={createBulk.isPending}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<Plus className="w-4 h-4" /> Добавить пачкой
</button>
</>
)}
</div>
{/* Загрузка готовых работ в базу сравнения */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-1">Загрузить работы в базу</h2>
<p className="text-sm text-gray-500 mb-3">
Файлы (PDF, DOCX, TXT) попадают прямо в базу сравнения — с ними будут сверяться проверяемые работы.
Это не проверка на плагиат.
</p>
<input
ref={fileInput}
type="file"
multiple
accept=".pdf,.docx,.txt"
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length) upload.mutate(files);
e.target.value = '';
}}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
/>
<button
onClick={() => fileInput.current?.click()}
disabled={upload.isPending}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<Plus className="w-4 h-4" /> Добавить
<Upload className="w-4 h-4" /> {upload.isPending ? 'Загрузка…' : 'Выбрать файлы'}
</button>
</div>
@@ -81,34 +293,128 @@ export function Sources() {
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-3 py-3 w-8"></th>
<th className="px-4 py-3">Название</th><th className="px-4 py-3">Тип</th>
<th className="px-4 py-3">Запрос</th><th className="px-4 py-3">Лимит</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Добавлено</th><th className="px-4 py-3"></th>
<th className="px-4 py-3">Лимит</th>
<th className="px-4 py-3 min-w-[220px]">Прогресс</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Всего</th><th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{sources?.map((s) => (
<tr key={s.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800">{s.name}</td>
<td className="px-4 py-3 text-gray-600">{s.source_type}</td>
<td className="px-4 py-3 text-gray-500 max-w-[160px] truncate">{s.query || '—'}</td>
<td className="px-4 py-3 text-gray-600">{s.limit}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`} title={s.last_error || ''}>{s.last_status}</span>
</td>
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
<td className="px-4 py-3 flex gap-1">
<button onClick={() => run.mutate(s.id)} disabled={s.last_status === 'running'} title="Запустить"
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded disabled:opacity-40"><Play className="w-4 h-4" /></button>
<button onClick={() => { if (confirm('Удалить источник?')) del.mutate(s.id); }} title="Удалить"
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
{list.map((s) => {
const r = s.last_run;
const isActive = !!r && ACTIVE.includes(r.status);
return (
<Fragment key={s.id}>
<tr className="hover:bg-gray-50">
<td className="px-3 py-3">
<button onClick={() => setExpanded(expanded === s.id ? null : s.id)}
className="text-gray-400 hover:text-gray-600" title="Журнал прогона">
{expanded === s.id ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
</td>
<td className="px-4 py-3 text-gray-800">
{s.name}
{s.query && <div className="text-xs text-gray-400 truncate max-w-[220px]">{s.query}</div>}
</td>
<td className="px-4 py-3 text-gray-600">{s.source_type}</td>
<td className="px-4 py-3 text-gray-600">{s.limit}</td>
<td className="px-4 py-3">
{r ? <ProgressBar percent={r.percent} status={r.status} label={runLabel(r)} />
: <span className="text-xs text-gray-400">не запускался</span>}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`}
title={s.last_error || ''}>{s.last_status}</span>
</td>
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{isActive ? (
<button onClick={() => cancel.mutate(s.id)} title="Остановить"
className="p-1.5 text-gray-400 hover:text-amber-600 rounded"><Square className="w-4 h-4" /></button>
) : (
<button onClick={() => run.mutate(s.id)} title="Запустить"
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded"><Play className="w-4 h-4" /></button>
)}
<button onClick={() => { if (confirm('Удалить источник?')) del.mutate(s.id); }} title="Удалить"
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
{expanded === s.id && (
<tr>
<td colSpan={8} className="bg-gray-50 px-6 py-4">
<RunLog runId={r?.id} live={isActive} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
{!sources?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
{!list.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
</div>
</div>
);
}
const LOG_COLORS: Record<string, string> = {
error: 'text-red-600',
warning: 'text-amber-600',
info: 'text-gray-600',
};
/** Журнал последнего прогона источника — что именно происходило по шагам. */
function RunLog({ runId, live }: { runId?: number; live: boolean }) {
const { data, isLoading } = useQuery({
queryKey: ['admin-run', runId],
queryFn: () => adminApi.run(runId!).then((r) => r.data as RunDetail),
enabled: !!runId,
refetchInterval: live ? 3000 : false,
});
if (!runId) return <div className="text-sm text-gray-400">Источник ещё не запускался.</div>;
if (isLoading || !data) return <div className="text-sm text-gray-400">Загрузка журнала…</div>;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<Chip label="прогон" value={`#${data.id}`} />
<Chip label="стадия" value={STAGE_LABELS[data.stage] || data.stage} />
<Chip label="получено" value={`${data.fetched}/${data.target}`} />
<Chip label="обработано" value={String(data.processed)} />
<Chip label="добавлено" value={String(data.added)} />
<Chip label="дублей" value={String(data.duplicates)} />
{data.skipped > 0 && <Chip label="без метаданных" value={String(data.skipped)} />}
{data.failed > 0 && <Chip label="ошибок" value={String(data.failed)} />}
<Chip label="начат" value={new Date(data.started_at).toLocaleString('ru-RU')} />
{data.finished_at && <Chip label="завершён" value={new Date(data.finished_at).toLocaleString('ru-RU')} />}
</div>
{data.error && (
<div className="text-xs text-red-700 bg-red-50 border border-red-100 rounded-lg p-3 font-mono break-all">
{data.error}
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg max-h-64 overflow-y-auto font-mono text-xs">
{data.log?.length ? data.log.map((e, i) => (
<div key={i} className="px-3 py-1 border-b border-gray-50 last:border-0 flex gap-3">
<span className="text-gray-400 shrink-0 tabular-nums">+{e.elapsed.toFixed(0)}с</span>
<span className={`${LOG_COLORS[e.level] || 'text-gray-600'} break-all`}>{e.msg}</span>
</div>
)) : <div className="px-3 py-2 text-gray-400">Записей нет.</div>}
</div>
</div>
);
}
function Chip({ label, value }: { label: string; value: string }) {
return (
<span className="px-2 py-1 bg-white border border-gray-200 rounded-lg text-gray-600">
{label}: <b className="text-gray-800">{value}</b>
</span>
);
}