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:
Primakov Alexandr Alexandrovich
2025-10-13 00:10:04 +03:00
parent 70889421ea
commit 6ae2d0d8ec
18 changed files with 2725 additions and 3 deletions
+380
View 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>
);
}