Files
anti-plagiarism/services/frontend/src/pages/admin/Sources.tsx
jze9 673e72a15a feat(infra): переход прод/test на общую инфраструктуру вместо self-hosted
Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env),
локально в докере остаётся только Elasticsearch + app-сервисы. Старый
docker-compose.yml (полностью автономный стек) сохранён как
docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в
будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS,
собирающий фронтенд в статику). Makefile переписан под три режима
(dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
2026-07-27 12:22:36 +05:00

115 lines
6.2 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 { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react';
import toast from 'react-hot-toast';
import { adminApi } from '../../api/client';
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;
}
const STATUS_COLORS: Record<string, string> = {
idle: 'bg-gray-100 text-gray-600',
running: 'bg-blue-100 text-blue-700',
done: 'bg-emerald-100 text-emerald-700',
error: 'bg-red-100 text-red-700',
};
export function Sources() {
const qc = useQueryClient();
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 });
const { data: sources } = useQuery({
queryKey: ['admin-sources'],
queryFn: () => adminApi.sources().then((r) => r.data as Source[]),
refetchInterval: 5000,
});
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 || 'Ошибка'),
});
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 || 'Ошибка'),
});
const del = useMutation({
mutationFn: (id: number) => adminApi.deleteSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Удалён'); },
});
return (
<div className="space-y-5">
<h1 className="text-2xl font-bold text-gray-900">Источники парсинга</h1>
{/* Форма добавления */}
<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>
<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>
{/* Список */}
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<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>
</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>
))}
</tbody>
</table>
{!sources?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
</div>
</div>
);
}