feat: implement bucket and object management dialogs, enhance caching, and update theme colors

This commit is contained in:
Noooste
2025-11-25 18:12:48 +01:00
parent f33452dcb1
commit c6248adde9
51 changed files with 5953 additions and 2107 deletions
+204 -550
View File
@@ -1,15 +1,17 @@
import axios from 'axios';
import {toast} from 'sonner';
import type {
AccessKey,
Bucket,
BucketDetails,
S3Object,
ObjectMetadata,
AccessKey,
StorageMetrics,
ClusterHealth,
ClusterStatistics,
NodeInfo,
GarageMetrics,
NodeInfo,
ObjectListResponse,
ObjectMetadata,
S3Object,
StorageMetrics,
} from '@/types';
// Configure axios instance with base URL
@@ -29,177 +31,256 @@ api.interceptors.request.use((config) => {
return config;
});
// Add response interceptor for error handling
api.interceptors.response.use(
(response) => {
// If response has success=false in data, treat it as an error
if (response.data && response.data.success === false && response.data.error) {
const error = response.data.error;
const errorMessage = error.message || 'An error occurred';
const errorCode = error.code || 'UNKNOWN_ERROR';
// Display toast with error details
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
// Reject the promise so it's treated as an error
return Promise.reject(new Error(errorMessage));
}
return response;
},
(error) => {
// Handle axios errors
if (error.response) {
// Server responded with error status
const data = error.response.data;
if (data && data.error) {
const errorMessage = data.error.message || 'An error occurred';
const errorCode = data.error.code || 'UNKNOWN_ERROR';
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
} else {
// Generic HTTP error
toast.error(`Request failed: ${error.response.status}`, {
description: error.response.statusText || 'Unknown error',
});
}
} else if (error.request) {
// Request made but no response received
toast.error('Network Error', {
description: 'Unable to reach the server. Please check your connection.',
});
} else {
// Something else happened
toast.error('Error', {
description: error.message || 'An unexpected error occurred',
});
}
return Promise.reject(error);
}
);
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
// const response = await api.get('/buckets');
// return response.data;
return mockBuckets; // Using mock data for now
const response = await api.get('/v1/buckets');
return response.data.data.buckets || [];
},
get: async (_name: string): Promise<BucketDetails> => {
// const response = await api.get(`/buckets/${_name}`);
// return response.data;
return mockBucketDetails; // Using mock data for now
get: async (name: string): Promise<BucketDetails> => {
const response = await api.get(`/v1/buckets/${name}`);
return response.data.data;
},
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
// await api.post('/buckets', { name: bucketName, region: bucketRegion });
console.log('Create bucket:', bucketName, bucketRegion);
await api.post('/v1/buckets', { name: bucketName, region: bucketRegion });
},
delete: async (name: string): Promise<void> => {
// await api.delete(`/buckets/${name}`);
console.log('Delete bucket:', name);
await api.delete(`/v1/buckets/${name}`);
},
grantPermission: async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
): Promise<void> => {
await api.post(`/v1/buckets/${bucketName}/permissions`, {
accessKeyId,
permissions,
});
},
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
// await api.put(`/buckets/${name}/settings`, settings);
console.log('Update bucket settings:', name, settings);
// TODO: Implement when backend endpoint is ready
await api.patch(`/v1/buckets/${name}/settings`, settings);
},
};
// Objects API
export const objectsApi = {
list: async (_bucket: string, _prefix?: string): Promise<S3Object[]> => {
// const response = await api.get(`/buckets/${_bucket}/objects`, { params: { prefix: _prefix } });
// return response.data;
list: async (bucket: string, prefix?: string, maxKeys?: number, continuationToken?: string): Promise<ObjectListResponse> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = {};
if (prefix) params.prefix = prefix;
if (maxKeys) params.max_keys = maxKeys;
if (continuationToken) params.continuation_token = continuationToken;
// Filter mock objects by prefix
if (!_prefix) {
return mockObjectsFlat.filter(obj => {
// Include root-level files (no slash) and root-level folders (single slash at end)
return !obj.key.includes('/') || (obj.key.endsWith('/') && !obj.key.slice(0, -1).includes('/'));
});
}
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
const data = response.data.data;
// Get all objects with this prefix, but only direct children
const prefixObjects = mockObjectsFlat.filter(obj => obj.key.startsWith(_prefix));
const directChildren = new Set<string>();
// Combine objects and prefixes (folders)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objects: S3Object[] = data.objects?.map((obj: any) => ({
key: obj.key,
size: obj.size,
lastModified: obj.last_modified,
etag: obj.etag,
storageClass: obj.storage_class,
isFolder: false,
})) || [];
prefixObjects.forEach(obj => {
const remaining = obj.key.slice(_prefix.length);
const parts = remaining.split('/');
const folders: S3Object[] = data.prefixes?.map((prefix: string) => ({
key: prefix,
size: 0,
lastModified: null,
isFolder: true,
})) || [];
if (parts.length > 0 && parts[0]) {
if (parts.length === 1 && !remaining.endsWith('/')) {
// It's a file at this level
directChildren.add(obj.key);
} else if (parts.length > 1 || remaining.endsWith('/')) {
// It's a folder, add the folder path
const folderPath = _prefix + parts[0] + '/';
directChildren.add(folderPath);
}
}
return {
bucket: data.bucket,
objects: [...folders, ...objects],
prefixes: data.prefixes || [],
count: data.count,
isTruncated: data.is_truncated || false,
nextContinuationToken: data.next_continuation_token,
};
},
get: async (bucket: string, key: string): Promise<Blob> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
responseType: 'blob'
});
// Return unique objects
const seen = new Set<string>();
return Array.from(directChildren)
.map(key => {
if (seen.has(key)) return null;
seen.add(key);
return mockObjectsFlat.find(obj => obj.key === key) || {
key,
size: 0,
lastModified: new Date().toISOString(),
isFolder: key.endsWith('/'),
};
})
.filter((obj): obj is S3Object => obj !== null);
return response.data;
},
get: async (_bucket: string, _key: string): Promise<Blob> => {
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}`, { responseType: 'blob' });
// return response.data;
return new Blob(); // Using mock data for now
},
getMetadata: async (_bucket: string, _key: string): Promise<ObjectMetadata> => {
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}/metadata`);
// return response.data;
return mockObjectMetadata; // Using mock data for now
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
return response.data.data;
},
upload: async (bucket: string, key: string, file: File): Promise<void> => {
// const formData = new FormData();
// formData.append('file', file);
// await api.post(`/buckets/${bucket}/objects/${key}`, formData, {
// headers: { 'Content-Type': 'multipart/form-data' },
// });
console.log('Upload object:', bucket, key, file);
const formData = new FormData();
formData.append('file', file);
formData.append('key', key);
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
uploadStream: async (bucket: string, key: string, data: Blob | File, contentType?: string): Promise<void> => {
await api.put(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, data, {
headers: { 'Content-Type': contentType || 'application/octet-stream' },
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uploadMultiple: async (bucket: string, files: File[]): Promise<any> => {
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
const response = await api.post(`/v1/buckets/${bucket}/objects/upload-multiple`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data.data;
},
delete: async (bucket: string, key: string): Promise<void> => {
// await api.delete(`/buckets/${bucket}/objects/${key}`);
console.log('Delete object:', bucket, key);
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[]): Promise<void> => {
// await api.post(`/buckets/${bucket}/objects/delete`, { keys });
console.log('Delete multiple objects:', bucket, keys);
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
const payload = { keys, ...(prefix && { prefix }) };
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
},
getPresignedUrl: async (_bucket: string, _key: string, _expiresIn?: number): Promise<string> => {
// const response = await api.post(`/buckets/${_bucket}/objects/${_key}/presign`, { expiresIn: _expiresIn });
// return response.data.url;
return 'https://example.com/presigned-url'; // Using mock data for now
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
params: { expires_in: expiresIn }
});
return response.data.data.url;
},
};
// Access Control API
// Access Control API (Users/Keys)
export const accessApi = {
listKeys: async (): Promise<AccessKey[]> => {
// const response = await api.get('/access/keys');
// return response.data;
return mockAccessKeys; // Using mock data for now
const response = await api.get('/v1/users');
return response.data.data.users || [];
},
createKey: async (name: string, permissions: any[]): Promise<AccessKey> => {
// const response = await api.post('/access/keys', { name, permissions });
// return response.data;
console.log('Create access key:', name, permissions);
return mockAccessKeys[0]; // Using mock data for now
getKey: async (accessKey: string): Promise<AccessKey> => {
const response = await api.get(`/v1/users/${accessKey}`);
return response.data.data;
},
updateKey: async (keyId: string, updates: Partial<AccessKey>): Promise<void> => {
// await api.put(`/access/keys/${keyId}`, updates);
console.log('Update access key:', keyId, updates);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
const response = await api.post('/v1/users', { name, permissions });
return response.data.data;
},
deleteKey: async (keyId: string): Promise<void> => {
// await api.delete(`/access/keys/${keyId}`);
console.log('Delete access key:', keyId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateKey: async (accessKey: string, updates: any): Promise<void> => {
await api.patch(`/v1/users/${accessKey}`, updates);
},
deleteKey: async (accessKey: string): Promise<void> => {
await api.delete(`/v1/users/${accessKey}`);
},
};
// Analytics API
export const analyticsApi = {
getMetrics: async (): Promise<StorageMetrics> => {
// const response = await api.get('/analytics/metrics');
// return response.data;
return mockMetrics; // Using mock data for now
const response = await api.get('/v1/monitoring/dashboard');
return response.data.data;
},
};
// Garage Admin API
// Garage Cluster & Monitoring API
export const garageApi = {
getClusterHealth: async (): Promise<ClusterHealth> => {
// const response = await api.get('/v2/GetClusterHealth');
// return response.data;
return mockClusterHealth; // Using mock data for now
const response = await api.get('/v1/cluster/health');
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getClusterStatus: async (): Promise<any> => {
const response = await api.get('/v1/cluster/status');
return response.data.data;
},
getClusterStatistics: async (): Promise<ClusterStatistics> => {
// const response = await api.get('/v2/GetClusterStatistics');
// return response.data;
return mockClusterStatistics; // Using mock data for now
const response = await api.get('/v1/cluster/statistics');
return response.data.data;
},
getNodeInfo: async (): Promise<NodeInfo> => {
// const response = await api.get('/v2/GetNodeInfo?node=self');
// return response.data;
return mockNodeInfo; // Using mock data for now
getNodeInfo: async (nodeId: string = 'self'): Promise<NodeInfo> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNodeStatistics: async (nodeId: string): Promise<any> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
return response.data.data;
},
getFullMetrics: async (): Promise<GarageMetrics> => {
@@ -220,446 +301,19 @@ export const garageApi = {
},
};
// Mock data
const mockBuckets: Bucket[] = [
{
name: 'production-assets',
creationDate: '2025-01-15T10:30:00Z',
objectCount: 1247,
size: 524288000,
region: 'us-east-1',
// Monitoring API
export const monitoringApi = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getMetrics: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/metrics');
return response.data.data;
},
{
name: 'user-uploads',
creationDate: '2025-01-10T14:20:00Z',
objectCount: 3892,
size: 1073741824,
region: 'us-east-1',
},
{
name: 'backups',
creationDate: '2025-01-05T08:15:00Z',
objectCount: 156,
size: 2147483648,
region: 'us-west-2',
},
{
name: 'logs',
creationDate: '2024-12-20T16:45:00Z',
objectCount: 8934,
size: 314572800,
region: 'eu-west-1',
},
{
name: 'media-cdn',
creationDate: '2024-12-15T11:00:00Z',
objectCount: 5621,
size: 3221225472,
region: 'ap-southeast-1',
},
];
const mockBucketDetails: BucketDetails = {
...mockBuckets[0],
versioning: true,
encryption: true,
publicAccess: false,
lifecycleRules: [
{
id: 'rule-1',
enabled: true,
prefix: 'temp/',
expirationDays: 30,
},
],
};
const mockObjectsFlat: S3Object[] = [
// Root level files
{
key: 'config.json',
size: 2048,
lastModified: '2025-01-22T09:15:00Z',
etag: 'abc123',
storageClass: 'STANDARD',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkAdminHealth: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/admin-health');
return response.data.data;
},
{
key: 'README.md',
size: 4096,
lastModified: '2025-01-21T14:20:00Z',
etag: 'def456',
storageClass: 'STANDARD',
},
{
key: 'app.log',
size: 1048576,
lastModified: '2025-01-24T11:45:00Z',
etag: 'ghi789',
storageClass: 'STANDARD',
},
// Folder entries
{
key: 'images/',
size: 0,
lastModified: '2025-01-20T10:00:00Z',
isFolder: true,
},
{
key: 'documents/',
size: 0,
lastModified: '2025-01-19T15:30:00Z',
isFolder: true,
},
{
key: 'videos/',
size: 0,
lastModified: '2025-01-18T14:00:00Z',
isFolder: true,
},
{
key: 'backups/',
size: 0,
lastModified: '2025-01-17T08:30:00Z',
isFolder: true,
},
// Images subfolder
{
key: 'images/avatar.png',
size: 256000,
lastModified: '2025-01-20T10:15:00Z',
etag: 'img001',
storageClass: 'STANDARD',
},
{
key: 'images/logo.svg',
size: 12288,
lastModified: '2025-01-20T10:20:00Z',
etag: 'img002',
storageClass: 'STANDARD',
},
{
key: 'images/banner.jpg',
size: 512000,
lastModified: '2025-01-20T10:25:00Z',
etag: 'img003',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/',
size: 0,
lastModified: '2025-01-20T11:00:00Z',
isFolder: true,
},
// Images/thumbnails subfolder
{
key: 'images/thumbnails/avatar-small.png',
size: 32000,
lastModified: '2025-01-20T11:05:00Z',
etag: 'thumb001',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/logo-small.svg',
size: 4096,
lastModified: '2025-01-20T11:10:00Z',
etag: 'thumb002',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/banner-small.jpg',
size: 64000,
lastModified: '2025-01-20T11:15:00Z',
etag: 'thumb003',
storageClass: 'STANDARD',
},
// Documents subfolder
{
key: 'documents/report-2025-01.pdf',
size: 2048000,
lastModified: '2025-01-19T16:00:00Z',
etag: 'doc001',
storageClass: 'STANDARD',
},
{
key: 'documents/report-2025-02.pdf',
size: 2156000,
lastModified: '2025-01-19T16:10:00Z',
etag: 'doc002',
storageClass: 'STANDARD',
},
{
key: 'documents/contract.docx',
size: 128000,
lastModified: '2025-01-19T16:20:00Z',
etag: 'doc003',
storageClass: 'STANDARD',
},
{
key: 'documents/spreadsheet.xlsx',
size: 256000,
lastModified: '2025-01-19T16:30:00Z',
etag: 'doc004',
storageClass: 'STANDARD',
},
{
key: 'documents/archives/',
size: 0,
lastModified: '2025-01-19T17:00:00Z',
isFolder: true,
},
// Documents/archives subfolder
{
key: 'documents/archives/2024-reports.zip',
size: 10485760,
lastModified: '2025-01-19T17:15:00Z',
etag: 'arch001',
storageClass: 'STANDARD',
},
{
key: 'documents/archives/2023-reports.zip',
size: 9437184,
lastModified: '2025-01-19T17:20:00Z',
etag: 'arch002',
storageClass: 'STANDARD',
},
// Videos subfolder
{
key: 'videos/tutorial-intro.mp4',
size: 104857600,
lastModified: '2025-01-18T14:30:00Z',
etag: 'vid001',
storageClass: 'STANDARD',
},
{
key: 'videos/tutorial-advanced.mp4',
size: 157286400,
lastModified: '2025-01-18T14:35:00Z',
etag: 'vid002',
storageClass: 'STANDARD',
},
{
key: 'videos/demo.webm',
size: 52428800,
lastModified: '2025-01-18T14:40:00Z',
etag: 'vid003',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/',
size: 0,
lastModified: '2025-01-18T15:00:00Z',
isFolder: true,
},
// Videos/clips subfolder
{
key: 'videos/clips/intro.mp4',
size: 20971520,
lastModified: '2025-01-18T15:10:00Z',
etag: 'clip001',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/outro.mp4',
size: 20971520,
lastModified: '2025-01-18T15:15:00Z',
etag: 'clip002',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/transition.mp4',
size: 10485760,
lastModified: '2025-01-18T15:20:00Z',
etag: 'clip003',
storageClass: 'STANDARD',
},
// Backups subfolder
{
key: 'backups/db-backup-2025-01-24.sql',
size: 536870912,
lastModified: '2025-01-24T03:00:00Z',
etag: 'backup001',
storageClass: 'STANDARD',
},
{
key: 'backups/db-backup-2025-01-23.sql',
size: 524288000,
lastModified: '2025-01-23T03:00:00Z',
etag: 'backup002',
storageClass: 'STANDARD',
},
{
key: 'backups/app-backup-2025-01-24.tar.gz',
size: 1073741824,
lastModified: '2025-01-24T03:15:00Z',
etag: 'backup003',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/',
size: 0,
lastModified: '2025-01-24T03:30:00Z',
isFolder: true,
},
// Backups/daily subfolder
{
key: 'backups/daily/db-backup-2025-01-24-00.sql',
size: 268435456,
lastModified: '2025-01-24T00:30:00Z',
etag: 'daily001',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-06.sql',
size: 268435456,
lastModified: '2025-01-24T06:30:00Z',
etag: 'daily002',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-12.sql',
size: 268435456,
lastModified: '2025-01-24T12:30:00Z',
etag: 'daily003',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-18.sql',
size: 268435456,
lastModified: '2025-01-24T18:30:00Z',
etag: 'daily004',
storageClass: 'STANDARD',
},
];
const mockObjectMetadata: ObjectMetadata = {
key: 'config.json',
size: 2048,
lastModified: '2025-01-22T09:15:00Z',
contentType: 'application/json',
etag: 'abc123',
metadata: {
'x-amz-meta-author': 'admin',
'x-amz-meta-version': '1.0',
},
};
const mockAccessKeys: AccessKey[] = [
{
accessKeyId: 'GK1234567890ABCDEF',
name: 'Production API Key',
createdAt: '2025-01-10T10:00:00Z',
lastUsed: '2025-01-24T14:30:00Z',
status: 'active',
permissions: [
{
resource: 'production-assets/*',
actions: ['GetObject', 'PutObject'],
effect: 'Allow',
},
],
},
{
accessKeyId: 'GK0987654321FEDCBA',
name: 'Backup Service',
createdAt: '2025-01-05T08:00:00Z',
lastUsed: '2025-01-24T02:00:00Z',
status: 'active',
permissions: [
{
resource: 'backups/*',
actions: ['GetObject', 'PutObject', 'DeleteObject'],
effect: 'Allow',
},
],
},
{
accessKeyId: 'GK5555666677778888',
name: 'Legacy Integration',
createdAt: '2024-11-15T12:00:00Z',
status: 'inactive',
permissions: [
{
resource: 'user-uploads/*',
actions: ['GetObject'],
effect: 'Allow',
},
],
},
];
const mockMetrics: StorageMetrics = {
totalSize: 7282384896,
objectCount: 19850,
bucketCount: 5,
usageByBucket: [
{
bucketName: 'media-cdn',
size: 3221225472,
objectCount: 5621,
percentage: 44.2,
},
{
bucketName: 'backups',
size: 2147483648,
objectCount: 156,
percentage: 29.5,
},
{
bucketName: 'user-uploads',
size: 1073741824,
objectCount: 3892,
percentage: 14.7,
},
{
bucketName: 'production-assets',
size: 524288000,
objectCount: 1247,
percentage: 7.2,
},
{
bucketName: 'logs',
size: 314572800,
objectCount: 8934,
percentage: 4.3,
},
],
requestMetrics: {
getRequests: 145678,
putRequests: 12456,
deleteRequests: 3421,
listRequests: 8934,
period: 'last-24h',
},
};
const mockClusterHealth = {
status: 'Healthy',
connectedNodes: 3,
knownNodes: 3,
healthyStorageNodes: 3,
declaredStorageNodes: 3,
healthyPartitions: 256,
totalPartitions: 256,
};
const mockClusterStatistics = {
timestamp: Date.now(),
uptime: 864000000,
freeform: 'Cluster operating normally',
};
const mockNodeInfo = {
nodeId: 'node-001',
version: '1.0.0',
rustVersion: '1.75.0',
uptime: 864000,
dbSize: 1073741824,
blockReferenceTableSize: 536870912,
blockMetricsTableSize: 268435456,
objectTableSize: 134217728,
objectVersionTableSize: 67108864,
bucketTableSize: 33554432,
bucketAliasTableSize: 16777216,
};
export default api;