- 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.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|
||
|