diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5ff5d6..42718ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,511 +1,1023 @@ -# Contributing to Ospab Host 8.1 +# 🤝 Руководство по внесению вклада в Ospabhost 8.1 -Спасибо за интерес к проекту! Мы рады любому вкладу в развитие платформы. +Спасибо за интерес к улучшению проекта! Этот документ описывает процесс внесения изменений. -## Оглавление +--- -1. [Кодекс поведения](#кодекс-поведения) -2. [Как внести вклад](#как-внести-вклад) -3. [Процесс разработки](#процесс-разработки) -4. [Стандарты кода](#стандарты-кода) -5. [Коммиты и Pull Requests](#коммиты-и-pull-requests) -6. [Тестирование](#тестирование) -7. [Документация](#документация) +## 📋 Содержание -## Кодекс поведения +- [Кодекс поведения](#кодекс-поведения) +- [С чего начать](#с-чего-начать) +- [Процесс разработки](#процесс-разработки) +- [Стандарты кода](#стандарты-кода) +- [Коммиты](#коммиты) +- [Pull Requests](#pull-requests) +- [Архитектурные решения](#архитектурные-решения) +- [Тестирование](#тестирование) +- [Документация](#документация) + +--- + +## 📜 Кодекс поведения ### Наши обязательства -Мы стремимся создать открытое и дружелюбное сообщество. Мы обязуемся: +- Уважительное отношение ко всем участникам +- Конструктивная критика +- Фокус на улучшении проекта +- Помощь новичкам -- Использовать уважительный и профессиональный язык -- Уважать различные точки зрения и опыт -- Принимать конструктивную критику -- Фокусироваться на лучшем решении для сообщества -- Проявлять эмпатию к другим участникам +### Недопустимое поведение -### Неприемлемое поведение - -- Оскорбительные комментарии -- Домогательства в любой форме +- Оскорбления и агрессия +- Троллинг и спам +- Дискриминация любого рода - Публикация личной информации без разрешения -- Троллинг и провокации -- Другое неэтичное поведение -## Как внести вклад +--- -### Сообщение об ошибках +## 🚀 С чего начать -Перед созданием issue убедитесь: +### Для новичков -1. Ошибка воспроизводится на последней версии -2. Похожего issue еще нет -3. У вас есть вся необходимая информация +Ищите issues с метками: +- `good first issue` - простые задачи для начинающих +- `help wanted` - задачи, где нужна помощь +- `documentation` - улучшение документации -**Шаблон сообщения об ошибке:** +### Подготовка окружения -```markdown -## Описание -Краткое описание ошибки +1. **Форк репозитория** -## Шаги воспроизведения -1. Перейти на... -2. Нажать на... -3. Увидеть ошибку... +Нажмите кнопку "Fork" на GitHub. -## Ожидаемое поведение -Что должно произойти - -## Фактическое поведение -Что произошло на самом деле - -## Окружение -- OS: [e.g. Ubuntu 22.04] -- Node.js: [e.g. 18.19.0] -- Browser: [e.g. Chrome 120] -- Version: [e.g. 8.1.0] - -## Скриншоты -Если применимо - -## Дополнительная информация -Логи, stack traces и т.д. -``` - -### Предложение улучшений - -**Шаблон feature request:** - -```markdown -## Проблема -Какую проблему решает это улучшение? - -## Предлагаемое решение -Подробное описание решения - -## Альтернативы -Рассмотренные альтернативные решения - -## Дополнительный контекст -Скриншоты, примеры, ссылки -``` - -### Pull Requests - -1. Fork репозитория -2. Создайте feature ветку (`git checkout -b feature/AmazingFeature`) -3. Зафиксируйте изменения (`git commit -m 'Add some AmazingFeature'`) -4. Push в ветку (`git push origin feature/AmazingFeature`) -5. Откройте Pull Request - -## Процесс разработки - -### Настройка окружения - -1. **Установка зависимостей** +2. **Клонирование** ```bash -# Клонируйте репозиторий -git clone https://github.com/Ospab/ospabhost8.1.git +git clone https://github.com/YOUR_USERNAME/ospabhost8.1.git cd ospabhost8.1/ospabhost +``` +3. **Добавление upstream** + +```bash +git remote add upstream https://github.com/Ospab/ospabhost8.1.git +``` + +4. **Установка зависимостей** + +```bash # Backend cd backend npm install +cp .env.example .env +# Настройте .env + +# Миграции +npx prisma migrate dev npx prisma generate +npx prisma db seed # Frontend cd ../frontend npm install -``` - -2. **Настройка окружения** - -```bash -# Backend .env -cd backend cp .env.example .env -# Заполните необходимые переменные ``` -3. **Запуск в режиме разработки** +5. **Запуск** ```bash -# Terminal 1 - Backend +# Terminal 1: Backend cd backend npm run dev -# Terminal 2 - Frontend +# Terminal 2: Frontend cd frontend npm run dev ``` -### Структура веток +--- -- `main` - стабильная production ветка -- `develop` - активная разработка -- `feature/*` - новые функции -- `bugfix/*` - исправление ошибок -- `hotfix/*` - срочные исправления для production +## 🔄 Процесс разработки -### Git Flow +### 1. Синхронизация с upstream -``` -main - └─ develop - ├─ feature/new-feature - ├─ bugfix/fix-something - └─ hotfix/urgent-fix +```bash +git checkout main +git fetch upstream +git merge upstream/main +git push origin main ``` -## Стандарты кода +### 2. Создание ветки + +```bash +git checkout -b feature/your-feature-name +``` + +**Префиксы веток:** +- `feature/` - новая функциональность +- `fix/` - исправление бага +- `refactor/` - рефакторинг кода +- `docs/` - изменения в документации +- `test/` - добавление тестов +- `chore/` - рутинные задачи + +**Примеры:** +```bash +git checkout -b feature/add-user-notifications +git checkout -b fix/ticket-assignment-bug +git checkout -b docs/update-api-documentation +``` + +### 3. Разработка + +Внесите изменения, следуя [стандартам кода](#стандарты-кода). + +### 4. Коммиты + +```bash +git add . +git commit -m "feat: add user notifications" +``` + +См. [раздел о коммитах](#коммиты) для подробностей. + +### 5. Пуш + +```bash +git push origin feature/your-feature-name +``` + +### 6. Pull Request + +Откройте PR на GitHub, следуя [шаблону](#pull-requests). + +--- + +## 📝 Стандарты кода ### TypeScript/JavaScript -**Основные правила:** +#### Общие правила -- Используйте TypeScript для типобезопасности -- Избегайте `any`, используйте конкретные типы -- Функции должны быть чистыми где возможно -- Один компонент/функция = одна ответственность -- Максимальная длина файла - 300 строк +- ✅ Используйте TypeScript везде +- ✅ Строгая типизация (`strict: true`) +- ❌ Избегайте `any` (используйте `unknown` при необходимости) +- ✅ Используйте `const` и `let`, не `var` +- ✅ Предпочитайте стрелочные функции +- ✅ Асинхронный код через `async/await` -**Именование:** +#### Именование ```typescript -// Константы - UPPER_SNAKE_CASE -const MAX_RETRIES = 3; -const API_BASE_URL = 'https://api.example.com'; +// ✅ Хорошо +const userName = 'John'; +const getUserById = async (id: number) => { ... }; +class UserService { ... } +interface UserData { ... } +type UserId = number; -// Переменные и функции - camelCase -const userData = getUserData(); -function calculateTotal(items) { } - -// Классы и компоненты - PascalCase -class UserService { } -const LoginPage = () => { }; - -// Приватные поля - с underscore -class Example { - private _internalState: string; -} - -// Boolean переменные - is/has/should префиксы -const isLoading = true; -const hasPermission = false; -const shouldUpdate = true; +// ❌ Плохо +const username = 'John'; // camelCase для переменных +const get_user_by_id = () => { ... }; // не snake_case +class userService { ... } // PascalCase для классов ``` -**Комментарии:** +#### Функции и методы ```typescript -// Плохо - очевидное -const price = 100; // Устанавливаем цену - -// Хорошо - объясняем "почему" -// Используем кеш для снижения нагрузки на API -const cachedData = getFromCache(); - -/** - * Вычисляет финальную цену с учетом скидок и налогов - * @param basePrice Базовая цена товара - * @param discountPercent Процент скидки (0-100) - * @returns Финальная цена - */ -function calculateFinalPrice(basePrice: number, discountPercent: number): number { - // Реализация -} -``` - -### React компоненты - -**Структура компонента:** - -```typescript -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; - -// 1. Типы -interface Props { - userId: number; - onUpdate?: () => void; -} - -// 2. Константы -const DEFAULT_TIMEOUT = 5000; - -// 3. Компонент -const UserProfile: React.FC = ({ userId, onUpdate }) => { - // 3.1 Hooks - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - - // 3.2 Effects - useEffect(() => { - fetchUserData(); - }, [userId]); - - // 3.3 Handlers - const handleUpdate = async () => { - // Логика - }; - - // 3.4 Render helpers - if (loading) { - return
Loading...
; +// ✅ Хорошо +async function getUserById(id: number): Promise { + try { + const user = await prisma.user.findUnique({ where: { id } }); + return user; + } catch (error) { + console.error('Error fetching user:', error); + throw new Error('Failed to fetch user'); } - - // 3.5 Main render +} + +// ❌ Плохо +function getUserById(id: any) { // any запрещён + const user = prisma.user.findUnique({ where: { id } }); // нет await + return user; // нет обработки ошибок +} +``` + +#### Обработка ошибок + +```typescript +// ✅ Хорошо +try { + const result = await riskyOperation(); + return res.json(result); +} catch (error) { + console.error('[Module] Error:', error); + const message = error instanceof Error + ? error.message + : 'Unknown error'; + return res.status(500).json({ error: message }); +} + +// ❌ Плохо +try { + const result = await riskyOperation(); + return res.json(result); +} catch (error) { + return res.status(500).json({ error: error }); // может быть не Error +} +``` + +#### Express Controllers + +```typescript +// ✅ Хорошо +export async function createServer(req: Request, res: Response) { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { osId, tariffId } = req.body; + if (!osId || !tariffId) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const server = await serverService.createServer({ + userId, + osId: Number(osId), + tariffId: Number(tariffId), + }); + + return res.json({ server }); + } catch (error) { + console.error('[Server] Create error:', error); + const message = error instanceof Error ? error.message : 'Server creation failed'; + return res.status(500).json({ error: message }); + } +} +``` + +### React/Frontend + +#### Компоненты + +```typescript +// ✅ Хорошо - функциональный компонент с типизацией +interface UserCardProps { + user: User; + onEdit: (userId: number) => void; +} + +const UserCard: React.FC = ({ user, onEdit }) => { + const [isLoading, setIsLoading] = useState(false); + + const handleEdit = useCallback(() => { + onEdit(user.id); + }, [user.id, onEdit]); + return ( -
- {/* JSX */} +
+

{user.name}

+
); }; -// 4. Export -export default UserProfile; +export default UserCard; ``` -**Хуки правила:** - -- Используйте хуки только на верхнем уровне -- Создавайте custom hooks для повторяющейся логики -- Мемоизируйте тяжелые вычисления (`useMemo`) -- Оптимизируйте callbacks (`useCallback`) - -### CSS/Tailwind - -**Tailwind классы:** - -```tsx -// Плохо - слишком длинный inline -
- -// Хорошо - группировка или extracted component -const buttonClasses = "flex items-center justify-between px-4 py-2 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition-colors duration-200"; -
- -// Еще лучше - отдельный компонент -
)} +
+ +
+ setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" /> + +
+ {promoError &&
{promoError}
} + {promoApplied && !promoError &&
Промокод применён
} +
+
diff --git a/ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx b/ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx index a39d38d..fe5de41 100644 --- a/ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx +++ b/ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { isAxiosError } from 'axios'; import { FiArrowLeft, @@ -14,7 +14,11 @@ import { FiHelpCircle, FiSettings, FiFolder, + FiFolderMinus, + FiFile, FiBarChart2, + FiChevronRight, + FiChevronDown, } from 'react-icons/fi'; import apiClient from '../../utils/apiClient'; import { useToast } from '../../hooks/useToast'; @@ -38,6 +42,16 @@ interface CreatedKey { label?: string | null; } +// Тип для дерева файлов (проводник) +interface FileTreeNode { + name: string; + path: string; + isFolder: boolean; + size: number; + lastModified?: string; + children: Record; +} + interface UploadProgress { loaded: number; total: number; @@ -76,15 +90,164 @@ type LoadObjectsOptions = { prefix?: string; }; -type ConsoleCredentials = { - login: string; - password: string; - url?: string | null; -}; +// Компонент для отображения дерева файлов (проводник) +interface FileTreeViewProps { + node: FileTreeNode; + depth: number; + expandedFolders: Record; + toggleFolder: (path: string) => void; + selectedKeys: Record; + handleToggleSelection: (key: string) => void; + handleDownloadObject: (object: StorageObject) => void; + formatBytes: (bytes: number) => string; + formatDate: (value?: string | null, withTime?: boolean) => string; + objects: StorageObject[]; +} -type BucketLocationState = { - consoleCredentials?: ConsoleCredentials; - bucketName?: string; +const FileTreeView: React.FC = ({ + node, + depth, + expandedFolders, + toggleFolder, + selectedKeys, + handleToggleSelection, + handleDownloadObject, + formatBytes, + formatDate, + objects, +}) => { + const children = Object.values(node.children); + + // Сортируем: сначала папки, потом файлы, алфавитно + const sortedChildren = children.sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return a.name.localeCompare(b.name); + }); + + // Для корневой ноды (depth 0) отображаем только детей + if (depth === 0) { + return ( + <> + {sortedChildren.map((child) => ( + + ))} + + ); + } + + const isExpanded = expandedFolders[node.path] ?? false; + const paddingLeft = (depth - 1) * 20 + 12; + + if (node.isFolder) { + // Считаем общий размер папки + const folderSize = objects + .filter((obj) => obj.key.startsWith(node.path + '/')) + .reduce((acc, obj) => acc + obj.size, 0); + + // Считаем количество файлов в папке + const fileCount = objects.filter((obj) => obj.key.startsWith(node.path + '/')).length; + + return ( + <> +
toggleFolder(node.path)} + > + + {isExpanded ? : } + + + + + + {node.name} + ({fileCount} файл.) + + {formatBytes(folderSize)} + + +
+ {isExpanded && ( + <> + {sortedChildren.map((child) => ( + + ))} + + )} + + ); + } + + // Файл + const storageObject = objects.find((obj) => obj.key === node.path); + + return ( +
+ + { + e.stopPropagation(); + handleToggleSelection(node.path); + }} + className="w-4 h-4" + /> + + + + + {node.name} + {formatBytes(node.size)} + + {node.lastModified ? formatDate(node.lastModified, true) : '—'} + + + {storageObject && ( + + )} + +
+ ); }; const StorageBucketPage: React.FC = () => { @@ -92,7 +255,6 @@ const StorageBucketPage: React.FC = () => { const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN; const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0; - const location = useLocation(); const navigate = useNavigate(); const { addToast } = useToast(); @@ -126,6 +288,9 @@ const StorageBucketPage: React.FC = () => { const [uriUploadLoading, setUriUploadLoading] = useState(false); const [resumedFiles, setResumedFiles] = useState([]); const uploadAbortControllerRef = useRef(null); + + // Для проводника - какие папки развёрнуты + const [expandedFolders, setExpandedFolders] = useState>({}); const [accessKeys, setAccessKeys] = useState([]); const [accessKeysLoading, setAccessKeysLoading] = useState(false); @@ -141,12 +306,70 @@ const StorageBucketPage: React.FC = () => { const selectedCount = selectedList.length; const allSelected = objects.length > 0 && objects.every((object) => selectedKeys[object.key]); - const [consoleCredentials, setConsoleCredentials] = useState(() => { - const state = location.state as BucketLocationState | undefined; - return state?.consoleCredentials ?? null; - }); - const [consoleCredentialsLoading, setConsoleCredentialsLoading] = useState(false); - const [consoleCredentialsError, setConsoleCredentialsError] = useState(null); + // Строим дерево файлов для проводника + const fileTree = useMemo(() => { + const root: FileTreeNode = { + name: '', + path: '', + isFolder: true, + size: 0, + children: {}, + }; + + for (const obj of objects) { + const parts = obj.key.split('/').filter(Boolean); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const currentPath = parts.slice(0, i + 1).join('/'); + + if (!current.children[part]) { + current.children[part] = { + name: part, + path: currentPath, + isFolder: !isLast, + size: isLast ? obj.size : 0, + lastModified: isLast ? obj.lastModified : undefined, + children: {}, + }; + } + + if (!isLast) { + // Это промежуточная папка + current.children[part].isFolder = true; + } + + current = current.children[part]; + } + } + + return root; + }, [objects]); + + const toggleFolder = useCallback((path: string) => { + setExpandedFolders((prev) => ({ + ...prev, + [path]: !prev[path], + })); + }, []); + + const expandAllFolders = useCallback(() => { + const allPaths: Record = {}; + const collectPaths = (node: FileTreeNode) => { + if (node.isFolder && node.path) { + allPaths[node.path] = true; + } + Object.values(node.children).forEach(collectPaths); + }; + collectPaths(fileTree); + setExpandedFolders(allPaths); + }, [fileTree]); + + const collapseAllFolders = useCallback(() => { + setExpandedFolders({}); + }, []); const dispatchBucketsRefresh = useCallback(() => { window.dispatchEvent(new Event('storageBucketsRefresh')); @@ -311,41 +534,6 @@ const StorageBucketPage: React.FC = () => { loadObjects({ reset: true, prefix: '' }); }, [loadObjects]); - const handleGenerateConsoleCredentials = useCallback(async () => { - if (!bucketIdValid) { - return; - } - - try { - setConsoleCredentialsLoading(true); - setConsoleCredentialsError(null); - setConsoleCredentials(null); - const { data } = await apiClient.post<{ credentials?: ConsoleCredentials }>( - `/api/storage/buckets/${bucketNumber}/console-credentials` - ); - - const credentials = data?.credentials; - if (!credentials) { - throw new Error('Сервер не вернул данные входа'); - } - - setConsoleCredentials(credentials); - addToast('Создан новый пароль для MinIO Console', 'success'); - await fetchBucket({ silent: true }); - } catch (error) { - let message = 'Не удалось сгенерировать данные входа'; - if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { - message = error.response.data.error; - } else if (error instanceof Error) { - message = error.message; - } - setConsoleCredentialsError(message); - addToast(message, 'error'); - } finally { - setConsoleCredentialsLoading(false); - } - }, [addToast, bucketIdValid, bucketNumber, fetchBucket]); - const handleSelectAll = useCallback(() => { if (allSelected) { setSelectedKeys({}); @@ -441,18 +629,25 @@ const StorageBucketPage: React.FC = () => { const abortController = new AbortController(); uploadAbortControllerRef.current = abortController; setUploading(true); + + // Подсчитываем общий размер для единого прогресса + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + let totalLoaded = 0; + const uploadStartTime = Date.now(); + setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: files.length }); - const progressMap: Record = {}; try { // Сохраняем файлы в IndexedDB перед загрузкой const { saveFile } = await import('../../utils/uploadDB'); for (const file of files) { + // Используем webkitRelativePath для сохранения структуры папки + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const arrayBuffer = await file.arrayBuffer(); await saveFile({ - id: `${bucketNumber}_${Date.now()}_${file.name}`, + id: `${bucketNumber}_${Date.now()}_${relativePath}`, bucketId: bucketNumber, - name: file.name, + name: relativePath, size: file.size, type: file.type, data: arrayBuffer, @@ -470,50 +665,69 @@ const StorageBucketPage: React.FC = () => { } const file = files[i]; - setUploadStats({ currentFile: file.name, completedFiles: i, totalFiles: files.length }); + // Используем webkitRelativePath для сохранения структуры папки + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const displayName = relativePath.split('/').pop() || file.name; - progressMap[file.name] = { loaded: 0, total: file.size, speed: 0, percentage: 0 }; + setUploadStats({ currentFile: displayName, completedFiles: i, totalFiles: files.length }); - const key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name; + // Формируем ключ с учётом относительного пути (структуры папки) + let key: string; + if (relativePath && relativePath !== file.name) { + // Загрузка папки - сохраняем структуру + key = normalizedPrefix ? `${normalizedPrefix}/${relativePath}` : relativePath; + } else { + // Обычная загрузка файла + key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name; + } const { data } = await apiClient.post(`/api/storage/buckets/${bucketNumber}/objects/presign`, { key, method: 'PUT', contentType: file.type || undefined, }); - const startTime = Date.now(); + let fileLoaded = 0; // Отслеживаем прогресс текущего файла const xhr = new XMLHttpRequest(); - // Track upload progress + // Track upload progress - единый прогресс-бар xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { - const elapsed = (Date.now() - startTime) / 1000; - const speed = elapsed > 0 ? event.loaded / elapsed : 0; - const percentage = Math.round((event.loaded / event.total) * 100); + // Обновляем прогресс для этого файла + const prevFileLoaded = fileLoaded; + fileLoaded = event.loaded; + totalLoaded += (fileLoaded - prevFileLoaded); + + const elapsed = (Date.now() - uploadStartTime) / 1000; + const speed = elapsed > 0 ? totalLoaded / elapsed : 0; + const percentage = totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0; - progressMap[file.name] = { - loaded: event.loaded, - total: event.total, - speed, - percentage, - }; - setUploadProgress({ ...progressMap }); + // Единый прогресс в __total__ + setUploadProgress({ + __total__: { + loaded: totalLoaded, + total: totalSize, + speed, + percentage, + }, + }); } }); await new Promise((resolve, reject) => { xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { - progressMap[file.name].percentage = 100; - setUploadProgress({ ...progressMap }); + // Убедимся, что файл полностью засчитан + if (fileLoaded < file.size) { + totalLoaded += (file.size - fileLoaded); + } resolve(); } else { - reject(new Error(`Загрузка файла «${file.name}» завершилась с ошибкой (${xhr.status})`)); + reject(new Error(`Загрузка файла «${displayName}» завершилась с ошибкой (${xhr.status})`)); } }); xhr.addEventListener('error', () => { - reject(new Error(`Ошибка при загрузке файла «${file.name}»`)); + reject(new Error(`Ошибка при загрузке файла «${displayName}»`)); }); xhr.open('PUT', data.url); @@ -599,30 +813,67 @@ const StorageBucketPage: React.FC = () => { return; } + console.log('[URI Upload] Начало загрузки, URL:', uriUploadUrl); setUriUploadLoading(true); const abortController = new AbortController(); uriUploadAbortControllerRef.current = abortController; try { // Используем бэкенд proxy для обхода CORS с увеличенным timeout + console.log('[URI Upload] Отправляем запрос на бэкенд...'); const response = await apiClient.post( `/api/storage/buckets/${bucketNumber}/objects/download-from-uri`, { url: uriUploadUrl }, { timeout: 120000 } // 120 seconds timeout ); + console.log('[URI Upload] Ответ получен:', { + hasBlob: !!response.data?.blob, + blobLength: response.data?.blob?.length, + mimeType: response.data?.mimeType, + }); + if (response.data?.blob) { - const blob = new Blob([response.data.blob], { type: response.data.mimeType || 'application/octet-stream' }); - const fileName = uriUploadUrl.split('/').pop() || 'file'; + // Декодируем base64 в бинарные данные + const base64 = response.data.blob; + console.log('[URI Upload] Декодируем base64, длина:', base64.length); + + const binaryString = atob(base64); + console.log('[URI Upload] Бинарная строка длина:', binaryString.length); + + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: response.data.mimeType || 'application/octet-stream' }); + console.log('[URI Upload] Blob создан, размер:', blob.size, 'тип:', blob.type); + + // Извлекаем имя файла из URL + const urlObj = new URL(uriUploadUrl); + let fileName = urlObj.pathname.split('/').pop() || 'file'; + // Убираем query-параметры из имени + if (fileName.includes('?')) { + fileName = fileName.split('?')[0]; + } + + console.log('[URI Upload] Имя файла:', fileName); const file = new File([blob], fileName, { type: blob.type }); + console.log('[URI Upload] File объект создан, размер:', file.size); + await performUpload([file]); setUriUploadUrl(''); + addToast(`Файл "${fileName}" загружен`, 'success'); + } else { + console.error('[URI Upload] Ответ не содержит blob:', response.data); + addToast('Сервер не вернул данные файла', 'error'); } } catch (error) { + console.error('[URI Upload] Ошибка:', error); let message = 'Не удалось загрузить по URI'; if (error instanceof Error && error.message === 'canceled') { message = 'Загрузка отменена'; } else if (isAxiosError(error) && error.response?.data?.error) { + console.error('[URI Upload] Ошибка от сервера:', error.response.data.error); message = error.response.data.error; } else if (error instanceof Error) { message = error.message; @@ -827,14 +1078,11 @@ const StorageBucketPage: React.FC = () => { setObjectPrefix(''); objectPrefixRef.current = ''; setLastCreatedKey(null); - setConsoleCredentials((location.state as BucketLocationState | undefined)?.consoleCredentials ?? null); - setConsoleCredentialsError(null); - setConsoleCredentialsLoading(false); fetchBucket(); loadObjects({ reset: true, prefix: '' }); fetchAccessKeys(); - }, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects, location.state]); + }, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects]); const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0; const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? ''; @@ -844,9 +1092,6 @@ const StorageBucketPage: React.FC = () => { const bucketPrice = typeof bucketPriceValue === 'number' && Number.isFinite(bucketPriceValue) ? formatCurrency(bucketPriceValue) : '—'; - const consoleLoginValue = consoleCredentials?.login ?? bucket?.consoleLogin ?? bucket?.name ?? ''; - const consoleLoginDisplay = consoleLoginValue || '—'; - const consoleUrl = consoleCredentials?.url ?? bucket?.consoleUrl ?? null; const activeTabMeta = TAB_ITEMS.find((item) => item.key === activeTab); @@ -1097,25 +1342,25 @@ const StorageBucketPage: React.FC = () => { Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: {uploadStats.currentFile}
)} - {Object.entries(uploadProgress).map(([fileName, progress]: [string, UploadProgress]) => { - const speedMB = (progress.speed / (1024 * 1024)).toFixed(2); - return ( -
-
- {fileName} - - {progress.percentage}% • {speedMB} MB/s - -
-
-
-
+ {uploadProgress.__total__ && ( +
+
+ Общий прогресс + + {uploadProgress.__total__.percentage}% • {((uploadProgress.__total__.speed * 8) / (1024 * 1024)).toFixed(2)} Mbit/s +
- ); - })} +
+
+
+
+ {formatBytes(uploadProgress.__total__.loaded)} / {formatBytes(uploadProgress.__total__.total)} +
+
+ )} + +
{objectsLoading ? ( @@ -1227,44 +1490,30 @@ const StorageBucketPage: React.FC = () => { Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
) : ( -
- - - - - - - - - - - - {objects.map((object) => ( - - - - - - - - ))} - -
ВыборКлючРазмерИзменёнДействия
- handleToggleSelection(object.key)} - /> - {object.key}{formatBytes(object.size)}{object.lastModified ? formatDate(object.lastModified, true) : '—'} - -
+
+ {/* Заголовок проводника */} +
+ + Имя + Размер + Изменён + Действия +
+ {/* Дерево файлов */} +
+ +
)} @@ -1306,95 +1555,6 @@ const StorageBucketPage: React.FC = () => {
-
-
-
-

Доступ к MinIO Console

-

- Здесь можно получить логин и временный пароль для панели управления объектным хранилищем. - Пароль показывается только один раз после генерации. -

-
- -
- -
- Логин: {consoleLoginDisplay} - {consoleLoginValue && ( - - )} - {consoleUrl && ( - - Открыть MinIO Console - - )} -
- - {consoleCredentials && ( -
-
- - Новые данные входа -
-

- Скопируйте пароль сейчас. После закрытия страницы он больше не отобразится. -

-
- Логин: {consoleCredentials.login} - -
-
- Пароль: {consoleCredentials.password} - -
-
- )} - - {consoleCredentialsError && ( -
- {consoleCredentialsError} -
- )} -
-
@@ -1483,29 +1643,47 @@ const StorageBucketPage: React.FC = () => {
-

Доступ по ключам

-

Создавайте и управляйте access/secret ключами для приложений.

+

Ключ доступа S3

+

Access Key и Secret Key для программного доступа к хранилищу (один ключ на бакет).

-
- setNewKeyLabel(event.target.value)} - placeholder="Название или назначение ключа" - className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none" - /> - + {accessKeys.length === 0 ? ( +
+ setNewKeyLabel(event.target.value)} + placeholder="Название или назначение ключа" + className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none" + /> + +
+ ) : null} + + {/* Информация о консоли MinIO */} +
+

+ Веб-консоль: Вы также можете управлять файлами через{' '} + + console.s3.ospab.host + + {' '}— используйте Access Key и Secret Key для входа. +

{lastCreatedKey && ( @@ -1536,6 +1714,14 @@ const StorageBucketPage: React.FC = () => { Копировать
+
+

Подключение к S3:

+
+
Endpoint: {bucket?.endpoint || 's3.ospab.host'}
+
Bucket: {bucket?.physicalName || bucket?.name}
+
Region: {bucket?.region || 'ru-msk-1'}
+
+
)} diff --git a/ospabhost/frontend/src/pages/dashboard/types.ts b/ospabhost/frontend/src/pages/dashboard/types.ts index 51cffda..3611fc4 100644 --- a/ospabhost/frontend/src/pages/dashboard/types.ts +++ b/ospabhost/frontend/src/pages/dashboard/types.ts @@ -22,6 +22,8 @@ export interface Ticket { export interface StorageBucket { id: number; name: string; + physicalName?: string; + endpoint?: string; plan: string; quotaGb: number; usedBytes: number; diff --git a/ospabhost/package.json b/ospabhost/package.json index 982bb8f..c5d231b 100644 --- a/ospabhost/package.json +++ b/ospabhost/package.json @@ -41,9 +41,9 @@ ] }, "devDependencies": { - "@prisma/client": "^6.16.1", + "@prisma/client": "^6.0.0", "mysql2": "^3.14.5", - "prisma": "^6.16.1", + "prisma": "^6.0.0", "bcrypt": "^5.1.0", "cors": "^2.8.5", "proxmox-api": "^1.0.0", diff --git a/package.json b/package.json index fafdb5e..b7e2ec4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "util": "^0.12.5" }, "dependencies": { - "@prisma/client": "^6.16.1", + "@prisma/client": "^6.0.0", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^17.2.2", @@ -30,7 +30,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.9", - "prisma": "^6.16.1", + "prisma": "^6.0.0", "proxmox-api": "^1.1.1", "recharts": "^3.2.1", "socket.io": "^4.8.1",