Add organization and task queue features
- Introduced new models for `Organization` and `ReviewTask` to manage organizations and review tasks. - Implemented API endpoints for CRUD operations on organizations and tasks, including scanning organizations for repositories and PRs. - Developed a background worker for sequential processing of review tasks with priority handling and automatic retries. - Created frontend components for managing organizations and monitoring task queues, including real-time updates and filtering options. - Added comprehensive documentation for organization features and quick start guides. - Fixed UI issues and improved navigation for better user experience.
This commit is contained in:
@@ -4,6 +4,8 @@ import Dashboard from './pages/Dashboard';
|
||||
import Repositories from './pages/Repositories';
|
||||
import Reviews from './pages/Reviews';
|
||||
import ReviewDetail from './pages/ReviewDetail';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Tasks from './pages/Tasks';
|
||||
import WebSocketStatus from './components/WebSocketStatus';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -20,7 +22,9 @@ function Navigation() {
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/', label: 'Дашборд', icon: '📊' },
|
||||
{ path: '/organizations', label: 'Организации', icon: '🏢' },
|
||||
{ path: '/repositories', label: 'Репозитории', icon: '📁' },
|
||||
{ path: '/tasks', label: 'Очередь', icon: '📝' },
|
||||
{ path: '/reviews', label: 'Ревью', icon: '🔍' },
|
||||
];
|
||||
|
||||
@@ -67,7 +71,9 @@ function AppContent() {
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/repositories" element={<Repositories />} />
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/reviews" element={<Reviews />} />
|
||||
<Route path="/reviews/:id" element={<ReviewDetail />} />
|
||||
</Routes>
|
||||
|
||||
108
frontend/src/api/organizations.ts
Normal file
108
frontend/src/api/organizations.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Organization API client
|
||||
*/
|
||||
|
||||
import {
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
OrganizationScanResult,
|
||||
TaskListResponse,
|
||||
TaskStatus,
|
||||
WorkerStatus,
|
||||
} from '../types/organization';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// Organizations
|
||||
|
||||
export async function getOrganizations(skip = 0, limit = 100): Promise<{ items: Organization[]; total: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations?skip=${skip}&limit=${limit}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch organizations');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getOrganization(id: number): Promise<Organization> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch organization');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createOrganization(data: OrganizationCreate): Promise<Organization> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create organization');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateOrganization(id: number, data: OrganizationUpdate): Promise<Organization> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to update organization');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteOrganization(id: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete organization');
|
||||
}
|
||||
|
||||
export async function scanOrganization(id: number): Promise<OrganizationScanResult> {
|
||||
const response = await fetch(`${API_BASE_URL}/organizations/${id}/scan`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to scan organization');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Tasks
|
||||
|
||||
export async function getTasks(status?: TaskStatus, skip = 0, limit = 100): Promise<TaskListResponse> {
|
||||
const params = new URLSearchParams({ skip: skip.toString(), limit: limit.toString() });
|
||||
if (status) params.append('status', status);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/tasks?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch tasks');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getWorkerStatus(): Promise<WorkerStatus> {
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/worker/status`);
|
||||
if (!response.ok) throw new Error('Failed to fetch worker status');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function retryTask(id: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${id}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to retry task');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(id: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete task');
|
||||
}
|
||||
|
||||
380
frontend/src/pages/Organizations.tsx
Normal file
380
frontend/src/pages/Organizations.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Organizations page
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getOrganizations,
|
||||
createOrganization,
|
||||
updateOrganization,
|
||||
deleteOrganization,
|
||||
scanOrganization,
|
||||
} from '../api/organizations';
|
||||
import { Organization, OrganizationCreate, OrganizationPlatform } from '../types/organization';
|
||||
import { Modal, ConfirmModal } from '../components/Modal';
|
||||
|
||||
export default function Organizations() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [modalMessage, setModalMessage] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
|
||||
const [confirmMessage, setConfirmMessage] = useState('');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: () => getOrganizations(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createOrganization,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
setIsFormOpen(false);
|
||||
setModalMessage('✅ Организация успешно добавлена');
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => updateOrganization(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
setEditingOrg(null);
|
||||
setModalMessage('✅ Организация успешно обновлена');
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrganization,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
setModalMessage('✅ Организация успешно удалена');
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: scanOrganization,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
|
||||
let message = `✅ Сканирование завершено!\n\n`;
|
||||
message += `📦 Репозиториев найдено: ${result.repositories_found}\n`;
|
||||
message += `➕ Репозиториев добавлено: ${result.repositories_added}\n`;
|
||||
message += `🔀 PR найдено: ${result.pull_requests_found}\n`;
|
||||
message += `📝 Задач создано: ${result.tasks_created}`;
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
message += `\n\n⚠️ Ошибки:\n${result.errors.join('\n')}`;
|
||||
}
|
||||
|
||||
setModalMessage(message);
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка сканирования: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (org: Organization) => {
|
||||
setConfirmMessage(`Вы уверены, что хотите удалить организацию "${org.name}"?`);
|
||||
setConfirmAction(() => () => deleteMutation.mutate(org.id));
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleScan = (org: Organization) => {
|
||||
setConfirmMessage(`Начать сканирование организации "${org.name}"?\n\nБудут найдены все репозитории и PR.`);
|
||||
setConfirmAction(() => () => scanMutation.mutate(org.id));
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Организации</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Управление организациями и автоматическое сканирование репозиториев
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
➕ Добавить организацию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Organizations list */}
|
||||
<div className="grid gap-4">
|
||||
{data?.items.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{org.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{org.platform.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>🌐 {org.base_url}</div>
|
||||
{org.last_scan_at && (
|
||||
<div>
|
||||
🔍 Последнее сканирование:{' '}
|
||||
{new Date(org.last_scan_at).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleScan(org)}
|
||||
disabled={scanMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
🔍 Сканировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingOrg(org)}
|
||||
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
✏️ Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(org)}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
||||
>
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.items.length === 0 && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Нет организаций</p>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
➕ Добавить первую организацию
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form Modal */}
|
||||
{isFormOpen && (
|
||||
<OrganizationForm
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
onCancel={() => setIsFormOpen(false)}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Form Modal */}
|
||||
{editingOrg && (
|
||||
<OrganizationForm
|
||||
organization={editingOrg}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editingOrg.id, data })}
|
||||
onCancel={() => setEditingOrg(null)}
|
||||
isSubmitting={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
||||
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
|
||||
>
|
||||
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={() => {
|
||||
if (confirmAction) confirmAction();
|
||||
setShowConfirm(false);
|
||||
}}
|
||||
title="Подтверждение"
|
||||
message={confirmMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Organization Form Component
|
||||
function OrganizationForm({
|
||||
organization,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}: {
|
||||
organization?: Organization;
|
||||
onSubmit: (data: OrganizationCreate) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState<OrganizationCreate>({
|
||||
name: organization?.name || '',
|
||||
platform: (organization?.platform as OrganizationPlatform) || 'gitea',
|
||||
base_url: organization?.base_url || '',
|
||||
api_token: '',
|
||||
webhook_secret: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{organization ? 'Редактировать организацию' : 'Новая организация'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название организации *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="inno-js"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Платформа *
|
||||
</label>
|
||||
<select
|
||||
value={formData.platform}
|
||||
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="bitbucket">Bitbucket</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Base URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={formData.base_url}
|
||||
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="https://git.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API токен
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.api_token}
|
||||
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Опционально (используется master токен если не указан)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 Если не указан, будет использован master токен из конфигурации сервера
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Webhook Secret
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.webhook_secret}
|
||||
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Опционально (генерируется автоматически)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
323
frontend/src/pages/Tasks.tsx
Normal file
323
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Tasks page - Task Queue monitoring
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTasks, getWorkerStatus, retryTask, deleteTask } from '../api/organizations';
|
||||
import { TaskStatus } from '../types/organization';
|
||||
import { Modal, ConfirmModal } from '../components/Modal';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
export default function Tasks() {
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | undefined>();
|
||||
|
||||
// Modal states
|
||||
const [modalMessage, setModalMessage] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
|
||||
const [confirmMessage, setConfirmMessage] = useState('');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const { data: tasksData, isLoading } = useQuery({
|
||||
queryKey: ['tasks', statusFilter],
|
||||
queryFn: () => getTasks(statusFilter),
|
||||
refetchInterval: 5000, // Обновление каждые 5 секунд
|
||||
});
|
||||
|
||||
const { data: workerStatus } = useQuery({
|
||||
queryKey: ['workerStatus'],
|
||||
queryFn: getWorkerStatus,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setModalMessage('✅ Задача поставлена в очередь повторно');
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setModalMessage('✅ Задача удалена');
|
||||
setShowModal(true);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||||
setShowModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRetry = (taskId: number) => {
|
||||
setConfirmMessage('Повторить выполнение задачи?');
|
||||
setConfirmAction(() => () => retryMutation.mutate(taskId));
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (taskId: number) => {
|
||||
setConfirmMessage('Удалить задачу из очереди?');
|
||||
setConfirmAction(() => () => deleteMutation.mutate(taskId));
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '⏳ Ожидает';
|
||||
case 'in_progress':
|
||||
return '⚙️ Выполняется';
|
||||
case 'completed':
|
||||
return '✅ Завершено';
|
||||
case 'failed':
|
||||
return '❌ Ошибка';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'normal':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Мониторинг и управление задачами на review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Worker Status */}
|
||||
{workerStatus && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
|
||||
<span className="font-medium">
|
||||
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{workerStatus.current_task_id && (
|
||||
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
|
||||
)}
|
||||
{!workerStatus.current_task_id && workerStatus.running && (
|
||||
<span>Ожидание задач...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{tasksData && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === undefined ? 'ring-2 ring-indigo-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter(undefined)}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div>
|
||||
<div className="text-sm text-gray-600">Всего</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div>
|
||||
<div className="text-sm text-gray-600">Ожидает</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('in_progress')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div>
|
||||
<div className="text-sm text-gray-600">Выполняется</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('completed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div>
|
||||
<div className="text-sm text-gray-600">Завершено</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('failed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div>
|
||||
<div className="text-sm text-gray-600">Ошибок</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks List */}
|
||||
<div className="space-y-3">
|
||||
{tasksData?.items.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-mono font-semibold text-gray-900">
|
||||
#{task.id}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||
{getStatusLabel(task.status)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getPriorityColor(task.priority)}`}>
|
||||
{task.priority === 'high' && '🔴 Высокий'}
|
||||
{task.priority === 'normal' && '⚪ Обычный'}
|
||||
{task.priority === 'low' && '🟢 Низкий'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">PR:</span>{' '}
|
||||
<span className="font-medium">#{task.pr_number}</span>{' '}
|
||||
<span className="text-gray-700">{task.pr_title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
|
||||
</span>
|
||||
{task.started_at && (
|
||||
<span>
|
||||
Начато: {formatDistanceToNow(new Date(task.started_at), { addSuffix: true, locale: ru })}
|
||||
</span>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<span>
|
||||
Завершено: {formatDistanceToNow(new Date(task.completed_at), { addSuffix: true, locale: ru })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700">
|
||||
<strong>Ошибка:</strong> {task.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.retry_count > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Попыток: {task.retry_count} / {task.max_retries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{(task.status === 'failed' || task.status === 'completed') && (
|
||||
<button
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
🔄 Повторить
|
||||
</button>
|
||||
)}
|
||||
{task.status !== 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleDelete(task.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tasksData?.items.length === 0 && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">
|
||||
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
||||
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
|
||||
>
|
||||
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={() => {
|
||||
if (confirmAction) confirmAction();
|
||||
setShowConfirm(false);
|
||||
}}
|
||||
title="Подтверждение"
|
||||
message={confirmMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
frontend/src/types/organization.ts
Normal file
78
frontend/src/types/organization.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Organization types for frontend
|
||||
*/
|
||||
|
||||
export type OrganizationPlatform = 'gitea' | 'github' | 'bitbucket';
|
||||
|
||||
export interface Organization {
|
||||
id: number;
|
||||
name: string;
|
||||
platform: OrganizationPlatform;
|
||||
base_url: string;
|
||||
is_active: boolean;
|
||||
last_scan_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
export interface OrganizationCreate {
|
||||
name: string;
|
||||
platform: OrganizationPlatform;
|
||||
base_url: string;
|
||||
api_token?: string;
|
||||
webhook_secret?: string;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface OrganizationUpdate {
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
api_token?: string;
|
||||
webhook_secret?: string;
|
||||
config?: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface OrganizationScanResult {
|
||||
organization_id: number;
|
||||
repositories_found: number;
|
||||
repositories_added: number;
|
||||
pull_requests_found: number;
|
||||
tasks_created: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
export type TaskPriority = 'low' | 'normal' | 'high';
|
||||
|
||||
export interface ReviewTask {
|
||||
id: number;
|
||||
pull_request_id: number;
|
||||
pr_number: number | null;
|
||||
pr_title: string | null;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
items: ReviewTask[];
|
||||
total: number;
|
||||
pending: number;
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface WorkerStatus {
|
||||
running: boolean;
|
||||
current_task_id: number | null;
|
||||
poll_interval: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user