feat: админ-панель, лицензия, тестовый стек на распределённой инфраструктуре
Админ-панель (доступ: is_admin + секретный код сессии в /admin/<code>): - разделы: дашборд (здоровье сервисов, статистика), клиенты, работы, база документов, хранилище MinIO, источники парсинга, отстойник работ - backend: модели ParseSource/StagedWork/AdminSession, миграция 003, core/admin (авторизация), роутер api/admin - frontend: AdminLayout + 7 вкладок, adminApi, вход из кабинета - Celery index.run_parser (фоновый парсинг), автосбор проверенных работ в отстойник с ручным одобрением → индексация в базу Исправления: - openalex parser: тип article (не journal-article), убран is_oa-фильтр - winnowing: приведение xxh64 к signed int64 (фикс bigint out of range) - bcrypt пин 4.0.1 (совместимость с passlib 1.7.4) - alembic: prepend_sys_path + asyncpg-драйвер - frontend: контракт Task (public_id вместо id), react-dropzone Инфраструктура: - docker-compose.test.yml: api+frontend+воркеры локально, БД/кэш/хранилище/ брокер/LLM — на удалённых серверах через .env - .gitignore: исправлено ошибочное игнорирование кода services/*/app/models/ - LICENSE: проприетарная Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
114
services/frontend/src/pages/admin/Sources.tsx
Normal file
114
services/frontend/src/pages/admin/Sources.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user