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:
13
services/frontend/Dockerfile.dev
Normal file
13
services/frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
||||
# Dev-образ фронтенда: Vite dev-server с hot reload
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Сначала зависимости (кэш слоёв)
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
2968
services/frontend/package-lock.json
generated
Normal file
2968
services/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,28 +9,28 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.390.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"zustand": "^4.5.2",
|
||||
"axios": "^1.7.2",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.390.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"date-fns": "^3.6.0"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.12",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"postcss": "^8.4.38",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.4.0"
|
||||
"eslint": "^9.4.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +78,42 @@ export const reportsApi = {
|
||||
get: (taskId: string) =>
|
||||
api.get(`/reports/${taskId}`),
|
||||
};
|
||||
|
||||
// ─── Админка ────────────────────────────────────────────────────────────────
|
||||
export const adminApi = {
|
||||
openSession: () => api.post('/admin/session'),
|
||||
verifySession: (code: string) => api.get(`/admin/session/verify/${code}`),
|
||||
|
||||
health: () => api.get('/admin/health'),
|
||||
stats: () => api.get('/admin/stats'),
|
||||
|
||||
users: (params?: { q?: string; limit?: number; offset?: number }) =>
|
||||
api.get('/admin/users', { params }),
|
||||
updateUser: (id: number, data: { plan?: string; is_verified?: boolean; is_admin?: boolean }) =>
|
||||
api.patch(`/admin/users/${id}`, data),
|
||||
deleteUser: (id: number) => api.delete(`/admin/users/${id}`),
|
||||
|
||||
tasks: (params?: { status?: string; type?: string; user_id?: number; limit?: number; offset?: number }) =>
|
||||
api.get('/admin/tasks', { params }),
|
||||
task: (publicId: string) => api.get(`/admin/tasks/${publicId}`),
|
||||
requeueTask: (publicId: string) => api.post(`/admin/tasks/${publicId}/requeue`),
|
||||
deleteTask: (publicId: string) => api.delete(`/admin/tasks/${publicId}`),
|
||||
|
||||
documents: (params?: { q?: string; source?: string; year?: number; limit?: number; offset?: number }) =>
|
||||
api.get('/admin/documents', { params }),
|
||||
deleteDocument: (id: number) => api.delete(`/admin/documents/${id}`),
|
||||
|
||||
storage: () => api.get('/admin/storage'),
|
||||
|
||||
sources: () => api.get('/admin/sources'),
|
||||
createSource: (data: Record<string, unknown>) => api.post('/admin/sources', 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`),
|
||||
|
||||
staging: (params?: { status?: string; limit?: number; offset?: number }) =>
|
||||
api.get('/admin/staging', { params }),
|
||||
stagingDetail: (id: number) => api.get(`/admin/staging/${id}`),
|
||||
approveStaging: (id: number) => api.post(`/admin/staging/${id}/approve`),
|
||||
rejectStaging: (id: number) => api.post(`/admin/staging/${id}/reject`),
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/tasks/${task.id}`}
|
||||
to={`/tasks/${task.public_id}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-100 hover:border-brand-200 hover:shadow-sm transition-all duration-200">
|
||||
|
||||
@@ -14,6 +14,14 @@ import { Task } from './pages/Task';
|
||||
import { Pricing } from './pages/Pricing';
|
||||
import { Login } from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||
import { Dashboard } from './pages/admin/Dashboard';
|
||||
import { Users as AdminUsers } from './pages/admin/Users';
|
||||
import { Works as AdminWorks } from './pages/admin/Works';
|
||||
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 './index.css';
|
||||
|
||||
@@ -26,23 +34,51 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
// Маршруты пользовательской части (обёрнуты в общий Layout)
|
||||
function PublicApp() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/check" element={<Check />} />
|
||||
<Route path="/bibliography" element={<Bibliography />} />
|
||||
<Route path="/cabinet" element={<Cabinet />} />
|
||||
<Route path="/tasks/:taskId" element={<Task />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/check" element={<Check />} />
|
||||
<Route path="/bibliography" element={<Bibliography />} />
|
||||
<Route path="/cabinet" element={<Cabinet />} />
|
||||
<Route path="/tasks/:taskId" element={<Task />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
{/* Админка — собственный layout, защита по is_admin + коду сессии */}
|
||||
<Route
|
||||
path="/admin/:code/*"
|
||||
element={
|
||||
<AdminLayout>
|
||||
<Routes>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="works" element={<AdminWorks />} />
|
||||
<Route path="documents" element={<AdminDocuments />} />
|
||||
<Route path="storage" element={<AdminStorage />} />
|
||||
<Route path="sources" element={<AdminSources />} />
|
||||
<Route path="staging" element={<AdminStaging />} />
|
||||
<Route path="*" element={<Dashboard />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
}
|
||||
/>
|
||||
{/* Всё остальное — пользовательская часть */}
|
||||
<Route path="/*" element={<PublicApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Crown, Search, Shield, BookOpen } from 'lucide-react';
|
||||
import { Crown, Search, Shield, BookOpen, ShieldAlert } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { TaskCard } from '../components/TaskCard';
|
||||
import { tasksApi } from '../api/client';
|
||||
import { tasksApi, adminApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { PLAN_LIMITS, type Task } from '../types';
|
||||
|
||||
@@ -16,6 +17,16 @@ const PLAN_BADGE_STYLES = {
|
||||
|
||||
export function Cabinet() {
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const openAdmin = async () => {
|
||||
try {
|
||||
const { data } = await adminApi.openSession();
|
||||
navigate(`${data.url}/dashboard`);
|
||||
} catch {
|
||||
toast.error('Не удалось открыть админ-панель');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
@@ -49,9 +60,20 @@ export function Cabinet() {
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">{user?.email}</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
Обновить план
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.is_admin && (
|
||||
<button
|
||||
onClick={openAdmin}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
Админ-панель
|
||||
</button>
|
||||
)}
|
||||
<button className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
Обновить план
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +138,7 @@ export function Cabinet() {
|
||||
{tasks && tasks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
<TaskCard key={task.public_id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -53,14 +53,14 @@ export function Check() {
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Проверка запущена</h2>
|
||||
<p className="text-gray-500 mb-2">
|
||||
Файл <strong>{(submittedTask.input_data as any).filename}</strong> передан на проверку.
|
||||
Файл <strong>{file?.name ?? 'документ'}</strong> передан на проверку.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Вы получите email когда проверка завершится. Обычно это занимает 1-3 минуты.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
to={`/tasks/${submittedTask.id}`}
|
||||
to={`/tasks/${submittedTask.public_id}`}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Следить за прогрессом
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Search() {
|
||||
mutationFn: searchApi.create,
|
||||
onSuccess: (response) => {
|
||||
const task: Task = response.data;
|
||||
setTaskId(task.id);
|
||||
setTaskId(task.public_id);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg = error.response?.data?.detail || 'Ошибка поиска';
|
||||
|
||||
@@ -60,7 +60,7 @@ export function Task() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={task.status} />
|
||||
<span className="text-xs text-gray-400">{task.id}</span>
|
||||
<span className="text-xs text-gray-400">{task.public_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,8 +91,7 @@ export function Task() {
|
||||
{task.status === 'done' && task.type === 'search' && task.result && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Запрос: <strong>«{(task.input_data as any).query}»</strong>
|
||||
{' · '}{(task.result as SearchResultData).total} источников
|
||||
Найдено {(task.result as SearchResultData).total} источников
|
||||
</p>
|
||||
{(task.result as SearchResultData).sources.map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
@@ -104,7 +103,7 @@ export function Task() {
|
||||
{task.status === 'done' && task.type === 'plagiarism' && task.result && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">
|
||||
Файл: {(task.input_data as any).filename}
|
||||
Результат проверки плагиата
|
||||
</h2>
|
||||
<PlagiarismReport data={task.result as PlagiarismResultData} />
|
||||
</div>
|
||||
|
||||
102
services/frontend/src/pages/admin/AdminLayout.tsx
Normal file
102
services/frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
|
||||
const NAV = [
|
||||
{ to: 'dashboard', label: 'Дашборд', icon: LayoutDashboard },
|
||||
{ to: 'users', label: 'Клиенты', icon: Users },
|
||||
{ to: 'works', label: 'Работы', icon: FileText },
|
||||
{ to: 'documents', label: 'База', icon: Database },
|
||||
{ to: 'storage', label: 'Хранилище', icon: HardDrive },
|
||||
{ to: 'sources', label: 'Источники', icon: Download },
|
||||
{ to: 'staging', label: 'Отстойник', icon: Inbox },
|
||||
];
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const { isAuthenticated, user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Доступ: только аутентифицированный админ
|
||||
const { data: verify, isLoading } = useQuery({
|
||||
queryKey: ['admin-verify', code],
|
||||
queryFn: () => adminApi.verifySession(code!).then((r) => r.data),
|
||||
enabled: !!code && isAuthenticated && !!user?.is_admin,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!isAuthenticated || !user?.is_admin) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-gray-400">Проверка доступа…</div>;
|
||||
}
|
||||
|
||||
if (!verify?.valid) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gray-50">
|
||||
<ShieldAlert className="w-12 h-12 text-red-400" />
|
||||
<p className="text-gray-600">Код доступа недействителен или истёк.</p>
|
||||
<button
|
||||
onClick={() => navigate('/cabinet')}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm"
|
||||
>
|
||||
В кабинет
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gray-50">
|
||||
{/* Сайдбар */}
|
||||
<aside className="w-56 bg-gray-900 text-gray-300 flex flex-col fixed inset-y-0 left-0">
|
||||
<div className="px-5 py-5 border-b border-gray-800 flex items-center gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-amber-400" />
|
||||
<span className="font-semibold text-white">Админ-панель</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={`/admin/${code}/${to}`}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-brand-600 text-white' : 'hover:bg-gray-800 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 py-4 border-t border-gray-800">
|
||||
<div className="px-3 mb-2 text-xs text-gray-500">{user?.email}</div>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/'); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Контент */}
|
||||
<main className="flex-1 ml-56 p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
services/frontend/src/pages/admin/Dashboard.tsx
Normal file
119
services/frontend/src/pages/admin/Dashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CheckCircle2, XCircle, Users, FileText, Database, Inbox } from 'lucide-react';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface Health { name: string; ok: boolean; detail?: string }
|
||||
interface Stats {
|
||||
users_total: number;
|
||||
tasks_by_status: Record<string, number>;
|
||||
documents_total: number;
|
||||
documents_by_source: Record<string, number>;
|
||||
staging_pending: number;
|
||||
storage: Record<string, { objects: number; bytes: number } | unknown>;
|
||||
queues: Record<string, number | string>;
|
||||
}
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
if (b < 1024) return `${b} Б`;
|
||||
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
|
||||
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} МБ`;
|
||||
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['admin-health'],
|
||||
queryFn: () => adminApi.health().then((r) => r.data as Health[]),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-stats'],
|
||||
queryFn: () => adminApi.stats().then((r) => r.data as Stats),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const totalStorage = Object.values(stats?.storage || {}).reduce(
|
||||
(acc: number, v) => acc + (typeof v === 'object' && v && 'bytes' in v ? (v as { bytes: number }).bytes : 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Дашборд</h1>
|
||||
|
||||
{/* Счётчики */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Users} label="Клиентов" value={stats?.users_total ?? '—'} color="text-blue-600" />
|
||||
<StatCard icon={FileText} label="Работ всего" value={Object.values(stats?.tasks_by_status || {}).reduce((a, b) => a + b, 0)} color="text-purple-600" />
|
||||
<StatCard icon={Database} label="Документов в базе" value={stats?.documents_total ?? '—'} color="text-emerald-600" />
|
||||
<StatCard icon={Inbox} label="В отстойнике" value={stats?.staging_pending ?? '—'} color="text-amber-600" />
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Здоровье сервисов */}
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-4">Сервисы</h2>
|
||||
<div className="space-y-2">
|
||||
{health?.map((h) => (
|
||||
<div key={h.name} className="flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{h.ok ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> : <XCircle className="w-4 h-4 text-red-500" />}
|
||||
<span className="text-sm text-gray-700">{h.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${h.ok ? 'text-gray-400' : 'text-red-500'}`}>{h.detail || (h.ok ? 'OK' : 'недоступен')}</span>
|
||||
</div>
|
||||
))}
|
||||
{!health && <div className="text-sm text-gray-400">Загрузка…</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Работы по статусам + очереди */}
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Работы по статусам</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats?.tasks_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>
|
||||
))}
|
||||
{!Object.keys(stats?.tasks_by_status || {}).length && <span className="text-sm text-gray-400">нет данных</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Очереди</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats?.queues || {}).map(([q, n]) => (
|
||||
<span key={q} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{q}: <b>{String(n)}</b></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-800 mb-2">Хранилище</h2>
|
||||
<p className="text-sm text-gray-600">Всего: <b>{fmtBytes(totalStorage)}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</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="flex flex-wrap gap-2">
|
||||
{Object.entries(stats?.documents_by_source || {}).map(([s, c]) => (
|
||||
<span key={s} className="px-3 py-1 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{s}: <b>{c}</b></span>
|
||||
))}
|
||||
{!Object.keys(stats?.documents_by_source || {}).length && <span className="text-sm text-gray-400">база пуста</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: React.ReactNode; color: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5">
|
||||
<Icon className={`w-6 h-6 ${color} mb-2`} />
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
services/frontend/src/pages/admin/Documents.tsx
Normal file
71
services/frontend/src/pages/admin/Documents.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2, Search } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface Doc {
|
||||
id: number; source: string; title: string; authors: Array<{ last_name?: string }>;
|
||||
year: number | null; lang: string | null; url: string | null; indexed_at: string;
|
||||
}
|
||||
|
||||
export function Documents() {
|
||||
const qc = useQueryClient();
|
||||
const [q, setQ] = useState('');
|
||||
const [source, setSource] = useState('');
|
||||
const { data: docs } = useQuery({
|
||||
queryKey: ['admin-docs', q, source],
|
||||
queryFn: () => adminApi.documents({ q: q || undefined, source: source || undefined }).then((r) => r.data as Doc[]),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deleteDocument(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-docs'] }); toast.success('Удалён из базы'); },
|
||||
onError: () => toast.error('Ошибка'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-bold text-gray-900">База документов</h1>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск по названию"
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
<select value={source} onChange={(e) => setSource(e.target.value)}
|
||||
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">все источники</option>
|
||||
<option value="openalex">openalex</option>
|
||||
<option value="cyberleninka">cyberleninka</option>
|
||||
<option value="arxiv">arxiv</option>
|
||||
<option value="user_submission">работы пользователей</option>
|
||||
</select>
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{docs?.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800 max-w-md truncate">{d.title}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-xs">{d.source}</span></td>
|
||||
<td className="px-4 py-3 text-gray-600">{d.year || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{d.lang || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => { if (confirm('Удалить документ из базы?')) del.mutate(d.id); }}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!docs?.length && <div className="p-6 text-center text-gray-400 text-sm">База пуста</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
119
services/frontend/src/pages/admin/Staging.tsx
Normal file
119
services/frontend/src/pages/admin/Staging.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, X, Eye } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface Staged {
|
||||
id: number; user_id: number | null; filename: string | null; title: string | null;
|
||||
word_count: number | null; status: string; document_id: number | null; created_at: string;
|
||||
}
|
||||
interface StagedDetail extends Staged { text_preview: string | null }
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export function Staging() {
|
||||
const qc = useQueryClient();
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [preview, setPreview] = useState<StagedDetail | null>(null);
|
||||
|
||||
const { data: items } = useQuery({
|
||||
queryKey: ['admin-staging', status],
|
||||
queryFn: () => adminApi.staging({ status }).then((r) => r.data as Staged[]),
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: number) => adminApi.approveStaging(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-staging'] }); setPreview(null); toast.success('Одобрено — добавляется в базу'); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: (id: number) => adminApi.rejectStaging(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-staging'] }); setPreview(null); toast.success('Отклонено'); },
|
||||
});
|
||||
|
||||
const openPreview = async (id: number) => {
|
||||
const { data } = await adminApi.stagingDetail(id);
|
||||
setPreview(data as StagedDetail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Отстойник проверенных работ</h1>
|
||||
<p className="text-sm text-gray-500">Работы, прошедшие проверку. Одобрение добавляет их в базу источников для будущих сравнений.</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['pending', 'approved', 'rejected', 'all'].map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm ${status === s ? 'bg-brand-600 text-white' : 'bg-white border border-gray-200 text-gray-600'}`}>
|
||||
{s === 'all' ? 'все' : s}
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{items?.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800 max-w-xs truncate">{s.filename || s.title || `#${s.id}`}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{s.user_id ? `#${s.user_id}` : '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{s.word_count ?? '—'}</td>
|
||||
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.status]}`}>{s.status}</span></td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">{new Date(s.created_at).toLocaleString('ru')}</td>
|
||||
<td className="px-4 py-3 flex gap-1">
|
||||
<button onClick={() => openPreview(s.id)} title="Просмотр"
|
||||
className="p-1.5 text-gray-400 hover:text-brand-600 rounded"><Eye className="w-4 h-4" /></button>
|
||||
{s.status === 'pending' && (
|
||||
<>
|
||||
<button onClick={() => approve.mutate(s.id)} title="Одобрить"
|
||||
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded"><Check className="w-4 h-4" /></button>
|
||||
<button onClick={() => reject.mutate(s.id)} title="Отклонить"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><X className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!items?.length && <div className="p-6 text-center text-gray-400 text-sm">Пусто</div>}
|
||||
</div>
|
||||
|
||||
{/* Превью */}
|
||||
{preview && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-6" onClick={() => setPreview(null)}>
|
||||
<div className="bg-white rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800 truncate">{preview.filename || preview.title}</h3>
|
||||
<button onClick={() => setPreview(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 overflow-auto text-sm text-gray-700 whitespace-pre-wrap flex-1">
|
||||
{preview.text_preview || '(нет текста)'}
|
||||
</div>
|
||||
{preview.status === 'pending' && (
|
||||
<div className="px-5 py-4 border-t border-gray-100 flex gap-2 justify-end">
|
||||
<button onClick={() => reject.mutate(preview.id)}
|
||||
className="px-4 py-2 border border-gray-200 text-gray-600 rounded-lg text-sm">Отклонить</button>
|
||||
<button onClick={() => approve.mutate(preview.id)}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm">Одобрить → в базу</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
services/frontend/src/pages/admin/Storage.tsx
Normal file
57
services/frontend/src/pages/admin/Storage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface Bucket {
|
||||
name: string; objects_total?: number; bytes?: number;
|
||||
sample?: Array<{ name: string; size: number }>; error?: string;
|
||||
}
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
if (b < 1024) return `${b} Б`;
|
||||
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
|
||||
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} МБ`;
|
||||
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
|
||||
}
|
||||
|
||||
export function Storage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-storage'],
|
||||
queryFn: () => adminApi.storage().then((r) => r.data as { buckets: Bucket[] }),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Хранилище (MinIO)</h1>
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{data?.buckets.map((b) => (
|
||||
<div key={b.name} className="bg-white rounded-xl border border-gray-100 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="w-5 h-5 text-brand-600" />
|
||||
<span className="font-semibold text-gray-800">{b.name}</span>
|
||||
</div>
|
||||
{b.error ? (
|
||||
<p className="text-sm text-red-500">{b.error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">Объектов: <b>{b.objects_total}</b></p>
|
||||
<p className="text-sm text-gray-600">Объём: <b>{fmtBytes(b.bytes || 0)}</b></p>
|
||||
<div className="mt-3 max-h-40 overflow-auto space-y-1">
|
||||
{b.sample?.map((o) => (
|
||||
<div key={o.name} className="flex justify-between text-xs text-gray-400">
|
||||
<span className="truncate max-w-[160px]">{o.name}</span>
|
||||
<span>{fmtBytes(o.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!data?.buckets.length && <div className="text-gray-400 text-sm">Нет бакетов</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
services/frontend/src/pages/admin/Users.tsx
Normal file
92
services/frontend/src/pages/admin/Users.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2, Search } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface AdminUser {
|
||||
id: number; email: string; name: string; plan: string;
|
||||
is_verified: boolean; is_admin: boolean; created_at: string; tasks_count: number;
|
||||
}
|
||||
|
||||
const PLANS = ['free', 'student', 'premium', 'science'];
|
||||
|
||||
export function Users() {
|
||||
const qc = useQueryClient();
|
||||
const [q, setQ] = useState('');
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['admin-users', q],
|
||||
queryFn: () => adminApi.users({ q: q || undefined }).then((r) => r.data as AdminUser[]),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => adminApi.updateUser(id, data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-users'] }); toast.success('Сохранено'); },
|
||||
onError: () => toast.error('Ошибка'),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deleteUser(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-users'] }); toast.success('Удалён'); },
|
||||
onError: () => toast.error('Ошибка удаления'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Клиенты</h1>
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск по email/имени"
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
</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">Email</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">Verified</th><th className="px-4 py-3">Admin</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{users?.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800">{u.email}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{u.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={u.plan}
|
||||
onChange={(e) => update.mutate({ id: u.id, data: { plan: e.target.value } })}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{PLANS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{u.tasks_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={u.is_verified}
|
||||
onChange={(e) => update.mutate({ id: u.id, data: { is_verified: e.target.checked } })} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={u.is_admin}
|
||||
onChange={(e) => update.mutate({ id: u.id, data: { is_admin: e.target.checked } })} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => { if (confirm(`Удалить ${u.email}?`)) del.mutate(u.id); }}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!users?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет клиентов</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
services/frontend/src/pages/admin/Works.tsx
Normal file
81
services/frontend/src/pages/admin/Works.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminApi } from '../../api/client';
|
||||
|
||||
interface AdminTask {
|
||||
public_id: string; user_id: number; type: string; status: string;
|
||||
error: string | null; created_at: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: 'bg-gray-100 text-gray-600',
|
||||
processing: 'bg-blue-100 text-blue-700',
|
||||
done: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export function Works() {
|
||||
const qc = useQueryClient();
|
||||
const [status, setStatus] = useState('');
|
||||
const { data: tasks } = useQuery({
|
||||
queryKey: ['admin-tasks', status],
|
||||
queryFn: () => adminApi.tasks({ status: status || undefined }).then((r) => r.data as AdminTask[]),
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const requeue = useMutation({
|
||||
mutationFn: (id: string) => adminApi.requeueTask(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-tasks'] }); toast.success('Перезапущено'); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteTask(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-tasks'] }); toast.success('Удалено'); },
|
||||
onError: () => toast.error('Ошибка'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Работы</h1>
|
||||
<div className="flex gap-2">
|
||||
{['', 'queued', 'processing', 'done', 'failed'].map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm ${status === s ? 'bg-brand-600 text-white' : 'bg-white border border-gray-200 text-gray-600'}`}>
|
||||
{s || 'все'}
|
||||
</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">ID</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">
|
||||
{tasks?.map((t) => (
|
||||
<tr key={t.public_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{t.public_id.slice(0, 10)}…</td>
|
||||
<td className="px-4 py-3 text-gray-600">#{t.user_id}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{t.type}</td>
|
||||
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[t.status] || 'bg-gray-100'}`}>{t.status}</span></td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">{new Date(t.created_at).toLocaleString('ru')}</td>
|
||||
<td className="px-4 py-3 flex gap-1">
|
||||
<button onClick={() => requeue.mutate(t.public_id)} title="Перезапустить"
|
||||
className="p-1.5 text-gray-400 hover:text-brand-600 rounded"><RefreshCw className="w-4 h-4" /></button>
|
||||
<button onClick={() => { if (confirm('Удалить работу?')) del.mutate(t.public_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>
|
||||
{!tasks?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет работ</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface User {
|
||||
name: string;
|
||||
plan: UserPlan;
|
||||
is_verified: boolean;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
// ─── Источник в результатах поиска ────────────────────────────────────────────
|
||||
@@ -88,12 +89,12 @@ export interface GostResultData {
|
||||
export type TaskResult = SearchResultData | PlagiarismResultData | GostResultData | null;
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
// Бэкенд отдаёт public_id (а не внутренний id) и не возвращает input_data
|
||||
public_id: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
queue_position?: number;
|
||||
eta_seconds?: number;
|
||||
input_data: Record<string, unknown>;
|
||||
result?: TaskResult;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:8000';
|
||||
const wsTarget = apiTarget.replace(/^http/, 'ws');
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
target: wsTarget,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user