import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { isAxiosError } from 'axios'; import { FiArrowLeft, FiRefreshCw, FiDatabase, FiUpload, FiTrash2, FiDownload, FiKey, FiCopy, FiInfo, FiHelpCircle, FiSettings, FiFolder, FiBarChart2, } from 'react-icons/fi'; import apiClient from '../../utils/apiClient'; import { useToast } from '../../hooks/useToast'; import { getFiles, deleteFilesByBucket } from '../../utils/uploadDB'; import type { StorageAccessKey, StorageBucket, StorageObject } from './types'; import { formatBytes, formatCurrency, formatDate, getPlanTone, getStatusBadge, getUsagePercent } from './storage-utils'; interface ObjectsResponse { objects: StorageObject[]; nextCursor?: string | null; } interface PresignResponse { url: string; method: 'GET' | 'PUT'; } interface CreatedKey { accessKey: string; secretKey: string; label?: string | null; } interface UploadProgress { loaded: number; total: number; speed: number; percentage: number; } const TEN_GIB = 10 * 1024 * 1024 * 1024; const TAB_ITEMS = [ { key: 'summary', label: 'Сводка', icon: FiBarChart2, description: 'Статистика, квоты и текущее состояние бакета.', }, { key: 'files', label: 'Файлы', icon: FiFolder, description: 'Загрузка, скачивание и управление объектами.', }, { key: 'settings', label: 'Настройки', icon: FiSettings, description: 'Права доступа, версионирование и ключи API.', }, ] as const; type TabKey = (typeof TAB_ITEMS)[number]['key']; type LoadObjectsOptions = { reset?: boolean; cursor?: string | null; prefix?: string; }; type ConsoleCredentials = { login: string; password: string; url?: string | null; }; type BucketLocationState = { consoleCredentials?: ConsoleCredentials; bucketName?: string; }; const StorageBucketPage: React.FC = () => { const { bucketId: bucketIdParam } = useParams<{ bucketId: string }>(); const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN; const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0; const location = useLocation(); const navigate = useNavigate(); const { addToast } = useToast(); const objectPrefixRef = useRef(''); const fileInputRef = useRef(null); const directoryInputRef = useRef(null); const fileDialogOpenRef = useRef(false); const [activeTab, setActiveTab] = useState('summary'); const [bucket, setBucket] = useState(null); const [bucketLoading, setBucketLoading] = useState(true); const [bucketRefreshing, setBucketRefreshing] = useState(false); const [bucketActionPending, setBucketActionPending] = useState(false); const [bucketError, setBucketError] = useState(null); const [objects, setObjects] = useState([]); const [objectsLoading, setObjectsLoading] = useState(true); const [objectsLoadingMore, setObjectsLoadingMore] = useState(false); const [objectsCursor, setObjectsCursor] = useState(null); const [objectPrefix, setObjectPrefix] = useState(''); const [objectSearch, setObjectSearch] = useState(''); const [selectedKeys, setSelectedKeys] = useState>({}); const [uploadPath, setUploadPath] = useState(''); const [uploading, setUploading] = useState(false); const [isDragActive, setIsDragActive] = useState(false); const [uploadProgress, setUploadProgress] = useState>({}); const [uploadStats, setUploadStats] = useState<{ currentFile: string; completedFiles: number; totalFiles: number }>({ currentFile: '', completedFiles: 0, totalFiles: 0 }); const [uriUploadUrl, setUriUploadUrl] = useState(''); const [uriUploadLoading, setUriUploadLoading] = useState(false); const [resumedFiles, setResumedFiles] = useState([]); const uploadAbortControllerRef = useRef(null); const [accessKeys, setAccessKeys] = useState([]); const [accessKeysLoading, setAccessKeysLoading] = useState(false); const [newKeyLabel, setNewKeyLabel] = useState(''); const [creatingKey, setCreatingKey] = useState(false); const [deletingKeys, setDeletingKeys] = useState>({}); const [lastCreatedKey, setLastCreatedKey] = useState(null); const selectedList = useMemo( () => Object.entries(selectedKeys).filter(([, value]) => value).map(([key]) => key), [selectedKeys], ); 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 dispatchBucketsRefresh = useCallback(() => { window.dispatchEvent(new Event('storageBucketsRefresh')); }, []); const fetchBucket = useCallback(async (options: { silent?: boolean } = {}) => { if (!bucketIdValid) { setBucket(null); setBucketError('Некорректный идентификатор бакета'); setBucketLoading(false); return; } if (options.silent) { setBucketRefreshing(true); } else { setBucketLoading(true); } try { const { data } = await apiClient.get<{ bucket: StorageBucket }>(`/api/storage/buckets/${bucketNumber}`); setBucket(data.bucket); setBucketError(null); } catch (error) { let message = 'Не удалось загрузить бакет'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } setBucket(null); setBucketError(message); addToast(message, 'error'); } finally { if (options.silent) { setBucketRefreshing(false); } else { setBucketLoading(false); } } }, [addToast, bucketIdValid, bucketNumber]); const loadObjects = useCallback(async ({ reset = false, cursor = null, prefix }: LoadObjectsOptions = {}) => { if (!bucketIdValid) { setObjectsLoading(false); setObjectsLoadingMore(false); return; } if (reset) { setObjectsLoading(true); setSelectedKeys({}); } else if (cursor) { setObjectsLoadingMore(true); } else { setObjectsLoading(true); } try { const params: Record = {}; if (typeof prefix === 'string') { objectPrefixRef.current = prefix.trim(); } const effectivePrefix = objectPrefixRef.current.trim(); if (effectivePrefix) { params.prefix = effectivePrefix; } if (cursor) { params.cursor = cursor; } const { data } = await apiClient.get(`/api/storage/buckets/${bucketNumber}/objects`, { params }); setObjects((prev) => (reset ? data.objects : [...prev, ...data.objects])); setObjectsCursor(data.nextCursor ?? null); } catch (error) { console.error('[StorageBucket] Не удалось получить список объектов', error); addToast('Не удалось загрузить список объектов', 'error'); } finally { setObjectsLoading(false); setObjectsLoadingMore(false); } }, [addToast, bucketIdValid, bucketNumber]); const fetchAccessKeys = useCallback(async () => { if (!bucketIdValid) { setAccessKeys([]); return; } setAccessKeysLoading(true); try { const { data } = await apiClient.get<{ keys: StorageAccessKey[] }>(`/api/storage/buckets/${bucketNumber}/access-keys`); setAccessKeys(data.keys); } catch (error) { console.error('[StorageBucket] Не удалось получить ключи доступа', error); addToast('Не удалось загрузить ключи доступа', 'error'); } finally { setAccessKeysLoading(false); } }, [addToast, bucketIdValid, bucketNumber]); const updateBucketSettings = useCallback(async (payload: Record, successMessage?: string) => { if (!bucketIdValid) { return; } setBucketActionPending(true); try { const { data } = await apiClient.patch<{ bucket: StorageBucket }>(`/api/storage/buckets/${bucketNumber}`, payload); setBucket(data.bucket); if (successMessage) { addToast(successMessage, 'success'); } dispatchBucketsRefresh(); } catch (error) { let message = 'Не удалось обновить настройки бакета'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); } finally { setBucketActionPending(false); } }, [addToast, bucketIdValid, bucketNumber, dispatchBucketsRefresh]); const togglePublic = useCallback(() => { if (!bucket) { return; } const next = !bucket.public; updateBucketSettings({ public: next }, next ? 'Публичный доступ включён' : 'Публичный доступ отключён'); }, [bucket, updateBucketSettings]); const toggleVersioning = useCallback(() => { if (!bucket) { return; } const next = !bucket.versioning; updateBucketSettings({ versioning: next }, next ? 'Версионирование включено' : 'Версионирование отключено'); }, [bucket, updateBucketSettings]); const toggleAutoRenew = useCallback(() => { if (!bucket) { return; } const next = !bucket.autoRenew; updateBucketSettings({ autoRenew: next }, next ? 'Автопродление включено' : 'Автопродление отключено'); }, [bucket, updateBucketSettings]); const handleRefreshBucket = useCallback(() => { fetchBucket({ silent: true }); loadObjects({ reset: true }); fetchAccessKeys(); }, [fetchAccessKeys, fetchBucket, loadObjects]); const handleApplyFilter = useCallback((event: React.FormEvent) => { event.preventDefault(); const trimmed = objectSearch.trim(); setObjectPrefix(trimmed); loadObjects({ reset: true, prefix: trimmed }); }, [loadObjects, objectSearch]); const handleResetFilter = useCallback(() => { setObjectSearch(''); setObjectPrefix(''); 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({}); } else { const nextSelection: Record = {}; objects.forEach((object) => { nextSelection[object.key] = true; }); setSelectedKeys(nextSelection); } }, [allSelected, objects]); const handleToggleSelection = useCallback((key: string) => { setSelectedKeys((prev) => { const next = { ...prev }; if (next[key]) { delete next[key]; } else { next[key] = true; } return next; }); }, []); const handleClearSelection = useCallback(() => { setSelectedKeys({}); }, []); const handleDownloadObject = useCallback(async (object: StorageObject) => { if (object.size >= TEN_GIB) { const confirmed = window.confirm('Файл весит больше 10 ГБ. Скачивание может занять продолжительное время. Продолжить?'); if (!confirmed) { return; } } try { const { data } = await apiClient.post(`/api/storage/buckets/${bucketNumber}/objects/presign`, { key: object.key, method: 'GET', download: true, downloadFileName: object.key.split('/').pop() ?? object.key, }); const link = document.createElement('a'); link.href = data.url; link.rel = 'noopener'; link.download = object.key.split('/').pop() ?? object.key; document.body.appendChild(link); link.click(); document.body.removeChild(link); } catch (error) { let message = 'Не удалось скачать объект'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); } }, [addToast, bucketNumber]); const handleDeleteObjects = useCallback(async () => { if (selectedList.length === 0) { return; } const confirmed = window.confirm(`Удалить ${selectedList.length} объект(ов)? Действие нельзя отменить.`); if (!confirmed) { return; } try { await apiClient.delete(`/api/storage/buckets/${bucketNumber}/objects`, { data: { keys: selectedList }, }); addToast(`Удалено объектов: ${selectedList.length}`, 'success'); setSelectedKeys({}); loadObjects({ reset: true }); fetchBucket({ silent: true }); dispatchBucketsRefresh(); } catch (error) { let message = 'Не удалось удалить объекты'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); } }, [addToast, bucketNumber, dispatchBucketsRefresh, fetchBucket, loadObjects, selectedList]); const performUpload = useCallback(async (files: File[]) => { if (files.length === 0 || uploading) { return; } const abortController = new AbortController(); uploadAbortControllerRef.current = abortController; setUploading(true); setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: files.length }); const progressMap: Record = {}; try { // Сохраняем файлы в IndexedDB перед загрузкой const { saveFile } = await import('../../utils/uploadDB'); for (const file of files) { const arrayBuffer = await file.arrayBuffer(); await saveFile({ id: `${bucketNumber}_${Date.now()}_${file.name}`, bucketId: bucketNumber, name: file.name, size: file.size, type: file.type, data: arrayBuffer, uploadPath: uploadPath.trim(), timestamp: Date.now(), }); } const normalizedPrefix = uploadPath.trim().replace(/\\/g, '/').replace(/^\/+/u, '').replace(/\/+$/u, ''); for (let i = 0; i < files.length; i++) { // Проверяем отмену if (abortController.signal.aborted) { throw new Error('Загрузка отменена'); } const file = files[i]; setUploadStats({ currentFile: file.name, completedFiles: i, totalFiles: files.length }); progressMap[file.name] = { loaded: 0, total: file.size, speed: 0, percentage: 0 }; const 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(); const xhr = new XMLHttpRequest(); // 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); progressMap[file.name] = { loaded: event.loaded, total: event.total, speed, percentage, }; setUploadProgress({ ...progressMap }); } }); await new Promise((resolve, reject) => { xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { progressMap[file.name].percentage = 100; setUploadProgress({ ...progressMap }); resolve(); } else { reject(new Error(`Загрузка файла «${file.name}» завершилась с ошибкой (${xhr.status})`)); } }); xhr.addEventListener('error', () => { reject(new Error(`Ошибка при загрузке файла «${file.name}»`)); }); xhr.open('PUT', data.url); if (file.type) { xhr.setRequestHeader('Content-Type', file.type); } xhr.send(file); }); } // Успешная загрузка - удаляем файлы из IndexedDB const { deleteFilesByBucket } = await import('../../utils/uploadDB'); await deleteFilesByBucket(bucketNumber); localStorage.removeItem(`uploadState_bucket_${bucketNumber}`); addToast(`Загружено файлов: ${files.length}`, 'success'); setUploadProgress({}); loadObjects({ reset: true }); fetchBucket({ silent: true }); dispatchBucketsRefresh(); } catch (error) { let message = 'Не удалось загрузить файлы'; if (error instanceof Error && error.message) { message = error.message; } if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); // Файлы остаются в IndexedDB для возможности повторной загрузки } finally { setUploading(false); setIsDragActive(false); setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 }); uploadAbortControllerRef.current = null; } }, [addToast, bucketNumber, dispatchBucketsRefresh, fetchBucket, loadObjects, uploadPath, uploading, setUploadProgress, setUploadStats]); const handleCancelUpload = useCallback(() => { if (uploadAbortControllerRef.current) { uploadAbortControllerRef.current.abort(); uploadAbortControllerRef.current = null; } setUploading(false); setUploadProgress({}); setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 }); addToast('Загрузка отменена', 'info'); }, [addToast]); const handleClickSelectFiles = useCallback(() => { if (fileDialogOpenRef.current || uploading) { return; } fileDialogOpenRef.current = true; if (fileInputRef.current) { fileInputRef.current.value = ''; fileInputRef.current.click(); } setTimeout(() => { fileDialogOpenRef.current = false; }, 500); }, [uploading]); const handleClickSelectDirectory = useCallback(() => { if (fileDialogOpenRef.current || uploading) { return; } fileDialogOpenRef.current = true; if (directoryInputRef.current) { directoryInputRef.current.value = ''; directoryInputRef.current.click(); } setTimeout(() => { fileDialogOpenRef.current = false; }, 500); }, [uploading]); const uriUploadAbortControllerRef = useRef(null); const handleUriUpload = useCallback(async () => { if (!uriUploadUrl.trim()) { addToast('Введите URL', 'error'); return; } setUriUploadLoading(true); const abortController = new AbortController(); uriUploadAbortControllerRef.current = abortController; try { // Используем бэкенд proxy для обхода CORS с увеличенным timeout const response = await apiClient.post( `/api/storage/buckets/${bucketNumber}/objects/download-from-uri`, { url: uriUploadUrl }, { timeout: 120000 } // 120 seconds timeout ); if (response.data?.blob) { const blob = new Blob([response.data.blob], { type: response.data.mimeType || 'application/octet-stream' }); const fileName = uriUploadUrl.split('/').pop() || 'file'; const file = new File([blob], fileName, { type: blob.type }); await performUpload([file]); setUriUploadUrl(''); } } catch (error) { let message = 'Не удалось загрузить по URI'; if (error instanceof Error && error.message === 'canceled') { message = 'Загрузка отменена'; } else if (isAxiosError(error) && error.response?.data?.error) { message = error.response.data.error; } else if (error instanceof Error) { message = error.message; } addToast(message, 'error'); } finally { setUriUploadLoading(false); uriUploadAbortControllerRef.current = null; } }, [uriUploadUrl, performUpload, addToast, bucketNumber]); const handleCancelUriUpload = useCallback(() => { if (uriUploadAbortControllerRef.current) { uriUploadAbortControllerRef.current.abort(); uriUploadAbortControllerRef.current = null; } setUriUploadLoading(false); addToast('Загрузка отменена', 'info'); }, [addToast]); const handleUploadInput = useCallback((event: React.ChangeEvent) => { const { files } = event.target; if (!files || files.length === 0) { return; } performUpload(Array.from(files)); event.target.value = ''; }, [performUpload]); const handleUploadDirectory = useCallback((event: React.ChangeEvent) => { const { files } = event.target; if (!files || files.length === 0) { return; } // Фильтруем только файлы, пропускаем директории (у которых size === 0 и не webkitRelativePath) const fileArray = Array.from(files).filter(file => { // Skip empty folder entries return file.size > 0 || file.type !== ''; }); if (fileArray.length === 0) { addToast('Папка пуста или не содержит файлов', 'warning'); event.target.value = ''; return; } performUpload(fileArray); event.target.value = ''; }, [performUpload, addToast]); const handleDropUpload = useCallback(async (event: React.DragEvent) => { event.preventDefault(); setIsDragActive(false); const { files } = event.dataTransfer; if (!files || files.length === 0) { return; } await performUpload(Array.from(files)); }, [performUpload]); const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragActive(true); }, []); const handleDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setIsDragActive(false); } }, []); const handleCopy = useCallback(async (value: string, label: string) => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); } else { const textarea = document.createElement('textarea'); textarea.value = value; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } addToast(`${label} скопирован`, 'success'); } catch { addToast('Не удалось скопировать в буфер обмена', 'error'); } }, [addToast]); const handleCreateAccessKey = useCallback(async () => { if (!bucketIdValid || creatingKey) { return; } setCreatingKey(true); try { const { data } = await apiClient.post<{ key: CreatedKey }>(`/api/storage/buckets/${bucketNumber}/access-keys`, { label: newKeyLabel.trim() || undefined, }); setNewKeyLabel(''); setLastCreatedKey(data.key); addToast('Создан новый ключ доступа', 'success'); fetchAccessKeys(); } catch (error) { let message = 'Не удалось создать ключ'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); } finally { setCreatingKey(false); } }, [addToast, bucketIdValid, bucketNumber, creatingKey, fetchAccessKeys, newKeyLabel]); const handleRevokeAccessKey = useCallback(async (keyId: number) => { const confirmed = window.confirm('Удалить ключ доступа? После удаления восстановить его будет невозможно.'); if (!confirmed) { return; } setDeletingKeys((prev) => ({ ...prev, [keyId]: true })); try { await apiClient.delete(`/api/storage/buckets/${bucketNumber}/access-keys/${keyId}`); setAccessKeys((prev) => prev.filter((key) => key.id !== keyId)); addToast('Ключ удалён', 'success'); } catch (error) { let message = 'Не удалось удалить ключ'; if (isAxiosError(error) && typeof error.response?.data?.error === 'string') { message = error.response.data.error; } addToast(message, 'error'); } finally { setDeletingKeys((prev) => { const next = { ...prev }; delete next[keyId]; return next; }); } }, [addToast, bucketNumber]); // Restore upload state and files from IndexedDB on component mount useEffect(() => { const restoreState = async () => { try { const savedFiles = await getFiles(bucketNumber); if (savedFiles.length > 0) { // Восстанавливаем файлы для загрузки const filesToResume = savedFiles.map(sf => new File([sf.data], sf.name, { type: sf.type })); const storageKey = `uploadState_bucket_${bucketNumber}`; const savedState = localStorage.getItem(storageKey); if (savedState) { try { const { uploadPath: savedPath } = JSON.parse(savedState); setUploadPath(savedPath || ''); } catch (e) { console.error('Failed to restore upload path:', e); } } // Сохраняем файлы в state для отображения кнопки setResumedFiles(filesToResume); // Показываем уведомление о восстановленных файлах addToast(`⚠️ Обнаружено ${filesToResume.length} файла(ов) для восстановления. Нажмите "Продолжить загрузку" чтобы завершить.`, 'warning'); } } catch (error) { console.error('Failed to restore upload state from IndexedDB:', error); } }; restoreState(); }, [bucketNumber, addToast]); // Poll for real-time bucket stats updates useEffect(() => { if (!bucketIdValid) { return; } const intervalId = setInterval(() => { fetchBucket({ silent: true }); }, 15000); // Update every 15 seconds return () => clearInterval(intervalId); }, [bucketIdValid, fetchBucket]); useEffect(() => { if (!bucketIdValid) { setBucket(null); setBucketError('Некорректный идентификатор бакета'); setBucketLoading(false); return; } setBucketError(null); setBucket(null); setObjects([]); setObjectsCursor(null); setSelectedKeys({}); setObjectSearch(''); 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]); const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0; const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? ''; const bucketPlanTone = getPlanTone(bucket?.planDetails?.code ?? bucket?.plan ?? ''); const bucketStatusBadge = getStatusBadge(bucket?.status ?? ''); const bucketPriceValue = bucket?.planDetails?.price ?? bucket?.monthlyPrice ?? null; 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); let tabContent: React.ReactNode = null; if (bucket) { if (activeTab === 'summary') { tabContent = (

Регион

{bucket.regionDetails?.name ?? bucket.region}

{bucket.regionDetails?.endpoint ?? bucket.regionDetails?.code ?? '—'}

Класс хранения

{bucket.storageClassDetails?.name ?? bucket.storageClass}

{bucket.storageClassDetails?.description ?? bucket.storageClassDetails?.code ?? '—'}

Тариф

{bucketPlanName}

Стоимость: {bucketPrice}

Биллинг

Следующее списание: {formatDate(bucket.nextBillingDate)}

Последнее списание: {formatDate(bucket.lastBilledAt)}

Использовано: {formatBytes(bucket.usedBytes)} Квота: {bucket.quotaGb} GB
90 ? 'bg-red-500' : bucketUsagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500' }`} style={{ width: `${bucketUsagePercent}%` }} />
{bucketUsagePercent.toFixed(1)}% квоты использовано Объектов: {bucket.objectCount} Синхронизация: {formatDate(bucket.usageSyncedAt, true)}

Создан

{formatDate(bucket.createdAt)}

Обновлён

{formatDate(bucket.updatedAt, true)}

Состояния

{bucket.public && ( Публичный доступ )} {bucket.versioning && ( Версионирование )} {bucket.autoRenew && ( Автопродление )} {!bucket.public && !bucket.versioning && !bucket.autoRenew && ( Опций нет )}

Нужна помощь по квотам или регионам?

Ответы есть в FAQ по хранилищу.

); } else if (activeTab === 'files') { tabContent = (
setObjectSearch(event.target.value)} placeholder="Например: backups/2025" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none" />
setUploadPath(event.target.value)} placeholder="Например: backups/april" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none" />

Префикс будет добавлен перед именем каждого файла.

{resumedFiles.length > 0 ? ( <>

Прерванная загрузка

Восстановлено файлов: {resumedFiles.length}

) : ( <>

Перетащите файлы сюда

или выберите вручную

)}
{uploading && (
{uploadStats.currentFile && (
Загрузка файла {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
); })}
)} {!uploading && Object.keys(uploadProgress).length === 0 && (

Загружаем файлы...

)}
setUriUploadUrl(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && !uriUploadLoading && handleUriUpload()} placeholder="https://example.com/file.zip" className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none" disabled={uriUploadLoading || uploading} /> {!uriUploadLoading ? ( ) : ( )}
{uriUploadLoading && (
Загрузка с сервера... Подождите (может занять время)
)}

Укажите прямую ссылку на файл для загрузки в бакет.

Найдено объектов: {objects.length} {objectPrefix && ( Фильтр: {objectPrefix} )} {selectedCount > 0 && ( Выбрано объектов: {selectedCount} )}
{objectsLoading ? (
) : objects.length === 0 ? (
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
) : (
{objects.map((object) => ( ))}
Выбор Ключ Размер Изменён Действия
handleToggleSelection(object.key)} /> {object.key} {formatBytes(object.size)} {object.lastModified ? formatDate(object.lastModified, true) : '—'}
)} {objectsCursor && (
)}

Как работает загрузка и скачивание?

Подробные инструкции — в FAQ по файлам.

); } else if (activeTab === 'settings') { tabContent = (

Управление доступом

Рекомендации и best practices — в FAQ по настройкам бакета.

Доступ к MinIO Console

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

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

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

Логин: {consoleCredentials.login}
Пароль: {consoleCredentials.password}
)} {consoleCredentialsError && (
{consoleCredentialsError}
)}

Публичный доступ

Позволяет отдавать файлы по прямым ссылкам без авторизации.

Версионирование

Сохраняет историю изменений файлов, помогает откатиться при ошибках.

Автопродление

Продлевает подписку автоматически. Подходит для долгосрочных проектов.

Текущий тариф

{bucketPlanName}

Стоимость: {bucketPrice}

Следующее списание

{formatDate(bucket.nextBillingDate)}

Баланс списывается автоматически при продлении.

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

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

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" />
{lastCreatedKey && (
Сохраните данные нового ключа — после закрытия страницы секрет недоступен.
Access Key: {lastCreatedKey.accessKey}
Secret Key: {lastCreatedKey.secretKey}
)} {accessKeysLoading ? (
) : accessKeys.length === 0 ? (
Ключи доступа не созданы.
) : (
{accessKeys.map((key) => ( ))}
Название Access Key Создан Последнее использование Действия
{key.label || 'Без названия'} {key.accessKey} {formatDate(key.createdAt, true)} {formatDate(key.lastUsedAt, true)}
)}
); } } return (
{bucketError && (

{bucketError}

Попробуйте обновить страницу или вернитесь к списку хранилищ.

)} {bucketLoading ? (
) : bucket ? ( <>

{bucket.name}

{bucketPlanName} {bucketStatusBadge.label}

ID бакета: {bucket.id}

Регион: {bucket.regionDetails?.name ?? bucket.region} Класс хранения: {bucket.storageClassDetails?.name ?? bucket.storageClass} Объектов: {bucket.objectCount} Квота: {bucket.quotaGb} GB
{TAB_ITEMS.map(({ key, label, icon: Icon }) => { const isActive = key === activeTab; return ( ); })}
{activeTabMeta && (

{activeTabMeta.label}: {activeTabMeta.description}

)}
{tabContent} ) : (
Не удалось получить данные бакета. Обновите страницу или вернитесь к списку хранилищ.
)}
); }; export default StorageBucketPage;