Files
ospab.host/ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx
Georgiy Syralev 753696cc93 update README
2025-11-26 21:43:57 +03:00

1721 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const fileDialogOpenRef = useRef(false);
const [activeTab, setActiveTab] = useState<TabKey>('summary');
const [bucket, setBucket] = useState<StorageBucket | null>(null);
const [bucketLoading, setBucketLoading] = useState(true);
const [bucketRefreshing, setBucketRefreshing] = useState(false);
const [bucketActionPending, setBucketActionPending] = useState(false);
const [bucketError, setBucketError] = useState<string | null>(null);
const [objects, setObjects] = useState<StorageObject[]>([]);
const [objectsLoading, setObjectsLoading] = useState(true);
const [objectsLoadingMore, setObjectsLoadingMore] = useState(false);
const [objectsCursor, setObjectsCursor] = useState<string | null>(null);
const [objectPrefix, setObjectPrefix] = useState('');
const [objectSearch, setObjectSearch] = useState('');
const [selectedKeys, setSelectedKeys] = useState<Record<string, boolean>>({});
const [uploadPath, setUploadPath] = useState('');
const [uploading, setUploading] = useState(false);
const [isDragActive, setIsDragActive] = useState(false);
const [uploadProgress, setUploadProgress] = useState<Record<string, UploadProgress>>({});
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<File[]>([]);
const uploadAbortControllerRef = useRef<AbortController | null>(null);
const [accessKeys, setAccessKeys] = useState<StorageAccessKey[]>([]);
const [accessKeysLoading, setAccessKeysLoading] = useState(false);
const [newKeyLabel, setNewKeyLabel] = useState('');
const [creatingKey, setCreatingKey] = useState(false);
const [deletingKeys, setDeletingKeys] = useState<Record<number, boolean>>({});
const [lastCreatedKey, setLastCreatedKey] = useState<CreatedKey | null>(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<ConsoleCredentials | null>(() => {
const state = location.state as BucketLocationState | undefined;
return state?.consoleCredentials ?? null;
});
const [consoleCredentialsLoading, setConsoleCredentialsLoading] = useState(false);
const [consoleCredentialsError, setConsoleCredentialsError] = useState<string | null>(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<string, string> = {};
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<ObjectsResponse>(`/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<string, unknown>, 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<HTMLFormElement>) => {
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<string, boolean> = {};
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<PresignResponse>(`/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<string, UploadProgress> = {};
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<PresignResponse>(`/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<void>((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<AbortController | null>(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<HTMLInputElement>) => {
const { files } = event.target;
if (!files || files.length === 0) {
return;
}
performUpload(Array.from(files));
event.target.value = '';
}, [performUpload]);
const handleUploadDirectory = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
setIsDragActive(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
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 = (
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Регион</p>
<p className="font-semibold text-gray-800">{bucket.regionDetails?.name ?? bucket.region}</p>
<p className="text-xs text-gray-500">{bucket.regionDetails?.endpoint ?? bucket.regionDetails?.code ?? '—'}</p>
</div>
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения</p>
<p className="font-semibold text-gray-800">{bucket.storageClassDetails?.name ?? bucket.storageClass}</p>
<p className="text-xs text-gray-500">{bucket.storageClassDetails?.description ?? bucket.storageClassDetails?.code ?? '—'}</p>
</div>
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Тариф</p>
<p className="font-semibold text-gray-800">{bucketPlanName}</p>
<p className="text-xs text-gray-500">Стоимость: {bucketPrice}</p>
</div>
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Биллинг</p>
<p className="font-semibold text-gray-800">Следующее списание: {formatDate(bucket.nextBillingDate)}</p>
<p className="text-xs text-gray-500">Последнее списание: {formatDate(bucket.lastBilledAt)}</p>
</div>
</div>
<div>
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
<span>Квота: {bucket.quotaGb} GB</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
bucketUsagePercent > 90 ? 'bg-red-500' : bucketUsagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${bucketUsagePercent}%` }}
/>
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-2">
<span>{bucketUsagePercent.toFixed(1)}% квоты использовано</span>
<span>Объектов: {bucket.objectCount}</span>
<span>Синхронизация: {formatDate(bucket.usageSyncedAt, true)}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs uppercase text-gray-500 mb-1">Создан</p>
<p className="font-semibold text-gray-800">{formatDate(bucket.createdAt)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs uppercase text-gray-500 mb-1">Обновлён</p>
<p className="font-semibold text-gray-800">{formatDate(bucket.updatedAt, true)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs uppercase text-gray-500 mb-1">Состояния</p>
<div className="flex flex-wrap gap-2 mt-2">
{bucket.public && (
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 text-xs font-semibold rounded-full">
Публичный доступ
</span>
)}
{bucket.versioning && (
<span className="inline-flex items-center px-2 py-1 bg-purple-100 text-purple-700 text-xs font-semibold rounded-full">
Версионирование
</span>
)}
{bucket.autoRenew && (
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 text-xs font-semibold rounded-full">
Автопродление
</span>
)}
{!bucket.public && !bucket.versioning && !bucket.autoRenew && (
<span className="text-xs text-gray-500">Опций нет</span>
)}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3 text-sm text-blue-800">
<FiHelpCircle className="text-lg" />
<div>
<p className="font-semibold">Нужна помощь по квотам или регионам?</p>
<p>
Ответы есть в <a href="https://ospab.host/faq/storage/overview" target="_blank" rel="noreferrer" className="underline font-semibold">FAQ по хранилищу</a>.
</p>
</div>
</div>
</section>
);
} else if (activeTab === 'files') {
tabContent = (
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<form onSubmit={handleApplyFilter} className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1">
<label className="block text-xs font-semibold text-gray-500 mb-1">Фильтр по префиксу</label>
<input
type="text"
value={objectSearch}
onChange={(event) => 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"
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
>
Применить
</button>
<button
type="button"
onClick={handleResetFilter}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
>
Сбросить
</button>
<button
type="button"
onClick={() => loadObjects({ reset: true })}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
>
<FiRefreshCw className={objectsLoading ? 'animate-spin' : ''} />
Обновить
</button>
</div>
</form>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Путь для загрузки</label>
<input
type="text"
value={uploadPath}
onChange={(event) => 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"
/>
<p className="text-xs text-gray-500 mt-1">Префикс будет добавлен перед именем каждого файла.</p>
</div>
</div>
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition ${
isDragActive ? 'border-ospab-primary bg-ospab-primary/5' : 'border-gray-200 bg-gray-50'
} ${uploading ? 'opacity-60 cursor-not-allowed' : ''}`}
onDrop={handleDropUpload}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
>
<div className="flex flex-col items-center gap-3">
<div className="bg-white w-12 h-12 rounded-full flex items-center justify-center shadow">
<FiUpload className="text-ospab-primary text-xl" />
</div>
{resumedFiles.length > 0 ? (
<>
<p className="text-sm text-gray-700 font-semibold text-orange-600">Прерванная загрузка</p>
<p className="text-xs text-gray-500">Восстановлено файлов: <span className="font-semibold">{resumedFiles.length}</span></p>
<div className="flex flex-wrap justify-center gap-2">
<button
type="button"
onClick={() => {
performUpload(resumedFiles);
setResumedFiles([]);
}}
disabled={uploading}
className={`inline-flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-semibold transition ${
uploading ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-green-600 text-white hover:bg-green-700 cursor-pointer'
}`}
>
<FiUpload />
Продолжить загрузку
</button>
<button
type="button"
onClick={() => {
setResumedFiles([]);
deleteFilesByBucket(bucketNumber).catch((e: unknown) => console.error('Failed to clear files:', e));
}}
disabled={uploading}
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-semibold transition bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-pointer"
>
Отменить
</button>
</div>
</>
) : (
<>
<p className="text-sm text-gray-700 font-semibold">Перетащите файлы сюда</p>
<p className="text-xs text-gray-500">или выберите вручную</p>
<div className="flex flex-wrap justify-center gap-2">
<button
type="button"
onClick={handleClickSelectFiles}
disabled={uploading}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition ${
uploading ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-ospab-primary text-white hover:bg-ospab-primary/90 cursor-pointer'
}`}
>
Выбрать файлы
</button>
<button
type="button"
onClick={handleClickSelectDirectory}
disabled={uploading}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition ${
uploading ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700 cursor-pointer'
}`}
>
Выбрать папку
</button>
</div>
</>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
disabled={uploading}
onChange={handleUploadInput}
/>
<input
ref={directoryInputRef}
type="file"
multiple
className="hidden"
disabled={uploading}
onChange={handleUploadDirectory}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...({ webkitdirectory: '', mozdirectory: '' } as any)}
/>
</div>
{uploading && (
<div className="mt-6 space-y-4 bg-ospab-primary/5 p-4 rounded-lg border border-ospab-primary/20">
{uploadStats.currentFile && (
<div className="text-sm text-gray-700 font-semibold">
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
</div>
)}
{Object.entries(uploadProgress).map(([fileName, progress]: [string, UploadProgress]) => {
const speedMB = (progress.speed / (1024 * 1024)).toFixed(2);
return (
<div key={fileName} className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600 truncate">{fileName}</span>
<span className="text-ospab-primary font-semibold whitespace-nowrap">
{progress.percentage}% {speedMB} MB/s
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-ospab-primary h-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
);
})}
<button
onClick={handleCancelUpload}
className="mt-3 w-full px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition"
>
Отменить загрузку
</button>
</div>
)}
{!uploading && Object.keys(uploadProgress).length === 0 && (
<p className="text-xs text-gray-500 mt-3">Загружаем файлы...</p>
)}
</div>
<div className="space-y-3">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<label className="block text-xs font-semibold text-gray-600 mb-2">Загрузка по URI</label>
<div className="flex gap-2">
<input
type="url"
value={uriUploadUrl}
onChange={(e) => 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 ? (
<button
onClick={handleUriUpload}
disabled={uriUploadLoading || uploading || !uriUploadUrl.trim()}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
uriUploadLoading || uploading || !uriUploadUrl.trim()
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
Загрузить
</button>
) : (
<button
onClick={handleCancelUriUpload}
className="px-4 py-2 rounded-lg text-sm font-semibold bg-red-600 text-white hover:bg-red-700 transition"
>
Отменить
</button>
)}
</div>
{uriUploadLoading && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-gray-600">Загрузка с сервера...</span>
<span className="text-gray-500">Подождите (может занять время)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div className="bg-green-600 h-full animate-pulse" style={{ width: '100%' }} />
</div>
</div>
)}
<p className="text-xs text-gray-500 mt-2">Укажите прямую ссылку на файл для загрузки в бакет.</p>
</div>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="text-sm text-gray-600">Найдено объектов: {objects.length}</span>
{objectPrefix && (
<span className="text-xs text-gray-500 bg-gray-100 px-3 py-1 rounded-full">Фильтр: {objectPrefix}</span>
)}
{selectedCount > 0 && (
<span className="text-xs text-ospab-primary bg-ospab-primary/10 px-3 py-1 rounded-full">
Выбрано объектов: {selectedCount}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleSelectAll}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
disabled={objects.length === 0}
>
{allSelected ? 'Снять выделение' : 'Выделить все'}
</button>
<button
type="button"
onClick={handleClearSelection}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
disabled={selectedCount === 0}
>
Очистить выбор
</button>
<button
type="button"
onClick={handleDeleteObjects}
className="inline-flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-semibold hover:bg-red-100 transition"
disabled={selectedCount === 0}
>
<FiTrash2 />
Удалить выбранные
</button>
</div>
{objectsLoading ? (
<div className="flex justify-center py-12">
<FiRefreshCw className="text-2xl text-ospab-primary animate-spin" />
</div>
) : objects.length === 0 ? (
<div className="text-center text-sm text-gray-500 py-12">
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Выбор</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Ключ</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Размер</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Изменён</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{objects.map((object) => (
<tr key={object.key} className="hover:bg-gray-50">
<td className="px-4 py-2">
<input
type="checkbox"
checked={!!selectedKeys[object.key]}
onChange={() => handleToggleSelection(object.key)}
/>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-700 break-all">{object.key}</td>
<td className="px-4 py-2 text-gray-600">{formatBytes(object.size)}</td>
<td className="px-4 py-2 text-gray-600">{object.lastModified ? formatDate(object.lastModified, true) : '—'}</td>
<td className="px-4 py-2">
<button
type="button"
onClick={() => handleDownloadObject(object)}
className="inline-flex items-center gap-2 px-3 py-1.5 border border-gray-200 rounded-lg text-xs text-gray-600 hover:bg-gray-50"
>
<FiDownload />
Скачать
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{objectsCursor && (
<div className="flex justify-center">
<button
type="button"
onClick={() => loadObjects({ cursor: objectsCursor })}
className="inline-flex items-center gap-2 px-6 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
disabled={objectsLoadingMore}
>
<FiRefreshCw className={objectsLoadingMore ? 'animate-spin' : ''} />
Загрузить ещё
</button>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3 text-sm text-blue-800">
<FiHelpCircle className="text-lg" />
<div>
<p className="font-semibold">Как работает загрузка и скачивание?</p>
<p>
Подробные инструкции в <a href="https://ospab.host/faq/storage/files" target="_blank" rel="noreferrer" className="underline font-semibold">FAQ по файлам</a>.
</p>
</div>
</div>
</section>
);
} else if (activeTab === 'settings') {
tabContent = (
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-start gap-3 text-sm text-gray-700">
<FiHelpCircle className="text-lg text-ospab-primary" />
<div>
<p className="font-semibold">Управление доступом</p>
<p>
Рекомендации и best practices в <a href="https://ospab.host/faq/storage/settings" target="_blank" rel="noreferrer" className="underline font-semibold">FAQ по настройкам бакета</a>.
</p>
</div>
</div>
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">Доступ к MinIO Console</h3>
<p className="text-xs text-gray-500 mt-1">
Здесь можно получить логин и временный пароль для панели управления объектным хранилищем.
Пароль показывается только один раз после генерации.
</p>
</div>
<button
type="button"
onClick={handleGenerateConsoleCredentials}
disabled={consoleCredentialsLoading || bucketActionPending || bucketLoading}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
consoleCredentialsLoading || bucketActionPending || bucketLoading
? 'cursor-not-allowed bg-gray-200 text-gray-500'
: 'bg-ospab-primary text-white hover:bg-ospab-primary/90'
}`}
>
{consoleCredentialsLoading ? <FiRefreshCw className="animate-spin" /> : <FiKey />}
<span>{consoleCredentialsLoading ? 'Создаём...' : 'Сгенерировать пароль'}</span>
</button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-gray-700">Логин: {consoleLoginDisplay}</span>
{consoleLoginValue && (
<button
type="button"
onClick={() => handleCopy(consoleLoginValue, 'Логин консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-semibold text-gray-600 hover:bg-gray-50"
>
<FiCopy />
Копировать
</button>
)}
{consoleUrl && (
<a
href={consoleUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-ospab-primary hover:underline"
>
Открыть MinIO Console
</a>
)}
</div>
{consoleCredentials && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800 space-y-2">
<div className="flex items-center gap-2 font-semibold">
<FiInfo />
Новые данные входа
</div>
<p className="text-xs text-green-700">
Скопируйте пароль сейчас. После закрытия страницы он больше не отобразится.
</p>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-green-900">Логин: {consoleCredentials.login}</span>
<button
type="button"
onClick={() => handleCopy(consoleCredentials.login, 'Логин консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
>
<FiCopy />
Копировать
</button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-green-900">Пароль: {consoleCredentials.password}</span>
<button
type="button"
onClick={() => handleCopy(consoleCredentials.password, 'Пароль консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
>
<FiCopy />
Копировать
</button>
</div>
</div>
)}
{consoleCredentialsError && (
<div className="text-xs text-red-600">
{consoleCredentialsError}
</div>
)}
</div>
<div className="space-y-4">
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">Публичный доступ</h3>
<p className="text-xs text-gray-500 mt-1">Позволяет отдавать файлы по прямым ссылкам без авторизации.</p>
</div>
<button
type="button"
onClick={togglePublic}
disabled={bucketActionPending}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-semibold transition-colors ${
bucketActionPending
? 'opacity-60 cursor-not-allowed border-gray-200 text-gray-400'
: bucket.public
? 'border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{bucketActionPending ? <FiRefreshCw className="animate-spin" /> : null}
<span>{bucket.public ? 'Сделать приватным' : 'Сделать публичным'}</span>
</button>
</div>
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">Версионирование</h3>
<p className="text-xs text-gray-500 mt-1">Сохраняет историю изменений файлов, помогает откатиться при ошибках.</p>
</div>
<button
type="button"
onClick={toggleVersioning}
disabled={bucketActionPending}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-semibold transition-colors ${
bucketActionPending
? 'opacity-60 cursor-not-allowed border-gray-200 text-gray-400'
: bucket.versioning
? 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{bucketActionPending ? <FiRefreshCw className="animate-spin" /> : null}
<span>{bucket.versioning ? 'Отключить версионирование' : 'Включить версионирование'}</span>
</button>
</div>
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">Автопродление</h3>
<p className="text-xs text-gray-500 mt-1">Продлевает подписку автоматически. Подходит для долгосрочных проектов.</p>
</div>
<button
type="button"
onClick={toggleAutoRenew}
disabled={bucketActionPending}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-semibold transition-colors ${
bucketActionPending
? 'opacity-60 cursor-not-allowed border-gray-200 text-gray-400'
: bucket.autoRenew
? 'border-green-200 bg-green-50 text-green-700 hover:bg-green-100'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{bucketActionPending ? <FiRefreshCw className="animate-spin" /> : null}
<span>{bucket.autoRenew ? 'Отключить автопродление' : 'Включить автопродление'}</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs uppercase text-gray-500 mb-1">Текущий тариф</p>
<p className="font-semibold text-gray-800">{bucketPlanName}</p>
<p className="text-xs text-gray-500">Стоимость: {bucketPrice}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs uppercase text-gray-500 mb-1">Следующее списание</p>
<p className="font-semibold text-gray-800">{formatDate(bucket.nextBillingDate)}</p>
<p className="text-xs text-gray-500">Баланс списывается автоматически при продлении.</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-ospab-primary/10 p-3 rounded-xl">
<FiKey className="text-ospab-primary text-xl" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-800">Доступ по ключам</h2>
<p className="text-sm text-gray-500">Создавайте и управляйте access/secret ключами для приложений.</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={newKeyLabel}
onChange={(event) => 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"
/>
<button
type="button"
onClick={handleCreateAccessKey}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
disabled={creatingKey}
>
<FiKey />
{creatingKey ? 'Создаём...' : 'Создать ключ'}
</button>
</div>
{lastCreatedKey && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800 space-y-2">
<div className="flex items-center gap-2 font-semibold">
<FiInfo />
Сохраните данные нового ключа после закрытия страницы секрет недоступен.
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs">Access Key: {lastCreatedKey.accessKey}</span>
<button
type="button"
onClick={() => handleCopy(lastCreatedKey.accessKey, 'Access Key')}
className="inline-flex items-center gap-1 px-2 py-1 border border-blue-200 rounded text-xs"
>
<FiCopy />
Копировать
</button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs">Secret Key: {lastCreatedKey.secretKey}</span>
<button
type="button"
onClick={() => handleCopy(lastCreatedKey.secretKey, 'Secret Key')}
className="inline-flex items-center gap-1 px-2 py-1 border border-blue-200 rounded text-xs"
>
<FiCopy />
Копировать
</button>
</div>
</div>
)}
{accessKeysLoading ? (
<div className="flex justify-center py-8">
<FiRefreshCw className="text-2xl text-ospab-primary animate-spin" />
</div>
) : accessKeys.length === 0 ? (
<div className="text-sm text-gray-500">Ключи доступа не созданы.</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Название</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Access Key</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Создан</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Последнее использование</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{accessKeys.map((key) => (
<tr key={key.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{key.label || 'Без названия'}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-700 break-all">{key.accessKey}</td>
<td className="px-4 py-2 text-gray-600">{formatDate(key.createdAt, true)}</td>
<td className="px-4 py-2 text-gray-600">{formatDate(key.lastUsedAt, true)}</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleCopy(key.accessKey, 'Access Key')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-gray-200 rounded-lg text-xs text-gray-600 hover:bg-gray-50"
>
<FiCopy />
Копировать
</button>
<button
type="button"
onClick={() => handleRevokeAccessKey(key.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-semibold hover:bg-red-100"
disabled={deletingKeys[key.id]}
>
{deletingKeys[key.id] ? <FiRefreshCw className="animate-spin" /> : <FiTrash2 />}
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}
}
return (
<div className="max-w-6xl mx-auto space-y-6">
<button
type="button"
onClick={() => navigate('/dashboard/storage')}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<FiArrowLeft />
Вернуться к списку бакетов
</button>
{bucketError && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-xl p-4 flex items-start gap-3">
<FiInfo className="mt-0.5" />
<div>
<p className="font-semibold">{bucketError}</p>
<p className="text-xs text-red-600">Попробуйте обновить страницу или вернитесь к списку хранилищ.</p>
</div>
</div>
)}
{bucketLoading ? (
<div className="bg-white rounded-xl shadow-md p-12 flex justify-center">
<FiRefreshCw className="text-3xl text-ospab-primary animate-spin" />
</div>
) : bucket ? (
<>
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<div className="bg-ospab-primary/10 p-3 rounded-xl">
<FiDatabase className="text-ospab-primary text-2xl" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold text-gray-800">{bucket.name}</h1>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${bucketPlanTone}`}>
{bucketPlanName}
</span>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${bucketStatusBadge.className}`}>
{bucketStatusBadge.label}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">ID бакета: {bucket.id}</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleRefreshBucket}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition"
disabled={bucketRefreshing || objectsLoading}
>
<FiRefreshCw className={bucketRefreshing ? 'animate-spin' : ''} />
Обновить
</button>
<button
type="button"
onClick={() => navigate('/dashboard/storage')}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 transition"
>
Назад к списку
</button>
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
<span>
Регион: <span className="font-semibold text-gray-700">{bucket.regionDetails?.name ?? bucket.region}</span>
</span>
<span>
Класс хранения: <span className="font-semibold text-gray-700">{bucket.storageClassDetails?.name ?? bucket.storageClass}</span>
</span>
<span>
Объектов: <span className="font-semibold text-gray-700">{bucket.objectCount}</span>
</span>
<span>
Квота: <span className="font-semibold text-gray-700">{bucket.quotaGb} GB</span>
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{TAB_ITEMS.map(({ key, label, icon: Icon }) => {
const isActive = key === activeTab;
return (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-semibold transition-colors ${
isActive
? 'border-ospab-primary bg-ospab-primary text-white shadow'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<Icon className={isActive ? 'text-white' : 'text-ospab-primary'} />
{label}
</button>
);
})}
</div>
{activeTabMeta && (
<p className="text-xs text-gray-500">
<span className="font-semibold text-gray-700">{activeTabMeta.label}:</span> {activeTabMeta.description}
</p>
)}
</section>
{tabContent}
</>
) : (
<div className="bg-white rounded-xl shadow-md p-12 text-center text-sm text-gray-500">
Не удалось получить данные бакета. Обновите страницу или вернитесь к списку хранилищ.
</div>
)}
</div>
);
};
export default StorageBucketPage;