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>
This commit is contained in:
@@ -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 }),
|
||||
|
||||
47
services/frontend/src/components/ProgressBar.tsx
Normal file
47
services/frontend/src/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
255
services/frontend/src/pages/admin/Debug.tsx
Normal file
255
services/frontend/src/pages/admin/Debug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user