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). Мелкая чистка неиспользуемых импортов во фронтенде.
115 lines
6.2 KiB
TypeScript
115 lines
6.2 KiB
TypeScript
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>
|
||
);
|
||
}
|