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:
jze9
2026-05-30 20:45:53 +05:00
parent 9ee5ccdc3f
commit 9894fa9320
50 changed files with 5680 additions and 58 deletions

View 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>
);
}